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>
This commit is contained in:
2026-05-13 16:54:23 +00:00
parent b2c7b94fe9
commit e435103113
27 changed files with 2798 additions and 6 deletions

View File

@@ -1,5 +1,7 @@
"""Shared test fixtures."""
import os
import pandas as pd
import pytest
from pathlib import Path
@@ -7,6 +9,84 @@ from pathlib import Path
SAMPLES_DIR = Path(__file__).parent.parent / "samples"
# ---------------------------------------------------------------------------
# License gating bypass
# ---------------------------------------------------------------------------
#
# Every CLI entry point and every GUI page now requires a valid license,
# but the test suite shouldn't be in the business of paying for or
# generating licenses on every run. The session-scoped autouse fixture
# below sets the dev-mode env var BEFORE any test code (including
# parametrize-time imports) runs, so all 1900+ existing tests continue
# to pass.
#
# Individual license tests that DO want to exercise the real activation
# flow either:
# - clear the env var themselves and point the manager at a tmp file, or
# - use the explicit ``activated_license_manager`` / ``unactivated_license_manager``
# fixtures defined below.
@pytest.fixture(scope="session", autouse=True)
def _enable_license_dev_mode():
"""Bypass license checks for every test by default.
Set in the env so subprocess-based tests (test_install, test_e2e)
inherit it without each test needing to plumb the env var.
"""
previous = os.environ.get("DATATOOLS_DEV_MODE")
os.environ["DATATOOLS_DEV_MODE"] = "1"
try:
yield
finally:
if previous is None:
os.environ.pop("DATATOOLS_DEV_MODE", None)
else:
os.environ["DATATOOLS_DEV_MODE"] = previous
@pytest.fixture
def isolated_license_path(tmp_path, monkeypatch):
"""Point the license manager at a fresh tmp file for one test.
Useful when a test wants to exercise the real activation flow:
create + sign + verify the license bytes in a controlled location
without polluting ``~/.datatools/license.json``.
Also clears the dev-mode bypass so the manager actually consults
the file.
"""
path = tmp_path / "license.json"
monkeypatch.setenv("DATATOOLS_LICENSE_PATH", str(path))
monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False)
# The manager singleton caches its handle across tests; drop it
# so the new env vars take effect.
from src.license.manager import reset_singleton_for_tests
reset_singleton_for_tests()
yield path
reset_singleton_for_tests()
@pytest.fixture
def activated_license_manager(isolated_license_path):
"""Yield a LicenseManager pointed at a tmp file, pre-activated as
a Core user. The license is freshly signed with the current
secret so verification succeeds.
"""
from src.license import LicenseManager, Tier
mgr = LicenseManager()
mgr._mint(name="Test User", email="test@example.com", tier=Tier.CORE)
return mgr
@pytest.fixture
def unactivated_license_manager(isolated_license_path):
"""Yield a LicenseManager pointed at a tmp file with NO license
file. Useful for testing the activation flow + gate behaviour.
"""
from src.license import LicenseManager
return LicenseManager()
@pytest.fixture
def sample_csv_path():
return SAMPLES_DIR / "messy_sales.csv"

View File

