Files
voice-assistant/server/tests/test_access_control.py
T
Denis Defolie d20976fea2 Assistant vocal local ESP32 : étapes 1 à 15, et 15b écrite
État du projet au premier commit :
- firmware ESP32 : micro I2S, lecture anti-gigue, MQTT (état retenu, testament),
  mains libres, domotique de démonstration, chien de garde, présence du serveur ;
- serveur Python : sessions, VAD, faster-whisper, pyannote, identification,
  droits par profil, domotique, mot de réveil, Piper ;
- broker Mosquitto : configuration, comptes et ACL (non activés en développement) ;
- étape 15b écrite, en attente d'essai : agent MQTT pour machines Linux.

Étapes 1 à 15 validées. 203 tests, pyright sans erreur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:41:11 +02:00

161 lines
6.9 KiB
Python

"""Tests des droits par profil, étape 14 : niveaux, refus, confirmation (sans modèle)."""
from __future__ import annotations
from pathlib import Path
import pytest
from voice_server.access_control import AccessPolicy, Level, parse_level
from voice_server.home_control import DeviceCommand
from voice_server.settings import AccessSettings, HomeSettings, load_settings
HOME = HomeSettings(topic_prefix="home", rooms={"salon": "du salon"}, boards={"esp32-01": "salon"})
ACCESS = AccessSettings(
unknown_level="limite", default_level="standard", users={"Denis": "complet", "Tom": "limite"},
full_min_score=0.6,
rules={"light": {"on": "standard", "off": "standard"}, "door": {"open": "complet", "close": "standard"}},
confirm=("door.open",), confirm_timeout_s=15.0,
)
LIGHT_ON = DeviceCommand("on", "light", "salon")
DOOR_OPEN = DeviceCommand("open", "door", "salon")
class Clock:
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
@pytest.fixture
def clock() -> Clock:
return Clock()
@pytest.fixture
def policy(clock: Clock) -> AccessPolicy:
return AccessPolicy(ACCESS, HOME, clock=clock)
@pytest.mark.parametrize(
("speaker", "score", "expected"),
[
(None, None, Level.LIMITED), # voix inconnue
("Denis", 0.74, Level.FULL),
("Denis", 0.52, Level.STANDARD), # reconnu de justesse : pas le niveau complet
("Jessica", 0.9, Level.STANDARD), # enrôlée sans niveau : défaut
("Tom", 0.95, Level.LIMITED),
],
)
def test_levels(policy: AccessPolicy, speaker: str | None, score: float | None, expected: Level) -> None:
assert policy.level(speaker, score) == expected
def test_unknown_level_name_is_rejected() -> None:
with pytest.raises(ValueError, match="complet, standard ou limite"):
parse_level("admin")
def test_unknown_voice_cannot_command(policy: AccessPolicy) -> None:
verdict = policy.check(LIGHT_ON, None, None)
assert not verdict.allowed
assert verdict.answer == "Je ne vous ai pas reconnu : je ne peux pas allumer la lumière du salon."
def test_standard_profile_controls_lights_not_the_door(policy: AccessPolicy) -> None:
assert policy.check(LIGHT_ON, "Jessica", 0.9).allowed
refused = policy.check(DOOR_OPEN, "Jessica", 0.9)
assert not refused.allowed and refused.answer is not None and refused.answer.startswith("Désolé Jessica")
def test_action_missing_from_rules_needs_full_level(policy: AccessPolicy) -> None:
"""On ne permet jamais par oubli : sans règle, seul le niveau complet passe."""
shutter = DeviceCommand("open", "shutter", "salon")
assert not policy.check(shutter, "Jessica", 0.9).allowed
assert policy.check(shutter, "Denis", 0.8).allowed
def test_sensitive_action_asks_for_confirmation(policy: AccessPolicy) -> None:
verdict = policy.check(DOOR_OPEN, "Denis", 0.8)
assert verdict.allowed and verdict.confirm
assert verdict.answer == "Confirmez-vous : ouvrir la porte du salon ? Dites oui ou non."
def test_yes_from_the_same_person_executes(policy: AccessPolicy) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "Oui, vas-y.", "Denis")
assert resolution is not None and resolution.command == DOOR_OPEN
assert resolution.answer == "J'ouvre la porte du salon."
assert policy.resolve("esp32-01", "oui", "Denis") is None # une confirmation ne sert qu'une fois
def test_no_cancels(policy: AccessPolicy) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "Non, annule.", "Denis")
assert resolution is not None and resolution.command is None
def test_someone_else_cannot_confirm(policy: AccessPolicy) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
resolution = policy.resolve("esp32-01", "Oui", "Jessica")
assert resolution is not None and resolution.command is None
assert resolution.answer.startswith("Seul Denis")
def test_confirmation_expires(policy: AccessPolicy, clock: Clock) -> None:
policy.ask("esp32-01", DOOR_OPEN, "Denis")
clock.now += 16
assert policy.resolve("esp32-01", "oui", "Denis") is None
def test_other_request_drops_the_pending_one(policy: AccessPolicy) -> None:
"""Autre chose que oui/non : la demande en attente est oubliée, la nouvelle est traitée."""
policy.ask("esp32-01", DOOR_OPEN, "Denis")
assert policy.resolve("esp32-01", "Quelle heure est-il ?", "Denis") is None
assert policy.resolve("esp32-01", "oui", "Denis") is None
def test_project_config_is_valid() -> None:
"""Le config.yaml du dépôt se charge, et ses règles s'appliquent vraiment (profil standard)."""
settings = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
policy = AccessPolicy(settings.access, settings.home, settings.machines)
for action in ("on", "off"): # on/off nus en YAML = booléens : Pierre refusé à l'essai réel
assert policy.check(DeviceCommand(action, "light", "salon"), "Pierre", 0.8).allowed
assert policy.check(DeviceCommand("close", "shutter", "salon"), "Pierre", 0.8).allowed
assert not policy.check(DOOR_OPEN, "Pierre", 0.8).allowed
def test_yaml_boolean_keys_are_read_as_on_off(tmp_path: Path) -> None:
"""« light: {on: standard} » sans guillemets : YAML lit True, le chargeur rend « on »."""
text = (Path(__file__).resolve().parents[1] / "config.yaml").read_text(encoding="utf-8")
config = tmp_path / "config.yaml"
config.write_text(text.replace('{"on": standard, "off": standard}', "{on: standard, off: standard}"),
encoding="utf-8")
assert load_settings(config).access.rules["light"] == {"on": "standard", "off": "standard"}
def test_unknown_rule_action_is_rejected() -> None:
"""Une règle mal écrite est refusée au démarrage plutôt qu'ignorée en silence."""
access = AccessSettings(**{**ACCESS.__dict__, "rules": {"light": {"True": "standard"}}})
with pytest.raises(ValueError, match="action inconnue light.True"):
AccessPolicy(access, HOME)
def test_barely_recognised_full_profile_is_asked_to_repeat(policy: AccessPolicy) -> None:
"""Denis reconnu à 0,59 : son profil permettrait, c'est la voix qui n'est pas assez sûre."""
verdict = policy.check(DOOR_OPEN, "Denis", 0.59)
assert not verdict.allowed
assert verdict.answer == ("Denis, je ne reconnais pas assez nettement votre voix pour ouvrir "
"la porte du salon. Répétez, plus près du micro.")
def test_elision_in_refusal(policy: AccessPolicy) -> None:
"""« ne permet pas d'ouvrir », pas « de ouvrir »."""
answer = policy.check(DOOR_OPEN, "Jessica", 0.9).answer
assert answer == "Désolé Jessica, votre profil ne permet pas d'ouvrir la porte du salon."
close = policy.check(DeviceCommand("close", "door", "salon"), "Tom", 0.9).answer
assert close == "Désolé Tom, votre profil ne permet pas de fermer la porte du salon."