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:
@@ -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"
|
||||
|
||||
254
tests/gui/test_activation.py
Normal file
254
tests/gui/test_activation.py
Normal 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
430
tests/test_license.py
Normal 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
268
tests/test_license_cli.py
Normal 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
|
||||
Reference in New Issue
Block a user