Files
datatools-dev/tests/gui/test_activation.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

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}"
)