@@ -0,0 +1,254 @@
"""GUI activation + license-gate tests.
These exercise the chrome-level gate that ``hide_streamlit_chrome``
installs: when no valid license is on disk, every page renders the
activation form instead of the page body, and tool widgets do NOT
appear. We test against the Deduplicator page since it's the smallest
real-world tool that depends on chrome.
The autouse fixture in ``tests/conftest.py`` sets
``DATATOOLS_DEV_MODE=1``, which the GUI gate respects. Each test
below uses ``monkeypatch`` to clear that env var so the real gate
fires; ``isolated_license_path`` then redirects the manager to a
tmp file.
"""
from __future__ import annotations
import pytest
from streamlit.testing.v1 import AppTest
from .conftest import collected_text, stash_upload, with_language
@pytest.fixture
def no_license_env(monkeypatch, tmp_path):
"""Clear dev mode and point the license at a fresh empty tmp path."""
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()
yield tmp_path / "license.json"
reset_singleton_for_tests()
@pytest.fixture
def trial_license(no_license_env):
"""Pre-activate a 1-year trial license for tests that need to
pass the gate."""
from src.license import LicenseManager, Tier
mgr = LicenseManager()
mgr.issue_trial(name="Test User", email="test@example.com")
yield mgr
class TestGateBlocksWithoutLicense:
"""When no license file exists, every page should render the
activation form and short-circuit the tool body."""
def test_home_renders_activation_form(self, no_license_env, home_app):
home_app.run()
text = collected_text(home_app)
assert "Activate DataTools" in text or "Activar DataTools" in text
def test_dedup_page_does_not_render_tool_widgets(
self, no_license_env, app_factory,
):
app = app_factory("1_Deduplicator")
app.run()
# Without a license, the page should NOT have the dedup-
# specific advanced-options expander or Find Duplicates button.
labels = [b.label for b in app.button]
assert not any("Find Duplicates" in lbl for lbl in labels), (
f"tool widgets leaked past the gate; got: {labels}"
)
def test_activation_form_localizes_to_spanish(
self, no_license_env, home_app,
):
with_language(home_app, "es")
home_app.run()
text = collected_text(home_app)
assert "Activar DataTools" in text
def test_sidebar_shows_not_activated(self, no_license_env, home_app):
home_app.run()
# Sidebar caption "🔒 Not activated".
captions = [c.value for c in home_app.sidebar.caption]
joined = " ".join(captions)
assert "Not activated" in joined or "Sin activar" in joined
class TestGatePassesWithTrialLicense:
def test_home_renders_full_grid(self, trial_license, home_app):
home_app.run()
text = collected_text(home_app)
# With a valid license, the activation form should NOT be the
# primary content; we should see the home title + tool cards.
assert "Data Cleaning Mastery" in text
assert "Activate DataTools" not in text # form not shown inline
def test_sidebar_shows_active_status(self, trial_license, home_app):
home_app.run()
captions = " ".join(c.value for c in home_app.sidebar.caption)
# "Trial · 364 days left" (give or take one).
assert "Trial" in captions or "Prueba" in captions
assert "days left" in captions or "días" in captions
def test_dedup_page_renders_tool_widgets(
self, trial_license, app_factory, small_csv_bytes,
):
app = app_factory("1_Deduplicator")
stash_upload(app, name="messy.csv", data=small_csv_bytes)
app.run()
labels = [b.label for b in app.button]
assert any("Find Duplicates" in lbl for lbl in labels), (
f"tool widgets blocked by gate even with valid license; "
f"got: {labels}"
)
class TestActivationFormSubmission:
"""End-to-end: paste a generated blob into the inline form and
confirm the gate releases on next render."""
def test_paste_blob_activates_and_unlocks(
self, no_license_env, home_app,
):
# Generate a blob the same way scripts/generate_license.py does.
from src.license import LicenseManager, Tier
from src.license.crypto import encode_blob
mint_mgr = LicenseManager()
# Use a separate tmp path so the trial we mint doesn't fight
# with the manager the GUI will use.
from tempfile import mkstemp
from pathlib import Path
_, p = mkstemp(suffix=".json")
mint_mgr._path = Path(p)
lic = mint_mgr._mint(
name="Buyer", email="buyer@example.com", tier=Tier.CORE,
)
blob = encode_blob(lic.to_dict())
# Wipe the temporary mint manager so its file doesn't collide.
mint_mgr.deactivate()
# Now drive the real GUI manager.
home_app.run()
# The activation form is inline in chrome — its widget keys
# are prefixed ``gate_``.
home_app.text_input(key="gate_name").set_value("Buyer").run()
home_app.text_input(key="gate_email").set_value("buyer@example.com").run()
home_app.text_area(key="gate_blob").set_value(blob).run()
# Submit primary form button.
submit = next(
b for b in home_app.button
if b.label in ("Activate", "Apply renewal")
)
submit.click().run()
# After activation the page reruns and the activation form
# should be gone — we should see the home page proper.
text = collected_text(home_app)
assert "Data Cleaning Mastery" in text
def test_trial_button_self_issues_license(
self, no_license_env, home_app,
):
home_app.run()
home_app.text_input(key="gate_name").set_value("Trial").run()
home_app.text_input(key="gate_email").set_value("trial@example.com").run()
# Click the trial button on the same form.
trial_btn = next(
b for b in home_app.button
if "trial" in b.label.lower() or "prueba" in b.label.lower()
)
trial_btn.click().run()
text = collected_text(home_app)
# Successful activation → home page renders fully.
assert "Data Cleaning Mastery" in text
class TestActivationPageDirect:
"""``pages/_Activate.py`` renders the same form regardless of
license state — buyer can revisit it to review or deactivate."""
def test_activate_page_renders_with_valid_license(
self, trial_license, app_factory,
):
app = app_factory("_Activate")
app.run()
text = collected_text(app)
# Page title localized.
assert "Activate DataTools" in text
# Deactivate option only shown after activation.
labels = [b.label for b in app.button]
assert any("Deactivate" in lbl for lbl in labels)
def test_activate_page_renders_without_license(
self, no_license_env, app_factory,
):
app = app_factory("_Activate")
app.run()
text = collected_text(app)
assert "Activate DataTools" in text
# Deactivate button should NOT appear when nothing is active.
labels = [b.label for b in app.button]
assert not any("Deactivate" in lbl for lbl in labels)
class TestSidebarRenewalWarning:
"""A license with <30 days remaining surfaces a sidebar warning."""
def test_renewal_warning_appears_under_30_days(
self, no_license_env, home_app, monkeypatch,
):
# Mint a license with 7 days left.
from datetime import datetime, timedelta, timezone
from src.license import License, LicenseManager, Tier
from src.license import crypto as _crypto
from src.license.features import all_features_for_tier
future = (datetime.now(timezone.utc) + timedelta(days=7)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
lic = License(
name="X", email="x@x.com",
license_key="DT1-CORE-EXPIRING",
tier=Tier.CORE,
features=all_features_for_tier(Tier.CORE),
issued_at="2026-05-13T00:00:00Z",
expires_at=future,
signature="",
)
sig = _crypto.sign(lic.to_canonical_dict())
signed = License(**{**lic.__dict__, "signature": sig})
LicenseManager().save(signed)
home_app.run()
sidebar_warnings = [w.body for w in home_app.sidebar.warning if w.body]
joined = " ".join(sidebar_warnings)
assert "expires in" in joined.lower() or "caduca en" in joined.lower(), (
f"expected renewal warning in sidebar; got: {sidebar_warnings}"
)
class TestLicenseStatusBadgeI18n:
"""Sidebar status badge tier name must localize."""
def test_core_tier_localizes_in_spanish(
self, no_license_env, home_app, monkeypatch,
):
from src.license import LicenseManager, Tier
LicenseManager()._mint(
name="X", email="x@x.com", tier=Tier.CORE,
)
with_language(home_app, "es")
home_app.run()
captions = " ".join(c.value for c in home_app.sidebar.caption)
# The es pack maps ``license.tier_core`` to "Core" — same word
# in Spanish — but the surrounding template (``días restantes``)
# localizes.
assert "días restantes" in captions, (
f"Spanish status label missing; sidebar captions: {captions}"
)

430
tests/test_license.py Normal file
View File

@@ -0,0 +1,430 @@
"""Unit tests for the license layer.
Covers:
- Schema: License dataclass roundtrip + expiration helpers.
- Crypto: HMAC sign/verify, tamper detection, blob encode/decode.
- Manager: activation, renewal, deactivation, feature gating,
expiration handling, dev-mode bypass, name/email mismatch
rejection.
The session-scoped autouse fixture in ``conftest.py`` sets
``DATATOOLS_DEV_MODE=1`` for the suite. Tests in this file that need
the real check explicitly use the ``isolated_license_path`` fixture
which clears it.
"""
from __future__ import annotations
import json
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from src.license import (
ExpiredLicenseError,
FeatureFlag,
InvalidLicenseError,
License,
LicenseError,
LicenseManager,
NotActivatedError,
Tier,
UnsupportedFeatureError,
)
from src.license.crypto import (
_DEFAULT_SECRET,
decode_blob,
encode_blob,
sign,
verify,
)
from src.license.features import FEATURES_BY_TIER, all_features_for_tier
from src.license.schema import default_expiry_iso
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
class TestLicenseSchema:
def _make(self, **overrides) -> License:
defaults = dict(
name="Jane Doe",
email="jane@example.com",
license_key="DT1-CORE-AAAA-BBBB",
tier=Tier.CORE,
features=("01_deduplicator",),
issued_at="2026-05-13T00:00:00Z",
expires_at="2027-05-13T00:00:00Z",
signature="deadbeef",
)
defaults.update(overrides)
return License(**defaults)
def test_to_dict_roundtrip(self):
lic = self._make()
again = License.from_dict(lic.to_dict())
assert again == lic
def test_canonical_dict_excludes_signature(self):
lic = self._make()
canon = lic.to_canonical_dict()
assert "signature" not in canon
assert canon["name"] == "Jane Doe"
def test_is_expired_false_when_future(self):
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
lic = self._make(expires_at=future)
assert not lic.is_expired()
assert lic.days_remaining() >= 29
def test_is_expired_true_when_past(self):
past = (datetime.now(timezone.utc) - timedelta(days=1)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
lic = self._make(expires_at=past)
assert lic.is_expired()
def test_has_feature_accepts_string_and_enum(self):
lic = self._make(features=("01_deduplicator", "02_text_cleaner"))
assert lic.has_feature("01_deduplicator")
assert lic.has_feature(FeatureFlag.TEXT_CLEANER)
assert not lic.has_feature(FeatureFlag.PIPELINE_RUNNER)
def test_default_expiry_one_year_default(self):
now = datetime(2026, 5, 13, tzinfo=timezone.utc)
exp = default_expiry_iso(now=now)
# One year from 2026-05-13 is 2027-05-13 (2027 not a leap year).
assert exp.startswith("2027-05-13")
def test_default_expiry_leap_day_fallback(self):
# Feb 29 + 1y where target year (2027) isn't a leap year — we
# slide to Feb 28. Pin that contract.
leap = datetime(2024, 2, 29, tzinfo=timezone.utc)
exp = default_expiry_iso(years=3, now=leap)
# 2024 + 3 = 2027; not a leap year.
assert exp.startswith("2027-02-28")
# ---------------------------------------------------------------------------
# Crypto
# ---------------------------------------------------------------------------
class TestSignAndVerify:
def test_sign_is_deterministic(self):
payload = {"a": 1, "b": "hello"}
assert sign(payload) == sign(payload)
def test_verify_accepts_matching_signature(self):
payload = {"a": 1, "b": "hello"}
sig = sign(payload)
assert verify(payload, sig) is True
def test_verify_rejects_modified_payload(self):
payload = {"a": 1, "b": "hello"}
sig = sign(payload)
modified = dict(payload, b="goodbye")
assert verify(modified, sig) is False
def test_verify_rejects_modified_signature(self):
payload = {"a": 1}
sig = sign(payload)
# Flip one nibble.
bad = sig[:-1] + ("0" if sig[-1] != "0" else "1")
assert verify(payload, bad) is False
def test_sign_respects_secret_env_override(self, monkeypatch):
payload = {"a": 1}
monkeypatch.setenv("DATATOOLS_LICENSE_SECRET", "alternate")
alt = sign(payload)
monkeypatch.delenv("DATATOOLS_LICENSE_SECRET", raising=False)
default = sign(payload)
assert alt != default
def test_canonical_form_is_key_order_invariant(self):
a = {"x": 1, "y": 2}
b = {"y": 2, "x": 1}
assert sign(a) == sign(b)
class TestBlobEncodeDecode:
def test_roundtrip(self):
payload = {"name": "Jane", "tier": "core", "signature": "abc"}
blob = encode_blob(payload)
again = decode_blob(blob)
assert again == payload
def test_blob_has_human_readable_prefix(self):
blob = encode_blob({"x": 1})
assert blob.startswith("DTLIC1:")
def test_decode_rejects_missing_prefix(self):
with pytest.raises(ValueError, match="DTLIC1"):
decode_blob("not-a-blob")
def test_decode_rejects_bad_base64(self):
with pytest.raises(ValueError, match="base64"):
decode_blob("DTLIC1:!!!notbase64!!!")
def test_decode_rejects_truncated_blob(self):
blob = encode_blob({"x": 1})
truncated = blob[:-5]
with pytest.raises(ValueError):
decode_blob(truncated)
# ---------------------------------------------------------------------------
# Features
# ---------------------------------------------------------------------------
class TestFeatures:
def test_every_tier_has_features(self):
for tier in Tier:
assert FEATURES_BY_TIER[tier], (
f"tier {tier!r} has an empty feature set"
)
def test_all_features_for_tier_returns_sorted_tuple(self):
flags = all_features_for_tier(Tier.CORE)
assert flags == tuple(sorted(flags))
def test_core_unlocks_every_tool(self):
"""v1 SKU contract: Core = all 9 tools."""
flags = set(all_features_for_tier(Tier.CORE))
assert {f.value for f in FeatureFlag} <= flags
# ---------------------------------------------------------------------------
# Manager: activation flow
# ---------------------------------------------------------------------------
class TestManagerActivation:
def test_first_load_returns_none_when_no_file(
self, unactivated_license_manager,
):
assert unactivated_license_manager.load() is None
assert not unactivated_license_manager.is_activated()
assert not unactivated_license_manager.is_valid()
def test_issue_trial_writes_file_and_returns_license(
self, unactivated_license_manager, isolated_license_path,
):
lic = unactivated_license_manager.issue_trial(
name="Trial User", email="trial@example.com",
)
assert lic.tier == Tier.TRIAL
assert lic.name == "Trial User"
assert isolated_license_path.exists()
def test_trial_signature_round_trips(
self, unactivated_license_manager, isolated_license_path,
):
unactivated_license_manager.issue_trial(
name="A", email="a@b.com",
)
mgr2 = LicenseManager()
lic2 = mgr2.load()
assert lic2 is not None
assert lic2.name == "A"
def test_activate_from_blob_round_trips(
self, unactivated_license_manager,
):
# Use the manager itself to mint then re-activate from blob.
mgr = unactivated_license_manager
mgr.issue_trial(name="Buyer", email="buyer@example.com")
lic = mgr.load()
# Re-encode as if shipped via Gumroad.
blob = encode_blob(lic.to_dict())
# Deactivate then re-activate from the blob.
mgr.deactivate()
mgr2 = LicenseManager()
again = mgr2.activate_from_blob(blob, name="Buyer", email="buyer@example.com")
assert again.license_key == lic.license_key
def test_activate_rejects_wrong_name(self, unactivated_license_manager):
mgr = unactivated_license_manager
mgr.issue_trial(name="Buyer", email="buyer@example.com")
lic = mgr.load()
blob = encode_blob(lic.to_dict())
mgr.deactivate()
with pytest.raises(InvalidLicenseError, match="do not match"):
mgr.activate_from_blob(
blob, name="Different Person", email="buyer@example.com",
)
def test_activate_rejects_wrong_email(self, unactivated_license_manager):
mgr = unactivated_license_manager
mgr.issue_trial(name="Buyer", email="buyer@example.com")
lic = mgr.load()
blob = encode_blob(lic.to_dict())
mgr.deactivate()
with pytest.raises(InvalidLicenseError, match="do not match"):
mgr.activate_from_blob(
blob, name="Buyer", email="someone-else@example.com",
)
def test_activate_rejects_tampered_blob(self, unactivated_license_manager):
mgr = unactivated_license_manager
mgr.issue_trial(name="Buyer", email="buyer@example.com")
lic = mgr.load()
# Tamper: bump tier to enterprise without re-signing.
raw = lic.to_dict()
raw["tier"] = "enterprise"
bad = encode_blob(raw)
mgr.deactivate()
with pytest.raises(InvalidLicenseError, match="signature"):
mgr.activate_from_blob(
bad, name="Buyer", email="buyer@example.com",
)
def test_activate_rejects_invalid_email_format(
self, unactivated_license_manager,
):
mgr = unactivated_license_manager
with pytest.raises(InvalidLicenseError, match="valid email"):
mgr.activate_from_blob("anything", name="x", email="not-an-email")
def test_deactivate_returns_false_when_no_file(
self, unactivated_license_manager,
):
assert unactivated_license_manager.deactivate() is False
def test_deactivate_returns_true_after_activation(
self, unactivated_license_manager,
):
unactivated_license_manager.issue_trial(
name="A", email="a@b.com",
)
assert unactivated_license_manager.deactivate() is True
# ---------------------------------------------------------------------------
# Manager: expiration + renewal
# ---------------------------------------------------------------------------
class TestExpirationAndRenewal:
def test_is_valid_false_when_expired(
self, unactivated_license_manager, isolated_license_path,
):
# Mint a license with an expiry in the past.
from src.license import crypto as _crypto
past = (datetime.now(timezone.utc) - timedelta(days=2)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
lic = License(
name="X", email="x@x.com",
license_key="DT1-CORE-XXXX-YYYY",
tier=Tier.CORE,
features=all_features_for_tier(Tier.CORE),
issued_at="2025-01-01T00:00:00Z",
expires_at=past,
signature="",
)
sig = _crypto.sign(lic.to_canonical_dict())
signed = License(
**{**lic.__dict__, "signature": sig},
)
unactivated_license_manager.save(signed)
mgr2 = LicenseManager()
assert mgr2.is_activated()
assert not mgr2.is_valid()
state = mgr2.current_state()
assert state.error_kind == "expired"
def test_renew_extends_expiry(
self, unactivated_license_manager,
):
mgr = unactivated_license_manager
old = mgr.issue_trial(name="A", email="a@b.com", years=1)
# Mint a fresh blob with a longer expiry.
mgr2 = LicenseManager()
new = mgr2._mint(name="A", email="a@b.com", tier=Tier.CORE, years=2)
blob = encode_blob(new.to_dict())
# Renew via the manager.
renewed = mgr.renew(blob)
assert renewed.tier == Tier.CORE
assert renewed.expires_dt > old.expires_dt
def test_renew_rejects_for_different_buyer(
self, unactivated_license_manager,
):
mgr = unactivated_license_manager
mgr.issue_trial(name="A", email="a@b.com")
# Mint a blob for a DIFFERENT buyer.
other = LicenseManager()
# Use a separate path so other doesn't overwrite a's file.
from tempfile import mkstemp
_, p = mkstemp(suffix=".json")
other._path = Path(p)
other_lic = other._mint(name="B", email="b@c.com", tier=Tier.CORE)
blob = encode_blob(other_lic.to_dict())
with pytest.raises(InvalidLicenseError, match="different name/email"):
mgr.renew(blob)
# ---------------------------------------------------------------------------
# Manager: feature gating
# ---------------------------------------------------------------------------
class TestFeatureGating:
def test_require_feature_passes_on_valid_license(
self, activated_license_manager,
):
# CORE unlocks every flag in v1.
for flag in FeatureFlag:
activated_license_manager.require_feature(flag)
def test_require_feature_raises_not_activated(
self, unactivated_license_manager,
):
with pytest.raises(NotActivatedError):
unactivated_license_manager.require_feature(
FeatureFlag.DEDUPLICATOR,
)
def test_require_feature_returns_license(
self, activated_license_manager,
):
lic = activated_license_manager.require_feature(
FeatureFlag.DEDUPLICATOR,
)
assert lic.name == "Test User"
# ---------------------------------------------------------------------------
# Manager: dev mode
# ---------------------------------------------------------------------------
class TestDevMode:
def test_dev_mode_bypasses_validity_check(
self, isolated_license_path, monkeypatch,
):
monkeypatch.setenv("DATATOOLS_DEV_MODE", "1")
mgr = LicenseManager()
assert mgr.is_valid() is True
# No license file exists.
assert not isolated_license_path.exists()
def test_dev_mode_state_reports_synthetic_license(
self, isolated_license_path, monkeypatch,
):
monkeypatch.setenv("DATATOOLS_DEV_MODE", "1")
mgr = LicenseManager()
state = mgr.current_state()
assert state.activated is True
assert state.valid is True
assert state.tier == "enterprise"
assert state.error_kind == ""
def test_dev_mode_off_in_test_default_env_via_explicit_clear(
self, isolated_license_path, monkeypatch,
):
# ``isolated_license_path`` already clears DEV_MODE; double-
# check that contract here so the broader suite can rely on it.
assert "DATATOOLS_DEV_MODE" not in os.environ

268
tests/test_license_cli.py Normal file
View File

@@ -0,0 +1,268 @@
"""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