Backend: - tiers.py: real license validation via Heimdall (self-hosted key or cloud user_id), 30-min cache, BYOK exception for translation - session_store.py: create_session() accepts tier/user_id/window_ms/transcribe_lang/ num_speakers; tier-aware _should_reap() (Free: reap on subscriber leave, Paid: activity TTL); auto-snapshot paid sessions with history on reap; voice-ready probe on session start - services/translator.py: DeepL-backed label translation, CF-managed and BYOK key paths, per-session label cache - models/session.py, scene_event.py, service_event.py: new event types and session fields - api/snapshots.py: GET /sessions/history + POST /session/resume (Paid tier only) - api/dev.py: dev-only /dev/voice/stop|start|restart proxy to cf-orch agent - migrations/002_session_snapshots.sql: session_snapshots table Frontend: - ComposeBar.vue: pre-session settings panel (window, language, speakers, Elcor toggle) with slide transition; passes options to startSession() - stores/settings.ts: persistent user preferences (localStorage), survives page reload - stores/session.ts: voiceReady/voiceError/voiceWarning states; SceneEvent/AccentEvent/ ServiceEvent types; TTL timers for environ/queue badges; startSession() accepts opts - composables/useToneStream.ts: handlers for scene-event, accent-event, service-event SSE - NowPanel.vue: diarization speaker identity badge, acoustic scene badge, privacy risk indicator - HistoryStrip.vue: per-speaker colour chips in history strip - components/DevPanel.vue: dev-only cf-voice stop/restart controls + thread view toggle - App.vue: DevPanel in footer (DEV only), threadView flag switches v0.1.x/v0.2.x preview Tests: - tests/test_pinning.py: 20 tests covering tier-aware reaping, snapshot creation, and translator behaviour - tests/test_tiers.py: 17 tests covering key validation, cloud resolution, caching, require_paid() gate, BYOK exception - All 72 tests passing
156 lines
6.2 KiB
Python
156 lines
6.2 KiB
Python
# tests/test_tiers.py — tier gate and Heimdall validation logic
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app import tiers
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_tier_cache():
|
|
"""Wipe the in-process tier cache between tests."""
|
|
tiers._cache.clear()
|
|
yield
|
|
tiers._cache.clear()
|
|
|
|
|
|
# ── Key-based resolution ──────────────────────────────────────────────────────
|
|
|
|
def _mock_verify(tier: str, valid: bool = True):
|
|
"""Return a mock requests.Response for /v1/licenses/verify."""
|
|
resp = MagicMock()
|
|
resp.ok = valid
|
|
resp.json.return_value = {"valid": valid, "tier": tier, "user_id": "u123"}
|
|
return resp
|
|
|
|
|
|
def test_get_tier_free_when_no_key(monkeypatch):
|
|
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
|
assert tiers.get_tier() == "free"
|
|
|
|
|
|
def test_get_tier_paid_via_key(monkeypatch):
|
|
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
|
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")) as mock_post:
|
|
result = tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
|
assert result == "paid"
|
|
mock_post.assert_called_once()
|
|
assert "/v1/licenses/verify" in mock_post.call_args[0][0]
|
|
|
|
|
|
def test_get_tier_free_on_invalid_key(monkeypatch):
|
|
with patch("app.tiers.requests.post", return_value=_mock_verify("free", valid=False)):
|
|
result = tiers.get_tier(license_key="CFG-LNNT-DEAD-BEEF-0000")
|
|
assert result == "free"
|
|
|
|
|
|
def test_get_tier_free_on_heimdall_error(monkeypatch):
|
|
with patch("app.tiers.requests.post", side_effect=Exception("timeout")):
|
|
result = tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
|
assert result == "free"
|
|
|
|
|
|
def test_get_tier_uses_env_key(monkeypatch):
|
|
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "CFG-LNNT-ENV0-ENV0-ENV0")
|
|
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")) as mock_post:
|
|
result = tiers.get_tier()
|
|
assert result == "paid"
|
|
assert mock_post.call_args[1]["json"]["key"] == "CFG-LNNT-ENV0-ENV0-ENV0"
|
|
|
|
|
|
def test_key_result_is_cached(monkeypatch):
|
|
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
|
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")) as mock_post:
|
|
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
|
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
|
# Second call must hit cache, not Heimdall
|
|
assert mock_post.call_count == 1
|
|
|
|
|
|
def test_network_error_does_not_cache(monkeypatch):
|
|
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
|
with patch("app.tiers.requests.post", side_effect=Exception("timeout")) as mock_post:
|
|
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
|
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
|
# Network errors are NOT cached — both calls hit Heimdall
|
|
assert mock_post.call_count == 2
|
|
|
|
|
|
# ── Cloud user resolution ─────────────────────────────────────────────────────
|
|
|
|
def _mock_cloud_resolve(tier: str):
|
|
resp = MagicMock()
|
|
resp.ok = True
|
|
resp.json.return_value = {"tier": tier, "user_id": "u456"}
|
|
return resp
|
|
|
|
|
|
def test_get_tier_cloud_paid(monkeypatch):
|
|
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "tok-admin")
|
|
with patch("app.tiers.requests.post", return_value=_mock_cloud_resolve("paid")) as mock_post:
|
|
result = tiers.get_tier(user_id="user-uuid-123")
|
|
assert result == "paid"
|
|
assert "/admin/cloud/resolve" in mock_post.call_args[0][0]
|
|
|
|
|
|
def test_get_tier_cloud_free_when_no_admin_token(monkeypatch):
|
|
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "")
|
|
result = tiers.get_tier(user_id="user-uuid-123")
|
|
assert result == "free"
|
|
|
|
|
|
def test_user_id_takes_precedence_over_key(monkeypatch):
|
|
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "tok-admin")
|
|
with patch("app.tiers.requests.post", return_value=_mock_cloud_resolve("premium")) as mock_post:
|
|
result = tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC", user_id="u999")
|
|
assert result == "premium"
|
|
assert "/admin/cloud/resolve" in mock_post.call_args[0][0]
|
|
|
|
|
|
def test_cloud_result_is_cached(monkeypatch):
|
|
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "tok-admin")
|
|
with patch("app.tiers.requests.post", return_value=_mock_cloud_resolve("paid")) as mock_post:
|
|
tiers.get_tier(user_id="u-cached")
|
|
tiers.get_tier(user_id="u-cached")
|
|
assert mock_post.call_count == 1
|
|
|
|
|
|
# ── is_paid() and require_paid() ─────────────────────────────────────────────
|
|
|
|
def test_is_paid_true_for_paid_tier(monkeypatch):
|
|
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
|
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")):
|
|
assert tiers.is_paid(license_key="CFG-LNNT-AAAA-BBBB-CCCC") is True
|
|
|
|
|
|
def test_is_paid_true_for_premium_tier(monkeypatch):
|
|
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
|
with patch("app.tiers.requests.post", return_value=_mock_verify("premium")):
|
|
assert tiers.is_paid(license_key="CFG-LNNT-PREM-PREM-PREM") is True
|
|
|
|
|
|
def test_is_paid_false_for_free(monkeypatch):
|
|
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
|
assert tiers.is_paid() is False
|
|
|
|
|
|
def test_require_paid_raises_402_for_free():
|
|
with patch("app.tiers.get_tier", return_value="free"):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
tiers.require_paid()
|
|
assert exc_info.value.status_code == 402
|
|
|
|
|
|
def test_require_paid_passes_for_paid():
|
|
with patch("app.tiers.get_tier", return_value="paid"):
|
|
tiers.require_paid() # must not raise
|
|
|
|
|
|
def test_require_paid_byok_skips_check():
|
|
# byok_deepl=True bypasses the tier gate regardless of tier
|
|
with patch("app.tiers.get_tier", return_value="free") as mock_get:
|
|
tiers.require_paid(byok_deepl=True) # must not raise
|
|
mock_get.assert_not_called()
|