A complete offline licensing layer (no internet at any step): Core - src/license/ — schema (License, Tier, FeatureFlag), HMAC crypto, JSON storage, LicenseManager singleton with activate/renew/ deactivate/issue_trial. Tier-scaffolded so future SKUs can carve per-tool feature sets without consumer-code edits. - scripts/generate_license.py — creator-only key generator. Mints a DTLIC1: blob the buyer pastes into the activation page. GUI - New activation form component (src/gui/components/activation.py). - hide_streamlit_chrome() now inline-renders the activation form when no valid license is present (every page short-circuits to the form until activated). - Sidebar shows tier + days remaining; renewal warning under 30 days. - New pages/_Activate.py for revisiting the form after activation. CLI - src/license_cli.py — activate / renew / status / trial / deactivate commands. Exempt from the guard. - src/cli_license_guard.py — drop-in guard call added to every tool CLI's main(). Lets --help through; respects DATATOOLS_DEV_MODE. i18n - New activation.* and license.* keys in en.json + es.json (page title, form labels, status badges, renewal warnings, error messages). Pack parity test stays green. Test infrastructure - tests/conftest.py autouse fixture sets DATATOOLS_DEV_MODE=1 so the existing 1916 tests continue to pass. - isolated_license_path / activated_license_manager / unactivated_license_manager fixtures for tests that want to drive the real check. Tests (+79) - tests/test_license.py (40): schema, crypto roundtrip, blob encode/decode, tier→feature mapping, activation flow, name/email mismatch rejection, tamper detection, expiration, renewal, dev-mode bypass. - tests/test_license_cli.py (26): every license_cli command + subprocess tests confirming every tool CLI refuses to run without a license, --help always works, DEV_MODE bypasses. - tests/gui/test_activation.py (13): gate blocks without license, passes with trial, activation form submission unlocks the gate, sidebar status, renewal warning, i18n. Total: 1916 → 1995 tests. All pass under the strict warning filter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
269 lines
10 KiB
Python
269 lines
10 KiB
Python
"""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 TestLicenseCliTrial:
|
|
def test_trial_issues_one_year_license(self, unactivated_license_manager):
|
|
runner = CliRunner()
|
|
result = runner.invoke(license_app, [
|
|
"trial", "--name", "Trial User", "--email", "trial@example.com",
|
|
])
|
|
assert result.exit_code == 0
|
|
assert "Trial issued" in result.stdout
|
|
# And the manager now sees it as active.
|
|
from src.license import LicenseManager
|
|
mgr = LicenseManager()
|
|
assert mgr.is_valid()
|
|
lic = mgr.load()
|
|
assert lic.tier.value == "trial"
|
|
|
|
def test_trial_rejects_bad_email(self, unactivated_license_manager):
|
|
runner = CliRunner()
|
|
result = runner.invoke(license_app, [
|
|
"trial", "--name", "T", "--email", "not-an-email",
|
|
])
|
|
assert result.exit_code == 2
|
|
# ``typer.echo(..., err=True)`` lands in ``result.output`` when
|
|
# ``mix_stderr`` is the default True; ``result.stdout`` only has
|
|
# the bare stdout.
|
|
assert "valid email" in result.output.lower()
|
|
|
|
|
|
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
|