Files
datatools-dev/tests/test_license_cli.py
Michael e435103113 feat(license): registration + 1-year licenses + tier scaffolding
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>
2026-05-13 16:54:23 +00:00

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