"""Tests for the license CLI + the per-CLI guard. Two layers: 1. ``src/license_cli.py`` commands — ``activate``, ``renew``, ``status``, ``trial``, ``deactivate``. Invoked via Typer's testing helper so we get a clean ``CliRunner.invoke`` interface without spawning subprocesses for every test. 2. ``src/cli_license_guard.py`` — verify that every existing tool CLI refuses to run when no license is present, and that ``--help`` always works regardless of license state. """ from __future__ import annotations import json import os import subprocess import sys from pathlib import Path import pytest from typer.testing import CliRunner from src.license_cli import app as license_app PROJECT_ROOT = Path(__file__).resolve().parent.parent # --------------------------------------------------------------------------- # license_cli commands # --------------------------------------------------------------------------- class TestLicenseCliStatus: def test_status_without_activation_exits_nonzero(self, unactivated_license_manager): runner = CliRunner() result = runner.invoke(license_app, ["status"]) assert result.exit_code == 1 assert "not activated" in result.stdout.lower() def test_status_with_active_license(self, activated_license_manager): runner = CliRunner() result = runner.invoke(license_app, ["status"]) assert result.exit_code == 0 assert "active" in result.stdout.lower() assert "Test User" in result.stdout def test_status_json_emits_valid_json(self, activated_license_manager): runner = CliRunner() result = runner.invoke(license_app, ["status", "--json"]) assert result.exit_code == 0 data = json.loads(result.stdout) assert data["activated"] is True assert data["name"] == "Test User" assert data["tier"] == "core" assert isinstance(data["features"], list) assert data["days_remaining"] >= 0 class TestNoTrialSubcommand: """The ``trial`` subcommand was removed in v1.6 (no free trial — paid licenses only). Pin its absence so a future re-add has to be intentional.""" def test_trial_subcommand_not_registered(self): runner = CliRunner() result = runner.invoke(license_app, ["trial", "--help"]) assert result.exit_code != 0 # Typer surfaces unknown commands with "No such command" or # similar — exact wording varies by version, so we just confirm # it's a usage error, not a successful execution. class TestLicenseCliActivate: def _make_blob(self, name="Buyer", email="buyer@example.com", tier="core"): """Mint a blob via the same machinery scripts/generate_license.py uses.""" from src.license import LicenseManager, Tier from src.license.crypto import encode_blob # Use a throwaway manager (separate path) so we don't trample # the one the test is exercising. from tempfile import mkstemp _, p = mkstemp(suffix=".json") mgr = LicenseManager() mgr._path = Path(p) lic = mgr._mint(name=name, email=email, tier=Tier(tier)) Path(p).unlink(missing_ok=True) return encode_blob(lic.to_dict()) def test_activate_round_trip(self, unactivated_license_manager): blob = self._make_blob() runner = CliRunner() result = runner.invoke(license_app, [ "activate", blob, "--name", "Buyer", "--email", "buyer@example.com", ]) assert result.exit_code == 0 assert "Activated" in result.stdout # State is now active. from src.license import LicenseManager assert LicenseManager().is_valid() def test_activate_rejects_wrong_name(self, unactivated_license_manager): blob = self._make_blob() runner = CliRunner() result = runner.invoke(license_app, [ "activate", blob, "--name", "Wrong Person", "--email", "buyer@example.com", ]) assert result.exit_code == 2 assert "do not match" in result.output.lower() class TestLicenseCliRenew: def test_renew_extends_expiry(self, activated_license_manager): # Mint a longer-duration blob for the same buyer. from src.license import LicenseManager, Tier from src.license.crypto import encode_blob from tempfile import mkstemp _, p = mkstemp(suffix=".json") other = LicenseManager() other._path = Path(p) lic = other._mint( name="Test User", email="test@example.com", tier=Tier.CORE, years=2, ) Path(p).unlink(missing_ok=True) blob = encode_blob(lic.to_dict()) runner = CliRunner() result = runner.invoke(license_app, ["renew", blob]) assert result.exit_code == 0 assert "Renewed" in result.stdout class TestLicenseCliDeactivate: def test_deactivate_with_yes(self, activated_license_manager): runner = CliRunner() result = runner.invoke(license_app, ["deactivate", "--yes"]) assert result.exit_code == 0 assert "Deactivated" in result.stdout from src.license import LicenseManager assert not LicenseManager().is_activated() # --------------------------------------------------------------------------- # Guard tests — every tool CLI refuses to run without a license # --------------------------------------------------------------------------- class TestCliLicenseGuard: """Run each tool CLI as a subprocess so we exercise the real ``main()`` path, including the guard call. We bypass the suite's DEV_MODE bypass by clearing the env var in the subprocess.""" @pytest.fixture def clean_env(self, tmp_path): """Subprocess env: no DEV_MODE, license path in tmp_path.""" env = dict(os.environ) env.pop("DATATOOLS_DEV_MODE", None) env["DATATOOLS_LICENSE_PATH"] = str(tmp_path / "license.json") return env def _run(self, env, *args, expect_success=False): proc = subprocess.run( [sys.executable, "-m", *args], cwd=PROJECT_ROOT, env=env, capture_output=True, text=True, timeout=60, ) if expect_success: assert proc.returncode == 0, ( f"Expected success, got {proc.returncode}\n" f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}" ) return proc @pytest.mark.parametrize("module", [ "src.cli", "src.cli_text_clean", "src.cli_format", "src.cli_missing", "src.cli_column_map", "src.cli_pipeline", "src.cli_analyze", ]) def test_cli_blocked_without_license(self, clean_env, module): # Run with a dummy filename so we'd otherwise be running the # tool; the guard should fire BEFORE typer parses argv. proc = self._run(clean_env, module, "/nonexistent.csv") assert proc.returncode == 2 assert "not activated" in proc.stderr.lower() or "license" in proc.stderr.lower() @pytest.mark.parametrize("module", [ "src.cli", "src.cli_text_clean", "src.cli_format", "src.cli_missing", "src.cli_column_map", "src.cli_pipeline", "src.cli_analyze", ]) def test_help_always_works(self, clean_env, module): # ``--help`` must bypass the guard so users can see usage # before they activate. proc = self._run(clean_env, module, "--help", expect_success=True) assert "usage" in (proc.stdout + proc.stderr).lower() def test_dev_mode_bypasses_guard(self, clean_env, tmp_path): # Set DEV_MODE; the guard should allow the CLI to run (and # then fail on the missing input file with a non-license # error — which is what we're asserting via stderr). env = dict(clean_env) env["DATATOOLS_DEV_MODE"] = "1" proc = self._run(env, "src.cli_analyze", "/nonexistent.csv") # We expect typer / our code to fail on the missing path, # NOT on the license. Look for evidence the license check # was bypassed. assert "not activated" not in proc.stderr.lower() # Either pandas / our io.py raises a FileNotFoundError-ish. combined = (proc.stdout + proc.stderr).lower() assert "no such file" in combined or "not found" in combined or proc.returncode != 0 class TestGuardBypassesHelp: """Sanity check: ``--help`` and friends must skip the guard so the Typer help screen renders even when no license is on disk.""" def test_runs_under_help_flag_without_license(self, tmp_path, monkeypatch): # In-process check via the guard helper. monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False) monkeypatch.setenv("DATATOOLS_LICENSE_PATH", str(tmp_path / "license.json")) from src.license.manager import reset_singleton_for_tests reset_singleton_for_tests() monkeypatch.setattr("sys.argv", ["progname", "--help"]) from src.cli_license_guard import guard # No exception expected: --help bypasses. guard() def test_blocks_under_real_command_without_license( self, tmp_path, monkeypatch, ): monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False) monkeypatch.setenv("DATATOOLS_LICENSE_PATH", str(tmp_path / "license.json")) from src.license.manager import reset_singleton_for_tests reset_singleton_for_tests() monkeypatch.setattr("sys.argv", ["progname", "input.csv", "--apply"]) from src.cli_license_guard import guard with pytest.raises(SystemExit) as ei: guard() assert ei.value.code == 2