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>
255 lines
9.6 KiB
Python
255 lines
9.6 KiB
Python
"""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}"
|
|
)
|