diff --git a/.gitignore b/.gitignore index e6442b2..0787951 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ scrapers/.debug/ scrapers/raw_scrapes/ compose.override.yml +config/license.json diff --git a/app/wizard/tiers.py b/app/wizard/tiers.py index cd100d4..81c846f 100644 --- a/app/wizard/tiers.py +++ b/app/wizard/tiers.py @@ -65,3 +65,32 @@ def tier_label(feature: str) -> str: if required is None: return "" return "🔒 Paid" if required == "paid" else "⭐ Premium" + + +def effective_tier( + profile=None, + license_path=None, + public_key_path=None, +) -> str: + """Return the effective tier for this installation. + + Priority: + 1. profile.dev_tier_override (developer mode override) + 2. License JWT verification (offline RS256 check) + 3. "free" (fallback) + + license_path and public_key_path default to production paths when None. + Pass explicit paths in tests to avoid touching real files. + """ + if profile and getattr(profile, "dev_tier_override", None): + return profile.dev_tier_override + + from scripts.license import effective_tier as _license_tier + from pathlib import Path as _Path + + kwargs = {} + if license_path is not None: + kwargs["license_path"] = _Path(license_path) + if public_key_path is not None: + kwargs["public_key_path"] = _Path(public_key_path) + return _license_tier(**kwargs) diff --git a/tests/test_license_tier_integration.py b/tests/test_license_tier_integration.py new file mode 100644 index 0000000..0b78481 --- /dev/null +++ b/tests/test_license_tier_integration.py @@ -0,0 +1,69 @@ +import json +import pytest +from pathlib import Path +from datetime import datetime, timedelta, timezone +from unittest.mock import patch +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +import jwt as pyjwt + + +@pytest.fixture() +def license_env(tmp_path): + """Returns (private_pem, public_path, license_path) for tier integration tests.""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + public_pem = private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + public_path = tmp_path / "public.pem" + public_path.write_bytes(public_pem) + license_path = tmp_path / "license.json" + return private_pem, public_path, license_path + + +def _write_jwt_license(license_path, private_pem, tier="paid", days=30): + now = datetime.now(timezone.utc) + token = pyjwt.encode({ + "sub": "CFG-PRNG-TEST", "product": "peregrine", "tier": tier, + "iat": now, "exp": now + timedelta(days=days), + }, private_pem, algorithm="RS256") + license_path.write_text(json.dumps({"jwt": token, "grace_until": None})) + + +def test_effective_tier_free_without_license(tmp_path): + from app.wizard.tiers import effective_tier + tier = effective_tier( + profile=None, + license_path=tmp_path / "missing.json", + public_key_path=tmp_path / "key.pem", + ) + assert tier == "free" + + +def test_effective_tier_paid_with_valid_license(license_env): + private_pem, public_path, license_path = license_env + _write_jwt_license(license_path, private_pem, tier="paid") + from app.wizard.tiers import effective_tier + tier = effective_tier(profile=None, license_path=license_path, + public_key_path=public_path) + assert tier == "paid" + + +def test_effective_tier_dev_override_takes_precedence(license_env): + """dev_tier_override wins even when a valid license is present.""" + private_pem, public_path, license_path = license_env + _write_jwt_license(license_path, private_pem, tier="paid") + + class FakeProfile: + dev_tier_override = "premium" + + from app.wizard.tiers import effective_tier + tier = effective_tier(profile=FakeProfile(), license_path=license_path, + public_key_path=public_path) + assert tier == "premium"