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>
128 lines
4.1 KiB
Python
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
|