diff --git a/Pseudonymisation_Gui_V6.py b/Pseudonymisation_Gui_V6.py new file mode 100644 index 0000000..5472000 --- /dev/null +++ b/Pseudonymisation_Gui_V6.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Point d'entrée de la GUI V6 de Pseudonymisation. + +Usage : + python Pseudonymisation_Gui_V6.py # lance la fenêtre + python Pseudonymisation_Gui_V6.py --self-test # importe l'app, sort 0, sans fenêtre + +Le mode ``--self-test`` vérifie que tout le socle GUI V6 s'importe correctement +(utile en CI / build sans display). Il n'ouvre aucune fenêtre. +""" + +from __future__ import annotations + +import sys + + +def _self_test() -> int: + """Importe les modules du socle GUI V6 sans créer de fenêtre.""" + from gui_v6 import app, license_client, license_store, theme # noqa: F401 + from gui_v6.tabs import tab_about # noqa: F401 + + # Sanity check des contrats publics du socle. + assert hasattr(app, "AnonymisationApp") + assert hasattr(license_client, "LicenseClient") + assert hasattr(license_client, "LicenseStatus") + assert hasattr(license_store, "LicenseStore") + assert hasattr(tab_about, "AboutTab") + print("GUI V6 self-test OK") + return 0 + + +def main(argv=None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if "--self-test" in argv: + return _self_test() + + from gui_v6.app import AnonymisationApp + + application = AnonymisationApp() + application.mainloop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/gui_v6/__init__.py b/gui_v6/__init__.py new file mode 100644 index 0000000..b1af0c2 --- /dev/null +++ b/gui_v6/__init__.py @@ -0,0 +1,11 @@ +"""Package GUI V6 de Pseudonymisation (refonte de la couche présentation). + +Frontière propre : ce package n'embarque AUCUNE logique de détection. Il +orchestre uniquement le moteur d'anonymisation existant et la licence. + +Lot G1 (socle) : thème, client/stockage licence, shell minimal, onglet À propos. +""" + +__all__ = ["__version__"] + +__version__ = "6.0.0-g1" diff --git a/gui_v6/app.py b/gui_v6/app.py new file mode 100644 index 0000000..0967632 --- /dev/null +++ b/gui_v6/app.py @@ -0,0 +1,97 @@ +"""Shell minimal de la GUI V6 (lot G1). + +Header + bandeau de statut licence + navigation 3 onglets +(Utilisation, Configuration, À propos). Seul « À propos » est étoffé en G1 ; +les deux autres sont des placeholders qui seront remplis en G2/G3. + +Aucune logique de détection ici : ce module orchestre uniquement. La fenêtre +n'est créée qu'à l'instanciation de :class:`AnonymisationApp` (import sûr). +""" + +from __future__ import annotations + +from typing import Optional + +import customtkinter as ctk + +from gui_v6 import theme as theme_mod +from gui_v6.license_client import LicenseClient, LicenseStatus +from gui_v6.tabs.tab_about import AboutTab + +_TABS = ("Utilisation", "Configuration", "À propos") + + +class AnonymisationApp(ctk.CTk): + """Fenêtre principale (socle G1).""" + + def __init__( + self, + license_client: Optional[LicenseClient] = None, + theme_name: str = theme_mod.DEFAULT_THEME, + ) -> None: + super().__init__() + self._theme_name = theme_name + theme_mod.apply_theme(theme_name) + + # Client licence : par défaut, lecture du statut local uniquement + # (aucun appel réseau au démarrage). Injectable pour les tests. + self._license_client = license_client or LicenseClient("http://localhost") + status = self._safe_local_status() + + self.title("Pseudonymisation de vos documents") + self.geometry("960x640") + + self._build_header(status) + self._build_tabs(status) + + # -- statut licence --------------------------------------------------- + + def _safe_local_status(self) -> LicenseStatus: + try: + return self._license_client.local_status() + except Exception: + # Licence indisponible → dégradation silencieuse (mode bêta). + return LicenseStatus.unavailable() + + # -- construction UI -------------------------------------------------- + + def _build_header(self, status: LicenseStatus) -> None: + header = ctk.CTkFrame(self, height=56) + header.pack(fill="x", padx=12, pady=(12, 6)) + + ctk.CTkLabel( + header, + text="Pseudonymisation", + font=ctk.CTkFont(size=16, weight="bold"), + ).pack(side="left", padx=12, pady=10) + + color = theme_mod.status_color(self._theme_name, status.status) + self._status_banner = ctk.CTkLabel( + header, text=self._banner_text(status), text_color=color + ) + self._status_banner.pack(side="right", padx=12, pady=10) + + def _build_tabs(self, status: LicenseStatus) -> None: + tabview = ctk.CTkTabview(self) + tabview.pack(fill="both", expand=True, padx=12, pady=(6, 12)) + for name in _TABS: + tabview.add(name) + + self._about = AboutTab( + tabview.tab("À propos"), status=status, theme_name=self._theme_name + ) + self._about.pack(fill="both", expand=True) + + # Placeholders G2/G3. + ctk.CTkLabel( + tabview.tab("Utilisation"), + text="Onglet Utilisation — disponible au lot G2.", + ).pack(padx=16, pady=16, anchor="w") + ctk.CTkLabel( + tabview.tab("Configuration"), + text="Onglet Configuration — disponible au lot G3.", + ).pack(padx=16, pady=16, anchor="w") + + @staticmethod + def _banner_text(status: LicenseStatus) -> str: + return f"Licence : {status.status}" diff --git a/gui_v6/license_client.py b/gui_v6/license_client.py new file mode 100644 index 0000000..594b4d2 --- /dev/null +++ b/gui_v6/license_client.py @@ -0,0 +1,195 @@ +"""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) diff --git a/gui_v6/license_store.py b/gui_v6/license_store.py new file mode 100644 index 0000000..23ba05e --- /dev/null +++ b/gui_v6/license_store.py @@ -0,0 +1,84 @@ +"""Stockage local de la licence signée, hors dépôt. + +La licence est stockée dans l'espace utilisateur, jamais dans le dépôt Git : + +- Windows : ``%LOCALAPPDATA%/Aivanov/Anonymisation/license.json`` +- Linux/dev : ``~/.local/share/aivanov/anonymisation/license.json`` + +Règle de sécurité : ce module ne journalise jamais le contenu de la licence +(le token signé est sensible). Aucune impression, aucun log du payload. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any, Optional + +_APP_VENDOR = "Aivanov" +_APP_NAME = "Anonymisation" +_LICENSE_FILENAME = "license.json" + + +def default_license_path() -> Path: + """Retourne le chemin du fichier licence dans l'espace utilisateur. + + Le répertoire n'est pas créé ici ; il l'est à la première écriture. + """ + if sys.platform.startswith("win"): + base = os.environ.get("LOCALAPPDATA") + root = Path(base) if base else Path.home() / "AppData" / "Local" + return root / _APP_VENDOR / _APP_NAME / _LICENSE_FILENAME + + # Linux / macOS / dev : respecte XDG_DATA_HOME si défini. + base = os.environ.get("XDG_DATA_HOME") + root = Path(base) if base else Path.home() / ".local" / "share" + return root / _APP_VENDOR.lower() / _APP_NAME.lower() / _LICENSE_FILENAME + + +class LicenseStore: + """Lit / écrit / supprime le fichier licence local (JSON). + + Le chemin est injectable pour les tests (vrai fichier dans un tmp_path) ; + par défaut il pointe vers l'emplacement utilisateur de la plateforme. + """ + + def __init__(self, path: Optional[Path] = None) -> None: + self._path = Path(path) if path is not None else default_license_path() + + @property + def path(self) -> Path: + return self._path + + def exists(self) -> bool: + return self._path.is_file() + + def load(self) -> Optional[dict[str, Any]]: + """Retourne le payload licence, ou ``None`` si absent / illisible.""" + if not self._path.is_file(): + return None + try: + with self._path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except (json.JSONDecodeError, OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + def save(self, data: dict[str, Any]) -> Path: + """Écrit le payload licence de façon atomique. Crée le répertoire parent.""" + self._path.parent.mkdir(parents=True, exist_ok=True) + tmp = self._path.with_name(self._path.name + ".tmp") + with tmp.open("w", encoding="utf-8") as handle: + json.dump(data, handle, ensure_ascii=False, indent=2) + os.replace(tmp, self._path) + return self._path + + def delete(self) -> bool: + """Supprime le fichier licence. Retourne True s'il existait.""" + try: + self._path.unlink() + return True + except FileNotFoundError: + return False diff --git a/gui_v6/tabs/__init__.py b/gui_v6/tabs/__init__.py new file mode 100644 index 0000000..9f3dbff --- /dev/null +++ b/gui_v6/tabs/__init__.py @@ -0,0 +1 @@ +"""Onglets de la GUI V6.""" diff --git a/gui_v6/tabs/tab_about.py b/gui_v6/tabs/tab_about.py new file mode 100644 index 0000000..1b9f817 --- /dev/null +++ b/gui_v6/tabs/tab_about.py @@ -0,0 +1,76 @@ +"""Onglet « À propos » : version, et état de la licence. + +Affiche le statut licence fourni par le client/stub. Dégrade proprement si la +licence est indisponible (bandeau d'information, pas d'erreur bloquante). +Les widgets ne sont créés qu'à l'instanciation (import sûr pour ``--self-test``). +""" + +from __future__ import annotations + +from typing import Optional + +import customtkinter as ctk + +from gui_v6 import __version__ as GUI_VERSION +from gui_v6 import theme as theme_mod +from gui_v6.license_client import LicenseStatus + +_STATUS_LABELS = { + "active": "Licence active", + "grace": "Licence en période de grâce", + "expired": "Licence expirée", + "revoked": "Poste révoqué", + "invalid": "Licence invalide", + "unavailable": "Serveur de licence indisponible", + "none": "Aucune licence", +} + + +def _build_info() -> str: + """Version / commit du build, si disponible. Best-effort, sans casser l'UI.""" + try: + import build_info # type: ignore + + version = getattr(build_info, "VERSION", "?") + commit = getattr(build_info, "COMMIT", "?") + return f"Moteur {version} ({commit})" + except Exception: + return "Moteur : information de build indisponible" + + +class AboutTab(ctk.CTkFrame): + def __init__( + self, + master, + status: Optional[LicenseStatus] = None, + theme_name: str = theme_mod.DEFAULT_THEME, + **kwargs, + ) -> None: + super().__init__(master, **kwargs) + self._theme_name = theme_name + + ctk.CTkLabel( + self, + text="Pseudonymisation de vos documents", + font=ctk.CTkFont(size=18, weight="bold"), + ).pack(anchor="w", padx=16, pady=(16, 4)) + + ctk.CTkLabel(self, text=f"Interface V6 — {GUI_VERSION}").pack( + anchor="w", padx=16 + ) + ctk.CTkLabel(self, text=_build_info()).pack(anchor="w", padx=16, pady=(0, 12)) + + self._status_label = ctk.CTkLabel(self, text="", anchor="w") + self._status_label.pack(anchor="w", padx=16, pady=(0, 8)) + + self.set_status(status or LicenseStatus.none()) + + def set_status(self, status: LicenseStatus) -> None: + label = _STATUS_LABELS.get(status.status, status.status) + text = f"État licence : {label}" + if status.expires_at: + text += f" · expire le {status.expires_at}" + if status.message: + text += f"\n{status.message}" + color = theme_mod.status_color(self._theme_name, status.status) + self._status_label.configure(text=text, text_color=color) diff --git a/gui_v6/theme.py b/gui_v6/theme.py new file mode 100644 index 0000000..a6f71b9 --- /dev/null +++ b/gui_v6/theme.py @@ -0,0 +1,83 @@ +"""Thèmes et palette de la GUI V6. + +Mappe les tokens de couleur de la maquette ``docs/ui_mockup_v6.html`` vers +customtkinter. Lot G1 : 4 thèmes de base + helper d'application. L'import de ce +module ne crée aucun widget (compatible ``--self-test`` sans display). +""" + +from __future__ import annotations + +from typing import Dict + +# Tokens couleur par thème (bandeau statut licence, accents, surfaces). +THEMES: Dict[str, dict] = { + "clair": { + "appearance": "light", + "color_theme": "blue", + "accent": "#2563eb", + "ok": "#16a34a", + "warn": "#d97706", + "error": "#dc2626", + }, + "sombre": { + "appearance": "dark", + "color_theme": "blue", + "accent": "#3b82f6", + "ok": "#22c55e", + "warn": "#f59e0b", + "error": "#ef4444", + }, + "médical": { + "appearance": "light", + "color_theme": "green", + "accent": "#0d9488", + "ok": "#15803d", + "warn": "#ca8a04", + "error": "#b91c1c", + }, + "contraste": { + "appearance": "dark", + "color_theme": "dark-blue", + "accent": "#60a5fa", + "ok": "#4ade80", + "warn": "#fbbf24", + "error": "#f87171", + }, +} + +DEFAULT_THEME = "clair" + +# Couleurs du bandeau de statut licence selon l'état métier. +STATUS_COLORS = { + "active": "ok", + "grace": "warn", + "expired": "error", + "revoked": "error", + "invalid": "error", + "unavailable": "warn", + "none": "warn", +} + + +def theme_names() -> list[str]: + return list(THEMES.keys()) + + +def get_theme(name: str) -> dict: + return THEMES.get(name, THEMES[DEFAULT_THEME]) + + +def status_color(theme_name: str, status: str) -> str: + """Couleur hex pour un statut licence, dans le thème donné.""" + theme = get_theme(theme_name) + return theme[STATUS_COLORS.get(status, "warn")] + + +def apply_theme(name: str = DEFAULT_THEME) -> dict: + """Applique le thème à customtkinter. Import paresseux de ctk.""" + theme = get_theme(name) + import customtkinter as ctk + + ctk.set_appearance_mode(theme["appearance"]) + ctk.set_default_color_theme(theme["color_theme"]) + return theme diff --git a/requirements.txt b/requirements.txt index 254da34..f063644 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,3 +34,6 @@ python-doctr[torch]>=0.9.0 # (optionnel – si tu gardes spaCy dans d'autres chemins) # spacy==3.7.4 + +# GUI V6 (customtkinter) — interface refondue, embarquée dans l'EXE +customtkinter==5.2.2 diff --git a/tests/unit/test_gui_v6_license_client.py b/tests/unit/test_gui_v6_license_client.py new file mode 100644 index 0000000..a37413c --- /dev/null +++ b/tests/unit/test_gui_v6_license_client.py @@ -0,0 +1,179 @@ +"""Tests du client licence : session HTTP injectée, aucun appel réseau réel. + +Le client accepte une session mockable. On vérifie : activation réussie + +stockage local, refus serveur (4xx), serveur indisponible (exception réseau), +réponse illisible, check, et lecture du statut local. +""" + +from __future__ import annotations + +import pytest + +from gui_v6.license_client import LicenseClient, LicenseStatus +from gui_v6.license_store import LicenseStore + + +class FakeResponse: + def __init__(self, status_code, payload=None, raise_on_json=False): + self.status_code = status_code + self._payload = payload + self._raise_on_json = raise_on_json + + def json(self): + if self._raise_on_json: + raise ValueError("not json") + return self._payload + + +class FakeSession: + """Session HTTP mockable : enregistre les appels, renvoie des réponses scriptées.""" + + def __init__(self, response=None, exc=None): + self._response = response + self._exc = exc + self.calls = [] + + def post(self, url, json, timeout): + self.calls.append({"url": url, "json": json, "timeout": timeout}) + if self._exc is not None: + raise self._exc + return self._response + + +def _client(tmp_path, session): + store = LicenseStore(tmp_path / "license.json") + return LicenseClient("https://portail.example/", session=session, store=store), store + + +def test_activate_success_stores_license(tmp_path): + payload = { + "state": "active", + "license": { + "payload": { + "license_ref": "LIC-9", + "expires_at": "2027-06-01", + "grace_days": 7, + "machine_id": "MID-0001", + }, + "signature": "signed", + "alg": "RSASSA-PSS-SHA256", + }, + } + session = FakeSession(FakeResponse(200, payload)) + client, store = _client(tmp_path, session) + + status = client.activate("TOKEN-ABC", "MID-0001") + + assert isinstance(status, LicenseStatus) + assert status.valid is True + assert status.status == "active" + assert status.license_ref == "LIC-9" + assert status.grace_days == 7 + # La licence signée est stockée localement. + assert store.load() == payload + # L'URL et le payload envoyés sont corrects. + assert session.calls[0]["url"] == "https://portail.example/api/v1/activate" + assert session.calls[0]["json"] == {"token": "TOKEN-ABC", "machine_id": "MID-0001"} + + +def test_activate_grace_is_valid(tmp_path): + session = FakeSession( + FakeResponse(200, {"state": "grace", "license": {"payload": {"grace_days": 3}}}) + ) + client, _ = _client(tmp_path, session) + status = client.activate("T", "M") + assert status.valid is True + assert status.status == "grace" + + +def test_activate_rejected_4xx_is_invalid_and_not_stored(tmp_path): + session = FakeSession(FakeResponse(403, {"detail": "token revoked"})) + client, store = _client(tmp_path, session) + + status = client.activate("BAD", "MID-1") + + assert status.valid is False + assert status.status == "invalid" + assert "revoked" in status.message + # Rien n'est stocké en cas de refus. + assert store.load() is None + + +def test_activate_server_unavailable_does_not_crash(tmp_path): + session = FakeSession(exc=ConnectionError("server down")) + client, store = _client(tmp_path, session) + + status = client.activate("T", "M") + + assert status.valid is False + assert status.status == "unavailable" + assert store.load() is None + + +def test_activate_unreadable_response_is_unavailable(tmp_path): + session = FakeSession(FakeResponse(200, raise_on_json=True)) + client, _ = _client(tmp_path, session) + status = client.activate("T", "M") + assert status.status == "unavailable" + + +def test_check_active(tmp_path): + payload = {"state": "active", "license": {"payload": {"license_ref": "LIC-1"}}} + session = FakeSession(FakeResponse(200, payload)) + client, store = _client(tmp_path, session) + + status = client.check("LIC-1", "MID-1") + + assert status.valid is True + assert session.calls[0]["url"] == "https://portail.example/api/v1/check" + assert session.calls[0]["json"] == {"license_ref": "LIC-1", "machine_id": "MID-1"} + assert store.load() == payload + + +def test_check_expired_is_not_valid(tmp_path): + session = FakeSession(FakeResponse(200, {"state": "expired"})) + client, _ = _client(tmp_path, session) + status = client.check("LIC-1", "MID-1") + assert status.valid is False + assert status.status == "expired" + + +def test_check_revoked_without_license_payload(tmp_path): + session = FakeSession(FakeResponse(200, {"state": "revoked"})) + client, store = _client(tmp_path, session) + + status = client.check("LIC-1", "MID-1") + + assert status.valid is False + assert status.status == "revoked" + assert store.load() is None + + +def test_check_server_unavailable(tmp_path): + session = FakeSession(exc=TimeoutError("timeout")) + client, _ = _client(tmp_path, session) + status = client.check("LIC-1", "MID-1") + assert status.status == "unavailable" + + +def test_local_status_none_when_empty(tmp_path): + session = FakeSession(FakeResponse(200, {})) + client, _ = _client(tmp_path, session) + status = client.local_status() + assert status.valid is False + assert status.status == "none" + + +def test_local_status_reads_store(tmp_path): + store = LicenseStore(tmp_path / "license.json") + store.save({"state": "active", "license": {"payload": {"license_ref": "LIC-7"}}}) + client = LicenseClient("https://x", session=FakeSession(), store=store) + status = client.local_status() + assert status.valid is True + assert status.license_ref == "LIC-7" + + +def test_status_never_exposes_token(): + # Le statut ne porte pas de token : la repr ne peut pas le fuiter. + status = LicenseStatus.from_payload({"status": "active", "license_ref": "LIC-1"}) + assert "token" not in repr(status).lower() diff --git a/tests/unit/test_gui_v6_license_store.py b/tests/unit/test_gui_v6_license_store.py new file mode 100644 index 0000000..83bea80 --- /dev/null +++ b/tests/unit/test_gui_v6_license_store.py @@ -0,0 +1,91 @@ +"""Tests du stockage local de licence (vrais fichiers, aucun mock). + +Couvre : résolution du chemin par plateforme, round-trip save/load, création +du répertoire parent, suppression, robustesse au JSON corrompu. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from gui_v6.license_store import LicenseStore, default_license_path + + +def test_default_path_windows(monkeypatch): + monkeypatch.setattr("sys.platform", "win32") + monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\dom\AppData\Local") + path = default_license_path() + parts = path.as_posix() + assert parts.endswith("Aivanov/Anonymisation/license.json") + + +def test_default_path_linux(monkeypatch): + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: Path("/home/tester"))) + path = default_license_path() + assert path == Path("/home/tester/.local/share/aivanov/anonymisation/license.json") + + +def test_default_path_linux_respects_xdg(monkeypatch): + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.setenv("XDG_DATA_HOME", "/custom/data") + path = default_license_path() + assert path == Path("/custom/data/aivanov/anonymisation/license.json") + + +def test_save_creates_parent_dir_and_roundtrips(tmp_path): + target = tmp_path / "nested" / "dir" / "license.json" + store = LicenseStore(target) + assert not store.exists() + + payload = {"status": "active", "license_ref": "LIC-123", "expires_at": "2027-01-01"} + store.save(payload) + + assert target.is_file() + assert store.exists() + assert store.load() == payload + # Vérifie aussi le contenu sur disque (vrai fichier JSON). + on_disk = json.loads(target.read_text(encoding="utf-8")) + assert on_disk["license_ref"] == "LIC-123" + + +def test_load_absent_returns_none(tmp_path): + store = LicenseStore(tmp_path / "absent.json") + assert store.load() is None + + +def test_load_corrupted_returns_none(tmp_path): + target = tmp_path / "license.json" + target.write_text("{ this is not valid json", encoding="utf-8") + store = LicenseStore(target) + assert store.load() is None + + +def test_load_non_object_returns_none(tmp_path): + target = tmp_path / "license.json" + target.write_text("[1, 2, 3]", encoding="utf-8") + store = LicenseStore(target) + assert store.load() is None + + +def test_delete(tmp_path): + target = tmp_path / "license.json" + store = LicenseStore(target) + store.save({"status": "active"}) + assert store.delete() is True + assert not target.exists() + # Suppression idempotente : un second delete ne lève pas. + assert store.delete() is False + + +def test_save_overwrites(tmp_path): + store = LicenseStore(tmp_path / "license.json") + store.save({"status": "active", "v": 1}) + store.save({"status": "grace", "v": 2}) + loaded = store.load() + assert loaded["status"] == "grace" + assert loaded["v"] == 2