"""Tests for the CloakBrowser Pro license module.""" import hashlib import json import os import time from pathlib import Path from unittest.mock import MagicMock, patch import pytest from cloakbrowser.download import BinaryVerificationError, ensure_binary from cloakbrowser.license import ( LicenseInfo, get_pro_latest_version, resolve_license_key, validate_license, ) # ── resolve_license_key ─────────────────────────────── class TestResolveLicenseKey: def test_explicit_param_wins(self): with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}): assert resolve_license_key("explicit") == "explicit" def test_env_var_fallback(self): with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}): assert resolve_license_key() == "env-key" def test_returns_none_when_absent(self): with patch.dict(os.environ, {}, clear=True): os.environ.pop("CLOAKBROWSER_LICENSE_KEY", None) assert resolve_license_key() is None def test_empty_string_param_uses_env(self): with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}): assert resolve_license_key("") == "env-key" def test_file_fallback(self, tmp_path): key_file = tmp_path / "license.key" key_file.write_text("file-key-123\n") with patch.dict(os.environ, {}, clear=True): os.environ.pop("CLOAKBROWSER_LICENSE_KEY", None) with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): assert resolve_license_key() == "file-key-123" def test_env_takes_precedence_over_file(self, tmp_path): key_file = tmp_path / "license.key" key_file.write_text("file-key") with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}): with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): assert resolve_license_key() == "env-key" def test_no_file_returns_none(self, tmp_path): with patch.dict(os.environ, {}, clear=True): os.environ.pop("CLOAKBROWSER_LICENSE_KEY", None) with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): assert resolve_license_key() is None # ── validate_license ────────────────────────────────── class TestValidateLicense: def test_fresh_cache_skips_server(self, tmp_path): cache_path = tmp_path / ".license_cache" key_sha = hashlib.sha256(b"test-key").hexdigest() cache_path.write_text(json.dumps({ "key_sha256": key_sha, "valid": True, "plan": "team", "expires": "2026-12-01", "validated_at": time.time(), })) with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): with patch("cloakbrowser.license.httpx.post") as mock_post: result = validate_license("test-key") mock_post.assert_not_called() assert result is not None assert result.valid is True assert result.plan == "team" def test_stale_cache_calls_server(self, tmp_path): cache_path = tmp_path / ".license_cache" key_sha = hashlib.sha256(b"test-key").hexdigest() cache_path.write_text(json.dumps({ "key_sha256": key_sha, "valid": True, "plan": "solo", "expires": None, "validated_at": time.time() - 90000, # 25 hours ago })) mock_resp = MagicMock() mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None} mock_resp.raise_for_status = MagicMock() with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): with patch("cloakbrowser.license.httpx.post", return_value=mock_resp) as mock_post: result = validate_license("test-key") mock_post.assert_called_once() assert result is not None assert result.valid is True def test_server_success(self, tmp_path): mock_resp = MagicMock() mock_resp.json.return_value = {"valid": True, "plan": "business", "expires": "2026-07-13"} mock_resp.raise_for_status = MagicMock() with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): with patch("cloakbrowser.license.httpx.post", return_value=mock_resp): result = validate_license("pro-key") assert result is not None assert result.valid is True assert result.plan == "business" assert result.expires == "2026-07-13" def test_server_rejection(self, tmp_path): mock_resp = MagicMock() mock_resp.json.return_value = {"valid": False, "plan": "solo", "expires": None} mock_resp.raise_for_status = MagicMock() with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): with patch("cloakbrowser.license.httpx.post", return_value=mock_resp): result = validate_license("bad-key") assert result is not None assert result.valid is False def test_server_unreachable_uses_stale_cache(self, tmp_path): cache_path = tmp_path / ".license_cache" key_sha = hashlib.sha256(b"test-key").hexdigest() cache_path.write_text(json.dumps({ "key_sha256": key_sha, "valid": True, "plan": "solo", "expires": "2026-12-01", "validated_at": time.time() - 90000, })) with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): with patch("cloakbrowser.license.httpx.post", side_effect=Exception("timeout")): result = validate_license("test-key") assert result is not None assert result.valid is True def test_server_unreachable_no_cache_returns_none(self, tmp_path): with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): with patch("cloakbrowser.license.httpx.post", side_effect=Exception("timeout")): result = validate_license("test-key") assert result is None def test_cache_stores_hash_not_raw_key(self, tmp_path): mock_resp = MagicMock() mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None} mock_resp.raise_for_status = MagicMock() with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): with patch("cloakbrowser.license.httpx.post", return_value=mock_resp): validate_license("secret-key-123") cache_path = tmp_path / ".license_cache" content = cache_path.read_text() assert "secret-key-123" not in content expected_sha = hashlib.sha256(b"secret-key-123").hexdigest() assert expected_sha in content def test_expired_license_rejected_from_cache(self, tmp_path): cache_path = tmp_path / ".license_cache" key_sha = hashlib.sha256(b"test-key").hexdigest() cache_path.write_text(json.dumps({ "key_sha256": key_sha, "valid": True, "plan": "solo", "expires": "2020-01-01T00:00:00+00:00", "validated_at": time.time(), })) with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): result = validate_license("test-key") assert result is not None assert result.valid is False def test_expired_license_naive_date_rejected(self, tmp_path): """Date-only string (naive datetime) should also be detected as expired.""" cache_path = tmp_path / ".license_cache" key_sha = hashlib.sha256(b"test-key").hexdigest() cache_path.write_text(json.dumps({ "key_sha256": key_sha, "valid": True, "plan": "solo", "expires": "2020-01-01", "validated_at": time.time(), })) with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): result = validate_license("test-key") assert result is not None assert result.valid is False def test_wrong_key_cache_ignored(self, tmp_path): cache_path = tmp_path / ".license_cache" cache_path.write_text(json.dumps({ "key_sha256": "other-hash", "valid": True, "plan": "solo", "expires": None, "validated_at": time.time(), })) mock_resp = MagicMock() mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None} mock_resp.raise_for_status = MagicMock() with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): with patch("cloakbrowser.license.httpx.post", return_value=mock_resp) as mock_post: validate_license("different-key") mock_post.assert_called_once() def test_corrupted_validated_at_does_not_crash(self, tmp_path): """A non-numeric validated_at must be treated as an absent cache, not crash.""" cache_path = tmp_path / ".license_cache" key_sha = hashlib.sha256(b"test-key").hexdigest() cache_path.write_text(json.dumps({ "key_sha256": key_sha, "valid": True, "plan": "solo", "expires": None, "validated_at": "not-a-number", })) mock_resp = MagicMock() mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None} mock_resp.raise_for_status = MagicMock() with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): with patch("cloakbrowser.license.httpx.post", return_value=mock_resp) as mock_post: result = validate_license("test-key") mock_post.assert_called_once() # corrupted cache ignored → server hit assert result is not None assert result.valid is True # ── get_pro_latest_version ──────────────────────────── class TestGetProLatestVersion: def test_fetches_version(self, tmp_path): mock_resp = MagicMock() mock_resp.json.return_value = {"version": "147.0.1234.5"} mock_resp.raise_for_status = MagicMock() with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): with patch("cloakbrowser.license.httpx.get", return_value=mock_resp): version = get_pro_latest_version() assert version == "147.0.1234.5" def test_rate_limited(self, tmp_path): marker = tmp_path / ".last_pro_version_check" marker.write_text("147.0.1234.5") with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): with patch("cloakbrowser.license.httpx.get") as mock_get: version = get_pro_latest_version() mock_get.assert_not_called() assert version == "147.0.1234.5" def test_network_error_returns_none(self, tmp_path): with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): with patch("cloakbrowser.license.httpx.get", side_effect=Exception("network")): version = get_pro_latest_version() assert version is None # ── Config pro parameter ────────────────────────────── class TestConfigPro: def test_binary_dir_pro_suffix(self, tmp_path): from cloakbrowser.config import get_binary_dir with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): normal = get_binary_dir("147.0.0.0") pro = get_binary_dir("147.0.0.0", pro=True) assert str(normal).endswith("chromium-147.0.0.0") assert str(pro).endswith("chromium-147.0.0.0-pro") def test_binary_dir_default_no_suffix(self, tmp_path): from cloakbrowser.config import get_binary_dir with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): normal = get_binary_dir("147.0.0.0") assert not str(normal).endswith("-pro") def test_effective_version_pro_marker(self, tmp_path): from cloakbrowser.config import get_effective_version, get_platform_tag with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): tag = get_platform_tag() marker = tmp_path / f"latest_pro_version_{tag}" marker.write_text("147.0.5555.1") # Create the binary so effective version returns it from cloakbrowser.config import get_binary_path bp = get_binary_path("147.0.5555.1", pro=True) bp.parent.mkdir(parents=True, exist_ok=True) bp.write_text("fake") version = get_effective_version(pro=True) assert version == "147.0.5555.1" # ── binary_info tier reporting ──────────────────────── class TestBinaryInfoTier: """binary_info() reports tier from the binary actually on disk — NOT from a cached license, which can disagree with what's installed or the active key.""" def test_free_when_no_pro_binary_even_if_license_cached(self, tmp_path): from cloakbrowser.download import binary_info with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}, clear=False): # A valid, fresh license is cached... (tmp_path / ".license_cache").write_text(json.dumps({ "key_sha256": hashlib.sha256(b"cb_x").hexdigest(), "valid": True, "plan": "solo", "expires": None, "validated_at": time.time(), })) # ...but no Pro binary is on disk → must report free, not pro. info = binary_info() assert info["tier"] == "free" def test_pro_when_pro_binary_installed(self, tmp_path): from cloakbrowser.config import get_binary_path, get_platform_tag from cloakbrowser.download import binary_info with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}, clear=False): tag = get_platform_tag() (tmp_path / f"latest_pro_version_{tag}").write_text("147.0.5555.1") bp = get_binary_path("147.0.5555.1", pro=True) bp.parent.mkdir(parents=True, exist_ok=True) bp.write_text("fake") bp.chmod(0o755) info = binary_info() assert info["tier"] == "pro" assert info["version"] == "147.0.5555.1" # ── ensure_binary Pro routing (fail-closed vs fall-back) ────────────────────── class TestEnsureBinaryProRouting: """A valid-license user is NEVER silently downgraded to the free binary. Both a tampering signal (verification failure) and a transient failure (network/server) surface a clear error — they differ only in the message: tampering is re-raised verbatim (security, no 'retry'); transient is rewrapped as an actionable 'Pro binary unavailable, retry' error carrying the cause.""" def test_verification_failure_propagates_verbatim(self): """A BinaryVerificationError must surface verbatim — never reach free.""" with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}, clear=False), \ patch("cloakbrowser.download.get_local_binary_override", return_value=None), \ patch("cloakbrowser.license.resolve_license_key", return_value="cb_x"), \ patch("cloakbrowser.license.validate_license", return_value=LicenseInfo(valid=True, plan="solo", expires=None)), \ patch("cloakbrowser.download._ensure_pro_binary", side_effect=BinaryVerificationError("bad signature")), \ patch("cloakbrowser.download.check_platform_available", side_effect=AssertionError("MUST NOT reach the free-tier path")): with pytest.raises(BinaryVerificationError, match="bad signature"): ensure_binary("cb_x") def test_transient_failure_hard_errors_not_free(self): """A transient Pro failure must surface a clear, actionable error carrying the underlying cause — NOT silently download the free binary.""" with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}, clear=False), \ patch("cloakbrowser.download.get_local_binary_override", return_value=None), \ patch("cloakbrowser.license.resolve_license_key", return_value="cb_x"), \ patch("cloakbrowser.license.validate_license", return_value=LicenseInfo(valid=True, plan="solo", expires=None)), \ patch("cloakbrowser.download._ensure_pro_binary", side_effect=RuntimeError("network blip")), \ patch("cloakbrowser.download.check_platform_available", side_effect=AssertionError("MUST NOT reach the free-tier path")): with pytest.raises(RuntimeError, match="Pro binary unavailable: network blip"): ensure_binary("cb_x")