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

128 lines
4.1 KiB
Python

"""Shared test fixtures."""
import os
import pandas as pd
import pytest
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"
@pytest.fixture
def sample_df(sample_csv_path):
return pd.read_csv(sample_csv_path, dtype=str, keep_default_na=False)
@pytest.fixture
def simple_df():
"""Small DataFrame with obvious duplicates for unit testing."""
return pd.DataFrame({
"name": ["Alice", "alice", "Bob", "Charlie", "ALICE"],
"email": ["alice@test.com", "alice@test.com", "bob@test.com",
"charlie@test.com", "alice@test.com"],
"phone": ["555-1234", "555-1234", "555-5678", "555-9012", "555-1234"],
})
@pytest.fixture
def merge_df():
"""DataFrame with partial records that benefit from merge."""
return pd.DataFrame({
"name": ["John Doe", "John Doe", "Jane Smith"],
"email": ["john@test.com", "john@test.com", "jane@test.com"],
"phone": ["555-1111", "", "555-3333"],
"address": ["", "123 Main St", "456 Oak Ave"],
})
@pytest.fixture
def tmp_csv(tmp_path, simple_df):
"""Write simple_df to a temp CSV and return the path."""
path = tmp_path / "test_input.csv"
simple_df.to_csv(path, index=False)
return path