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(api_client): 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 == 401 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