Files
anonymisation/gui_v6/license_client.py
Domi31tls a6ee68a8a3 feat(gui): add GUI V6 G1 foundation (license client/store, shell, About tab)
Socle de la refonte GUI V6 (couche présentation uniquement, aucune logique de
détection) :
- license_store: stockage licence hors dépôt (%LOCALAPPDATA%/Aivanov | XDG),
  read/write atomique/delete, ne journalise aucun token
- license_client: LicenseStatus + activate/check/local_status, session HTTP
  injectable, serveur indisponible géré sans crash, aucune clé privée
- theme: 4 thèmes + couleurs de statut licence
- app + tab_about: shell customtkinter minimal (header, bandeau licence,
  3 onglets), onglet À propos étoffé
- Pseudonymisation_Gui_V6.py: point d'entrée + --self-test (exit 0 sans fenêtre)
- requirements.txt: customtkinter==5.2.2

Tests: 20 nouveaux (store sur vrais fichiers, client sur session injectée).
Suite tests/unit: 167 passed, 0 régression. V5/moteur/managers/specs intacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:50:23 +02:00

196 lines
7.0 KiB
Python

"""Client licence de la GUI V6 (mockable, sans clé privée).
Contrat final aligné sur le portail ``app_aivanov`` :
- ``activate(token, machine_id)`` → ``POST /api/v1/activate``
- ``check(license_ref, machine_id)`` → ``POST /api/v1/check``
Principes :
- La session HTTP est **injectable** (paramètre ``session``) : en test on
fournit une fausse session, aucun appel réseau réel n'est effectué.
- Le client **ne contient aucune clé privée** : la vérification de signature
est faite côté serveur ; le client stocke seulement la licence signée reçue.
- Serveur indisponible / réponse illisible → statut ``unavailable``, jamais
d'exception remontée à l'appelant (la GUI ne doit pas crasher).
- Aucun token n'est journalisé.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional, Protocol
from gui_v6.license_store import LicenseStore
# Statuts métier exposés à l'UI.
ACTIVE = "active"
GRACE = "grace"
EXPIRED = "expired"
REVOKED = "revoked"
NONE = "none"
UNAVAILABLE = "unavailable"
INVALID = "invalid"
_VALID_STATES = {ACTIVE, GRACE}
class _HttpResponse(Protocol):
status_code: int
def json(self) -> Any: ...
class _HttpSession(Protocol):
def post(self, url: str, json: dict, timeout: float) -> _HttpResponse: ...
@dataclass
class LicenseStatus:
"""État de licence destiné à l'affichage et aux décisions de la GUI."""
valid: bool
status: str
message: str = ""
expires_at: Optional[str] = None
grace_days: int = 0
machine_id: Optional[str] = None
license_ref: Optional[str] = None
@classmethod
def none(cls, message: str = "Aucune licence active") -> "LicenseStatus":
return cls(valid=False, status=NONE, message=message)
@classmethod
def unavailable(
cls, message: str = "Serveur de licence indisponible"
) -> "LicenseStatus":
return cls(valid=False, status=UNAVAILABLE, message=message)
@classmethod
def invalid(cls, message: str = "Licence ou jeton invalide") -> "LicenseStatus":
return cls(valid=False, status=INVALID, message=message)
@classmethod
def from_payload(
cls, payload: dict[str, Any], machine_id: Optional[str] = None
) -> "LicenseStatus":
"""Construit un statut depuis la réponse API ou un ancien payload plat.
Le portail renvoie ``{"state": "...", "license": {"payload": ...}}``.
Les payloads plats ``{"status": "...", "license_ref": ...}`` restent
acceptés pour garder les tests/unités locales simples et robustes.
"""
status = str(payload.get("state") or payload.get("status") or NONE)
license_envelope = payload.get("license")
signed_payload = (
license_envelope.get("payload")
if isinstance(license_envelope, dict)
and isinstance(license_envelope.get("payload"), dict)
else {}
)
source = signed_payload if signed_payload else payload
try:
grace = int(source.get("grace_days", payload.get("grace_days", 0)) or 0)
except (TypeError, ValueError):
grace = 0
return cls(
valid=status in _VALID_STATES,
status=status,
message=str(payload.get("message", "") or ""),
expires_at=source.get("expires_at") or payload.get("expires_at"),
grace_days=grace,
machine_id=source.get("machine_id") or payload.get("machine_id") or machine_id,
license_ref=source.get("license_ref") or payload.get("license_ref"),
)
class LicenseClient:
"""Appelle le portail licence via une session HTTP injectable."""
def __init__(
self,
base_url: str,
session: Optional[_HttpSession] = None,
store: Optional[LicenseStore] = None,
timeout: float = 10.0,
) -> None:
self._base_url = base_url.rstrip("/")
self._session = session
self._store = store if store is not None else LicenseStore()
self._timeout = timeout
def _get_session(self) -> _HttpSession:
if self._session is None:
# Import paresseux : pas de dépendance dure si une session est injectée.
import requests
self._session = requests.Session()
return self._session
def _post(self, endpoint: str, payload: dict) -> Optional[_HttpResponse]:
try:
session = self._get_session()
return session.post(
f"{self._base_url}{endpoint}", json=payload, timeout=self._timeout
)
except Exception:
# Réseau indisponible, DNS, timeout, requests absent… : pas de crash.
return None
@staticmethod
def _parse(response: Optional[_HttpResponse]) -> Optional[dict]:
if response is None:
return None
try:
data = response.json()
except Exception:
return None
return data if isinstance(data, dict) else None
def activate(self, token: str, machine_id: str) -> LicenseStatus:
"""Active une licence à partir d'un jeton. Stocke la licence si valide."""
response = self._post(
"/api/v1/activate", {"token": token, "machine_id": machine_id}
)
if response is None:
return LicenseStatus.unavailable()
if getattr(response, "status_code", 500) >= 400:
payload = self._parse(response) or {}
message = str(payload.get("detail") or payload.get("message") or "")
return LicenseStatus.invalid(message or "Jeton refusé par le serveur")
payload = self._parse(response)
if payload is None:
return LicenseStatus.unavailable()
status = LicenseStatus.from_payload(payload, machine_id)
if status.valid:
# Stocke l'enveloppe signée reçue du serveur, jamais le jeton d'activation.
self._store.save(payload)
return status
def check(self, license_ref: str, machine_id: str) -> LicenseStatus:
"""Vérifie l'état d'une licence existante auprès du portail."""
response = self._post(
"/api/v1/check", {"license_ref": license_ref, "machine_id": machine_id}
)
if response is None:
return LicenseStatus.unavailable()
if getattr(response, "status_code", 500) >= 400:
payload = self._parse(response) or {}
message = str(payload.get("detail") or payload.get("message") or "")
return LicenseStatus.invalid(message or "Licence refusée par le serveur")
payload = self._parse(response)
if payload is None:
return LicenseStatus.unavailable()
status = LicenseStatus.from_payload(payload, machine_id)
if status.valid:
self._store.save(payload)
return status
def local_status(self) -> LicenseStatus:
"""État de licence depuis le stockage local, sans appel réseau."""
data = self._store.load()
if not data:
return LicenseStatus.none()
return LicenseStatus.from_payload(data)