Files
Unraid-AppOps-Gateway-Public/backend/tests/test_home_assistant_voice_export.py
T

223 lines
8.1 KiB
Python

from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, create_engine
@pytest.fixture
def api_client(tmp_path, monkeypatch):
db_path = tmp_path / "appops_voice_export.db"
monkeypatch.setenv("APPOPS_DB_PATH", str(db_path))
monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token")
monkeypatch.setenv("HOME_ASSISTANT_TOKEN", "test-ha-notify-token")
from backend.app.db import session
from backend.app.db.init_db import init_db
from backend.app.main import app
engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False})
session.engine = engine
SQLModel.metadata.drop_all(engine)
SQLModel.metadata.create_all(engine)
init_db()
with TestClient(app) as client:
yield client
def _voice_headers():
return {"Authorization": "Bearer test-voice-token"}
def test_home_assistant_export_contains_safe_yaml_without_tokens(api_client):
response = api_client.get("/api/voice/home-assistant/export", headers=_voice_headers())
assert response.status_code == 200
payload = response.json()
yaml = payload["yaml"]
assert "rest_command:" in yaml
assert "script:" in yaml
assert "appops_voice_authorization" in yaml
assert "Authorization: !secret appops_voice_authorization" in yaml
assert "test-voice-token" not in yaml
assert "test-ha-notify-token" not in yaml
assert "HOME_ASSISTANT_TOKEN" not in yaml
assert "http://testserver/api/voice/actions/setup/status" in yaml
assert "response_variable: appops_result" in yaml
assert "appops_base_url" not in yaml
assert len(payload["setupGuide"]) == 6
def test_home_assistant_export_does_not_include_blocked_actions(api_client):
payload = api_client.get("/api/voice/home-assistant/export", headers=_voice_headers()).json()
text = str(payload)
assert "unraid.mover.start" not in text
assert "volume.delete" not in text
assert "stack.redeploy" not in text
assert "authentik/restart" not in text
assert "pause-all" not in text
def test_voice_commands_expose_full_command_contract(api_client):
response = api_client.get("/api/voice/commands", headers=_voice_headers())
assert response.status_code == 200
commands = response.json()
assert len(commands) >= 23
required = {
"id",
"label",
"spokenPhraseNl",
"category",
"endpoint",
"method",
"payload",
"risk",
"policyDecision",
"requiresApproval",
"enabledByDefault",
"homeAssistantRestCommandName",
"homeAssistantScriptName",
"description",
"safetyNotes",
}
assert required <= set(commands[0])
assert any(command["id"] == "container.widefrog.restart" for command in commands)
assert any(command["id"] == "unraid.vms" for command in commands)
assert any(command["id"] == "security.exposure" for command in commands)
assert next(command for command in commands if command["id"] == "unraid.storage")["policyDecision"] == "allowed"
assert all(command["policyDecision"] != "blocked" for command in commands)
def test_voice_export_endpoints_require_voice_token_or_signed_in_user(api_client, monkeypatch):
from fastapi import HTTPException
from backend.app.services import user_access
def anonymous(_request):
raise HTTPException(401, "Sign in required")
monkeypatch.setattr(user_access, "current_user", anonymous)
missing = api_client.get("/api/voice/home-assistant/export")
wrong = api_client.get(
"/api/voice/home-assistant/export",
headers={"Authorization": "Bearer wrong-token"},
)
assert missing.status_code == 401
assert wrong.status_code == 403
def test_operator_voice_catalog_is_read_only_and_does_not_require_browser_secret(api_client):
commands = api_client.get('/api/operator/voice/commands')
export = api_client.get('/api/operator/voice/home-assistant/export')
dry_run = api_client.post('/api/operator/voice/test-intent', json={'input': 'controleer appops setup status'})
assert commands.status_code == 200
assert len(commands.json()) >= 11
assert export.status_code == 200
assert 'test-voice-token' not in str(export.json())
assert dry_run.status_code == 200
assert dry_run.json()['executed'] is False
assert api_client.post('/api/voice/actions/setup/status').status_code == 200
def test_operator_voice_readiness_uses_live_home_assistant_state(api_client, monkeypatch):
from backend.app.core.config import settings
from backend.app.services import home_assistant_voice_readiness
previous = dict(settings.apis)
settings.apis["HOME_ASSISTANT_URL"] = "http://home-assistant:8123"
settings.apis["HOME_ASSISTANT_TOKEN"] = "runtime-token-not-returned"
def fake_get_json(_url, _token, path, _verify_tls):
if path == "/api/config":
return {
"components": [
"conversation",
"google_generative_ai_conversation",
]
}, "ok", 200
return [
{"entity_id": "script.appops_check_setup"},
{"entity_id": "script.appops_check_unraid"},
], "ok", 200
monkeypatch.setattr(home_assistant_voice_readiness, "_get_json", fake_get_json)
try:
response = api_client.get("/api/operator/voice/home-assistant/readiness")
finally:
settings.apis.clear()
settings.apis.update(previous)
assert response.status_code == 200
payload = response.json()
assert payload["apiReachable"] is True
assert payload["operational"] is True
assert payload["coreScriptAvailable"] is True
assert payload["conversationAvailable"] is True
assert payload["geminiConversationAvailable"] is True
assert payload["detectedScriptCount"] == 2
assert payload["expectedScriptCount"] >= 20
assert payload["llmExposureStatus"] == "manual_verification_required"
assert "runtime-token-not-returned" not in str(payload)
def test_operator_voice_readiness_reports_missing_connector_without_network(api_client, monkeypatch):
from backend.app.core.config import settings
from backend.app.services import home_assistant_voice_readiness
previous = dict(settings.apis)
settings.apis["HOME_ASSISTANT_URL"] = None
settings.apis["HOME_ASSISTANT_TOKEN"] = None
monkeypatch.setattr(
home_assistant_voice_readiness,
"_get_json",
lambda *_args: (_ for _ in ()).throw(AssertionError("missing config must not call Home Assistant")),
)
try:
response = api_client.get("/api/operator/voice/home-assistant/readiness")
finally:
settings.apis.clear()
settings.apis.update(previous)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "missing_config"
assert payload["apiReachable"] is False
assert set(payload["missing"]) == {"HOME_ASSISTANT_URL", "HOME_ASSISTANT_TOKEN"}
def test_test_intent_is_dry_run_and_unknown_does_not_execute(api_client):
before_jobs = api_client.get("/api/jobs").json()
response = api_client.post(
"/api/voice/test-intent",
headers=_voice_headers(),
json={"input": "zet alles stuk aub"},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "unknown"
assert payload["executed"] is False
assert "jobId" not in payload
assert api_client.get("/api/jobs").json() == before_jobs
def test_dry_run_does_not_mutate_or_create_approval(api_client):
before_jobs = api_client.get("/api/jobs").json()
before_approvals = api_client.get("/api/approvals").json()
response = api_client.post(
"/api/voice/commands/container.widefrog.restart/dry-run",
headers=_voice_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["executed"] is False
assert payload["command"]["id"] == "container.widefrog.restart"
assert payload["policyPreview"]["decision"] == "allowed"
assert api_client.get("/api/jobs").json() == before_jobs
assert api_client.get("/api/approvals").json() == before_approvals