223 lines
8.1 KiB
Python
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
|