import json import subprocess import sys from pathlib import Path from types import SimpleNamespace import pytest from fastapi.testclient import TestClient from sqlmodel import Session, SQLModel, create_engine @pytest.fixture def api_client(tmp_path, monkeypatch): db_path = tmp_path / "appops_test.db" monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") from backend.app.main import app from backend.app.db import session from backend.app.db.init_db import init_db 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 _with_updated_connectors(portainer_url: str | None = None, portainer_key: str | None = None, portainer_endpoint: str | None = None, unraid_url: str | None = None, unraid_key: str | None = None): from backend.app.core.config import settings return_settings = dict(settings.apis) if portainer_url is not None: settings.apis['PORTAINER_URL'] = portainer_url if portainer_key is not None: settings.apis['PORTAINER_API_KEY'] = portainer_key if portainer_endpoint is not None: settings.apis['PORTAINER_ENDPOINT_ID'] = portainer_endpoint if unraid_url is not None: settings.apis['UNRAID_URL'] = unraid_url if unraid_key is not None: settings.apis['UNRAID_API_KEY'] = unraid_key return return_settings def _restore_connectors(previous): from backend.app.core.config import settings settings.apis.clear() settings.apis.update(previous) def test_approval_decision_writes_audit_event(api_client): created = api_client.post('/api/approvals', json={ 'action': 'container.restart', 'target': 'sonarr', 'source': 'manual', 'risk': 'medium', 'reason': 'Operator requested restart approval.', 'requester': 'operator', }).json() assert created['success'] is True approval_id = created['id'] decided = api_client.patch(f'/api/approvals/{approval_id}', json={'status': 'approved'}).json() assert decided['success'] is True assert decided['status'] == 'approved' assert decided['auditId'].startswith('audit-') audit = api_client.get('/api/audit').json() decision_events = [event for event in audit if event['id'] == decided['auditId']] assert decision_events assert decision_events[0]['action_id'] == 'approval.approved' assert decision_events[0]['target'] == 'sonarr' def test_approval_decision_uses_strict_state_machine(api_client): created = api_client.post('/api/approvals', json={ 'action': 'stack.redeploy', 'target': 'media', 'source': 'manual', 'risk': 'high', }).json() approval_id = created['id'] invalid = api_client.patch(f'/api/approvals/{approval_id}', json={'status': 'executed'}) assert invalid.status_code == 409 approved = api_client.patch(f'/api/approvals/{approval_id}', json={'status': 'approved'}) assert approved.status_code == 200 repeated = api_client.patch(f'/api/approvals/{approval_id}', json={'status': 'rejected'}) assert repeated.status_code == 409 execution = api_client.post(f'/api/approvals/{approval_id}/execute') assert execution.status_code == 200 assert execution.json()['result'] == 'unsupported' def test_expired_approval_is_closed_by_backend_not_only_ui(api_client): from datetime import timedelta from sqlmodel import Session from backend.app.core.time import utc_now from backend.app.db import session as db_session from backend.app.db.models import Approval created = api_client.post('/api/approvals', json={ 'action': 'deluge.pause_all', 'target': 'deluge', 'source': 'manual', 'risk': 'high', }).json() with Session(db_session.engine) as session: approval = session.get(Approval, created['id']) approval.expires_at = utc_now() - timedelta(minutes=1) session.add(approval) session.commit() listed = api_client.get('/api/approvals').json() expired = next(item for item in listed if item['id'] == created['id']) assert expired['status'] == 'expired' decision = api_client.patch(f"/api/approvals/{created['id']}", json={'status': 'approved'}) assert decision.status_code == 409 def test_approval_cleanup_archives_only_expired_records(api_client): from datetime import timedelta from sqlmodel import Session from backend.app.core.time import utc_now from backend.app.db import session as db_session from backend.app.db.models import Approval expired = api_client.post('/api/approvals', json={ 'action': 'deluge.pause_all', 'target': 'deluge', 'source': 'manual', 'risk': 'high', }).json() pending = api_client.post('/api/approvals', json={ 'action': 'plex.scan_all_libraries', 'target': 'plex', 'source': 'manual', 'risk': 'medium', }).json() with Session(db_session.engine) as session: approval = session.get(Approval, expired['id']) approval.expires_at = utc_now() - timedelta(minutes=1) session.add(approval) session.commit() cleaned = api_client.post('/api/approvals/cleanup') assert cleaned.status_code == 200 assert cleaned.json()['success'] is True assert cleaned.json()['dismissedCount'] == 1 active_ids = {item['id'] for item in api_client.get('/api/approvals').json()} assert expired['id'] not in active_ids assert pending['id'] in active_ids history = api_client.get('/api/approvals?include_dismissed=true').json() archived = next(item for item in history if item['id'] == expired['id']) assert archived['status'] == 'expired' assert archived['dismissed_at'] is not None cleanup_audit = next( event for event in api_client.get('/api/audit').json() if event['id'] == cleaned.json()['auditId'] ) assert cleanup_audit['action_id'] == 'approval.cleanup' def test_action_registry_reports_real_execution_modes(api_client): payload = api_client.get('/api/actions/registry').json() items = {item['id']: item for item in payload['items']} assert items['container.restart']['executionMode'] == 'command_center' assert items['container.restart']['commandExecutionSupported'] is True assert items['deluge.pause_all']['executionMode'] == 'approval_then_execute' assert items['unraid.vm.start']['executionMode'] == 'dedicated_surface' assert items['unraid.vm.pause']['executionMode'] == 'approval_then_execute' assert items['unraid.vm.stop']['executionMode'] == 'blocked' assert items['stack.redeploy']['executionMode'] == 'governance_only' assert items['jellyfin.scan_library']['executionMode'] == 'blocked' assert items['deployment.target.promote_workmesh']['executionMode'] == 'approval_then_execute' assert payload['summary']['commandExecutable'] >= 1 assert payload['summary']['approvalExecutable'] == 5 def test_deployment_target_sync_registry_is_bounded_command_action(api_client): payload = api_client.get('/api/actions/registry').json() items = {item['id']: item for item in payload['items']} sync = items['deployment.targets.sync'] assert sync['target'] == 'jens/*' assert sync['risk'] == 'high' assert sync['executionMode'] == 'command_center' assert sync['commandExecutionSupported'] is True def test_deployment_target_sync_executes_existing_bounded_reconciler(api_client, monkeypatch): from backend.app.services import gitea_project_auto_registration calls = [] def fake_sync(self, session, *, force=False): calls.append(force) return { 'status': 'ok', 'summary': { 'total': 64, 'registered': 12, 'knownUnconfigured': 50, 'conflicts': 0, 'errors': 0, 'changedThisSync': 1, }, } monkeypatch.setattr( gitea_project_auto_registration.GiteaProjectAutoRegistration, 'sync', fake_sync, raising=True, ) response = api_client.post( '/api/actions/execute', json={ 'intent': 'deployment.targets.sync', 'target': 'jens/*', 'source': 'manual', 'risk': 'high', }, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is True assert payload['status'] == 'success' assert payload['summary']['registered'] == 12 assert payload['summary']['changed'] == 1 assert calls == [True] audit = api_client.get('/api/audit').json() event = next(item for item in audit if item['id'] == payload['auditId']) assert event['action_id'] == 'deployment.targets.sync' assert event['target'] == 'jens/*' def test_deployment_target_sync_rejects_target_drift(api_client): response = api_client.post( '/api/actions/execute', json={ 'intent': 'deployment.targets.sync', 'target': 'jens/workmesh', 'source': 'manual', 'risk': 'high', }, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['result'] == 'blocked' def test_workmesh_first_deploy_registry_is_fixed_high_risk_command(api_client): payload = api_client.get('/api/actions/registry').json() items = {item['id']: item for item in payload['items']} deploy = items['deployment.target.deploy_workmesh'] assert deploy['target'] == 'jens/workmesh' assert deploy['risk'] == 'high' assert deploy['policyDecision'] == 'approval_required' assert deploy['executionMode'] == 'command_center' assert deploy['commandExecutionSupported'] is True def test_workmesh_first_deploy_contract_is_secret_safe_and_release_pinned(): from backend.app.services.workmesh_first_deployment import ( _CONTAINERS, _FIRST_DEPLOY_SCRIPT, _PORTS, _RELEASE_REVISION, ) assert _RELEASE_REVISION == '7dfbdb7d25e0efd70c53106de1d36d3fff6e3a05' assert _PORTS == (1255, 1256, 1257, 1258) assert _CONTAINERS == ( 'workmesh-postgres-1', 'workmesh-mock-ats-1', 'workmesh-api-1', 'workmesh-n8n-1', 'workmesh-workmesh-1', ) assert 'env_path = env_dir / ".env"' in _FIRST_DEPLOY_SCRIPT assert 'secrets.token_urlsafe(48)' in _FIRST_DEPLOY_SCRIPT assert 'os.open(temporary_env, flags, 0o600)' in _FIRST_DEPLOY_SCRIPT assert 'info.st_uid != 0' in _FIRST_DEPLOY_SCRIPT assert '"WORKMESH_BIND_HOST": "127.0.0.1"' in _FIRST_DEPLOY_SCRIPT assert '"WORKMESH_WEB_PORT": "1255"' in _FIRST_DEPLOY_SCRIPT assert '"WORKMESH_API_PORT": "1256"' in _FIRST_DEPLOY_SCRIPT assert '"N8N_PORT": "1257"' in _FIRST_DEPLOY_SCRIPT assert '"MOCK_ATS_PORT": "1258"' in _FIRST_DEPLOY_SCRIPT assert '["python3", str(deploy_py), "preflight", repository, revision]' in _FIRST_DEPLOY_SCRIPT assert '["python3", str(deploy_py), "deploy", repository, revision]' in _FIRST_DEPLOY_SCRIPT assert 'timeout=1800' in _FIRST_DEPLOY_SCRIPT assert 'stdout=subprocess.DEVNULL' in _FIRST_DEPLOY_SCRIPT assert 'stderr=subprocess.DEVNULL' in _FIRST_DEPLOY_SCRIPT def test_workmesh_first_deploy_script_accepts_the_pinned_release_descriptor(tmp_path, monkeypatch): from backend.app.services.workmesh_first_deployment import _FIRST_DEPLOY_SCRIPT release_revision = '7dfbdb7d25e0efd70c53106de1d36d3fff6e3a05' descriptor = { 'apiVersion': 'itworx.deployment-target/v1', 'applicationId': 'workmesh', 'composeFile': 'compose.yaml', 'composeProject': 'workmesh', 'healthTimeout': 300, 'verifyOciRevision': False, } descriptor_json = json.dumps(descriptor) controller_root = tmp_path / 'controller' registry_path = controller_root / 'projects.json' bare_repo = tmp_path / 'WorkMesh.git' deploy_py = controller_root / 'deploy.py' controller_root.mkdir() registry_path.write_text('{}', encoding='utf-8') deploy_py.write_text('# test stub\n', encoding='utf-8') bare_repo.mkdir() script = _FIRST_DEPLOY_SCRIPT for original, replacement in ( ('Path("/opt/gitea-deploy")', f'Path({json.dumps(str(controller_root))})'), ( 'Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")', f'Path({json.dumps(str(registry_path))})', ), ( 'Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock")', f'Path({json.dumps(str(tmp_path / "deploy.lock"))})', ), ( 'Path("/gitea-repositories/jens/workmesh.git")', f'Path({json.dumps(str(bare_repo))})', ), ): script = script.replace(original, replacement) artifact_checks = [] descriptor_reads = [] class DockerInventoryReached(Exception): pass def fake_run(command, **_kwargs): if command[0] == 'git': operation = command[2] if operation == 'rev-parse': return SimpleNamespace(returncode=0, stdout=f'{release_revision}\n') if operation == 'cat-file': artifact = command[4].split(':', 1)[1] artifact_checks.append(artifact) expected = {'compose.yaml', 'VERSION', '.itworx/deployment-target.json'} return SimpleNamespace(returncode=0 if artifact in expected else 1, stdout='') if operation == 'show': artifact = command[3].split(':', 1)[1] if artifact == 'VERSION': return SimpleNamespace(returncode=0, stdout='0.1.0\n') descriptor_reads.append(artifact) if artifact == '.itworx/deployment-target.json': return SimpleNamespace(returncode=0, stdout=descriptor_json) return SimpleNamespace(returncode=1, stdout='') raise AssertionError(command) if command[:3] == ['docker', 'ps', '-aq']: raise DockerInventoryReached raise AssertionError(command) monkeypatch.setattr(subprocess, 'run', fake_run) monkeypatch.setitem( sys.modules, 'fcntl', SimpleNamespace(LOCK_EX=1, flock=lambda *_args: None), ) with pytest.raises(DockerInventoryReached): exec(compile(script, '', 'exec'), {}) # noqa: S102 assert artifact_checks == [ 'compose.yaml', 'VERSION', '.itworx/deployment-target.json', ] assert descriptor_reads == ['.itworx/deployment-target.json'] def test_workmesh_first_deploy_uses_one_durable_job(api_client, monkeypatch): from backend.app.db import session as db_session from backend.app.services import action_execution from backend.app.services.workmesh_first_deployment import WorkMeshFirstDeployment monkeypatch.setattr( WorkMeshFirstDeployment, 'tracked_job_id', staticmethod(lambda session: None), raising=True, ) monkeypatch.setattr( WorkMeshFirstDeployment, 'run', lambda self, session, *, job: { 'status': 'in_progress', 'execution_id': 'exec-workmesh-1', }, raising=True, ) with Session(db_session.engine) as session: result = action_execution.execute_governed_action( session, action='deployment.target.deploy_workmesh', target='jens/workmesh', source='chatgpt-mcp', risk='high', reason='Test the fixed first-deploy boundary.', original_policy_decision='approval_required', ) assert result['success'] is False assert result['status'] == 'in_progress' assert result['executionId'] == 'exec-workmesh-1' assert result['jobId'].startswith('job-') assert result['auditId'].startswith('audit-') def test_workmesh_first_deploy_blocks_orphan_running_job(api_client, monkeypatch): from sqlmodel import select from backend.app.db import session as db_session from backend.app.db.models import JobRun from backend.app.services import action_execution from backend.app.services.workmesh_first_deployment import WorkMeshFirstDeployment monkeypatch.setattr( WorkMeshFirstDeployment, 'run', lambda self, session, *, job: pytest.fail('must not start a second controller execution'), raising=True, ) with Session(db_session.engine) as session: session.add(JobRun( id='job-orphan-workmesh', type='deployment.target.deploy_workmesh', status='running', action_id='deployment.target.deploy_workmesh', target='jens/workmesh', risk='high', policy_decision='allowed', )) session.commit() result = action_execution.execute_governed_action( session, action='deployment.target.deploy_workmesh', target='jens/workmesh', source='chatgpt-mcp', risk='high', reason='Reconcile the existing first-deploy job.', original_policy_decision='approval_required', ) jobs = session.exec( select(JobRun).where(JobRun.action_id == 'deployment.target.deploy_workmesh') ).all() assert result['status'] == 'outcome_unknown' assert result['jobId'] == 'job-orphan-workmesh' assert len(jobs) == 1 def test_workmesh_first_deploy_reconciles_one_controller_exec(api_client): from backend.app.db import session as db_session from backend.app.services.job_runner import create_job, start_job from backend.app.services.workmesh_first_deployment import WorkMeshFirstDeployment class FakeResponse: def __init__(self, payload): self.payload = payload def json(self): return self.payload class FakePortainer: def __init__(self): self.started = False self.running = True self.calls = [] def get_container_status(self, name): assert name == 'gitea-deploy-control' return { 'status': 'ok', 'container': { 'id': 'controller-1', 'name': 'gitea-deploy-control', 'state': 'running', }, } def _config(self): return SimpleNamespace(endpoint_id='3') def _request(self, method, path, **kwargs): self.calls.append((method, path)) if method == 'POST' and path.endswith('/exec'): return FakeResponse({'Id': 'exec-workmesh-1'}) if method == 'POST' and path.endswith('/start'): self.started = True return FakeResponse({}) if method == 'GET' and path.endswith('/json'): return FakeResponse({ 'ID': 'exec-workmesh-1', 'ContainerID': 'controller-1', 'Running': self.running, 'ExitCode': 0 if not self.running else None, }) raise AssertionError((method, path)) fake = FakePortainer() with Session(db_session.engine) as session: job = create_job( session, action_id='deployment.target.deploy_workmesh', target='jens/workmesh', source='chatgpt-mcp', risk='high', policy_decision='allowed', steps=['fixed first deploy'], ) start_job(session, job) service = WorkMeshFirstDeployment(portainer=fake) first = service.run(session, job=job) assert first == { 'status': 'in_progress', 'execution_id': 'exec-workmesh-1', } assert fake.started is True assert service.tracked_job_id(session) == job.id cancelled = api_client.post(f'/api/jobs/{job.id}/cancel').json() assert cancelled['success'] is False assert 'cannot be cancelled' in cancelled['message'] assert cancelled['status'] == 'running' second = service.run(session, job=job) assert second['status'] == 'in_progress' assert second['execution_id'] == 'exec-workmesh-1' fake.running = False third = service.run(session, job=job) assert third['status'] == 'deployed' assert third['revision'] == '7dfbdb7d25e0efd70c53106de1d36d3fff6e3a05' assert third['ports'] == [1255, 1256, 1257, 1258] fourth = service.run(session, job=job) assert fourth == third create_calls = [call for call in fake.calls if call[0] == 'POST' and call[1].endswith('/exec')] assert len(create_calls) == 1 @pytest.mark.parametrize('ambiguous_at', ['create', 'start']) def test_workmesh_first_deploy_does_not_repeat_ambiguous_exec(api_client, ambiguous_at): from backend.app.db import session as db_session from backend.app.services.job_runner import create_job, start_job from backend.app.services.workmesh_first_deployment import WorkMeshFirstDeployment class FakeResponse: def __init__(self, payload): self.payload = payload def json(self): return self.payload class FakePortainer: def __init__(self): self.create_calls = 0 def get_container_status(self, _name): return { 'status': 'ok', 'container': { 'id': 'controller-1', 'name': 'gitea-deploy-control', 'state': 'running', }, } def _config(self): return SimpleNamespace(endpoint_id='3') def _request(self, method, path, **_kwargs): if method == 'POST' and path.endswith('/exec'): self.create_calls += 1 if ambiguous_at == 'create': raise RuntimeError('provider response lost after exec creation') return FakeResponse({'Id': 'exec-workmesh-1'}) if method == 'POST' and path.endswith('/start'): raise RuntimeError('provider response lost after exec start') raise AssertionError((method, path)) fake = FakePortainer() with Session(db_session.engine) as session: job = create_job( session, action_id='deployment.target.deploy_workmesh', target='jens/workmesh', source='chatgpt-mcp', risk='high', policy_decision='allowed', steps=['fixed first deploy'], ) start_job(session, job) service = WorkMeshFirstDeployment(portainer=fake) first = service.run(session, job=job) second = service.run(session, job=job) assert first['status'] == 'outcome_unknown' assert second['status'] == 'outcome_unknown' assert service.tracked_job_id(session) == job.id assert fake.create_calls == 1 def test_workmesh_first_deploy_rejects_target_drift(api_client): from backend.app.db import session as db_session from backend.app.services import action_execution with Session(db_session.engine) as session: result = action_execution.execute_governed_action( session, action='deployment.target.deploy_workmesh', target='jens/not-workmesh', source='chatgpt-mcp', risk='high', reason='Reject target drift.', original_policy_decision='approval_required', ) assert result['success'] is False assert result['status'] == 'failed' assert result['result'] == 'blocked' @pytest.mark.parametrize('state_mode', ['missing', 'other_job']) def test_running_workmesh_first_deploy_cannot_be_cancelled_via_http_or_mcp( tmp_path, state_mode ): from backend.app.api import routes_jobs from backend.app.db.models import AppSetting, JobRun from backend.app.services import mcp_operations from backend.app.services.workmesh_first_deployment import _STATE_KEY engine = create_engine(f"sqlite:///{tmp_path / 'workmesh-cancel.db'}") SQLModel.metadata.create_all(engine) with Session(engine) as session: job = JobRun( id='job-workmesh-active', type='deployment.target.deploy_workmesh', status='running', source='chatgpt-mcp', action_id='deployment.target.deploy_workmesh', target='jens/workmesh', risk='high', policy_decision='allowed', ) session.add(job) if state_mode == 'other_job': session.add(AppSetting( key=_STATE_KEY, value_json={'state': 'started', 'jobId': 'job-different'}, is_secret=False, )) session.commit() http_result = routes_jobs.cancel(job.id, session=session) mcp_result = mcp_operations.cancel_operation_job(session, job.id) session.refresh(job) assert http_result['success'] is False assert http_result['status'] == 'running' assert 'cannot be cancelled' in http_result['message'] assert mcp_result['cancelled'] is False assert mcp_result['changed'] is False assert mcp_result['status'] == 'running' assert 'cannot be cancelled' in mcp_result['reason'] assert job.status == 'running' @pytest.fixture def restart_service(monkeypatch): from backend.app.services import container_restart_service monkeypatch.setattr( container_restart_service.PortainerConnector, 'get_container_status', lambda self, container_name: {'status': 'ok', 'container': {'name': container_name, 'state': 'running'}}, raising=True, ) monkeypatch.setattr( container_restart_service.PortainerConnector, 'restart_container', lambda self, target: {'status': 'ok', 'container': target, 'action': 'container.restart', 'target': target}, raising=True, ) monkeypatch.setattr( container_restart_service, '_widefrog_health_check', lambda: {'status': 'ok'}, raising=True, ) return container_restart_service def test_restart_manual_execute_succeeds(api_client, restart_service): response = api_client.post( '/api/actions/execute', json={'intent': 'container.restart', 'target': 'widefrog', 'source': 'manual', 'risk': 'medium'}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is True assert payload['decision'] == 'allowed' assert payload['status'] == 'success' assert payload['policy_decision'] == 'allowed' assert payload['result'] == 'success' assert payload['jobId'].startswith('job-') assert payload['auditId'].startswith('audit-') assert isinstance(payload.get('steps', []), list) assert isinstance(payload.get('post_checks', []), list) assert isinstance(payload.get('before_state', {}), dict) assert isinstance(payload.get('after_state', {}), dict) assert payload['before_state'] assert payload['after_state'] jobs = api_client.get('/api/jobs').json() assert any(job['id'] == payload['jobId'] and job['status'] == 'succeeded' for job in jobs) def test_restart_manual_endpoint_succeeds(api_client, restart_service): response = api_client.post('/api/actions/container/widefrog/restart') assert response.status_code == 200 payload = response.json() assert payload['success'] is True assert payload['decision'] == 'allowed' assert payload['status'] == 'success' def test_action_plan_recognizes_check_unraid_phrase(api_client): response = api_client.post( '/api/actions/plan', json={'input': 'check unraid', 'source': 'manual'}, ) assert response.status_code == 200 payload = response.json() assert payload['intent'] == 'unraid.health_summary' assert payload['target'] == 'unraid' assert payload['policy'] == 'allowed' assert payload['risk'] == 'low' assert payload['plan'] def test_restart_voice_endpoint_succeeds_with_valid_token(api_client, restart_service): response = api_client.post( '/api/voice/actions/container/widefrog/restart', headers={'Authorization': 'Bearer test-voice-token'}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is True assert payload['decision'] == 'allowed' assert payload['status'] == 'success' def test_restart_voice_endpoint_rejects_missing_or_invalid_token(api_client, restart_service, 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.post('/api/voice/actions/container/widefrog/restart') assert missing.status_code == 401 wrong = api_client.post( '/api/voice/actions/container/widefrog/restart', headers={'Authorization': 'Bearer wrong-token'}, ) assert wrong.status_code == 403 def test_voice_unraid_health_endpoint_rejects_missing_or_invalid_token(api_client, restart_service, 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.post('/api/voice/actions/unraid/health') assert missing.status_code == 401 wrong = api_client.post( '/api/voice/actions/unraid/health', headers={'Authorization': 'Bearer wrong-token'}, ) assert wrong.status_code == 403 def test_voice_playbook_endpoint_rejects_missing_or_invalid_token(api_client, restart_service, 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.post('/api/voice/actions/playbooks/fix-widefrog/run') assert missing.status_code == 401 wrong = api_client.post( '/api/voice/actions/playbooks/fix-widefrog/run', headers={'Authorization': 'Bearer wrong-token'}, ) assert wrong.status_code == 403 def test_voice_unraid_health_uses_explicit_readonly_policy(api_client, restart_service): response = api_client.post( '/api/voice/actions/unraid/health', headers={'Authorization': 'Bearer test-voice-token'}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is True assert payload['decision'] == 'allowed' assert payload['policy_decision'] == 'allowed' assert payload['status'] == 'success' assert isinstance(payload.get('data'), dict) def test_voice_playbook_run_evaluates_policy(api_client, restart_service): response = api_client.post( '/api/voice/actions/playbooks/fix-widefrog/run', headers={'Authorization': 'Bearer test-voice-token'}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] in {'approval_required', 'blocked'} assert payload['policy_decision'] in {'approval_required', 'blocked'} assert payload['status'] in {'failed'} def test_restart_requires_approval_for_authentik(api_client, restart_service): response = api_client.post( '/api/actions/execute', json={'intent': 'container.restart', 'target': 'authentik', 'source': 'manual', 'risk': 'high'}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'approval_required' assert payload['approvalId'].startswith('appr-') assert payload['auditId'].startswith('audit-') assert payload['status'] == 'failed' assert payload['result'] == 'approval_required' def test_restart_fails_when_portainer_missing_config(api_client, monkeypatch): from backend.app.services import container_restart_service monkeypatch.setattr( container_restart_service.PortainerConnector, 'get_container_status', lambda self, container_name: {'status': 'missing_config', 'reason': 'missing_config'}, raising=True, ) monkeypatch.setattr( container_restart_service.PortainerConnector, 'restart_container', lambda self, target: {'status': 'missing_config', 'reason': 'missing_config'}, raising=True, ) response = api_client.post('/api/actions/container/widefrog/restart') assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'failed' assert payload['message'] == 'missing_config' assert payload['jobId'].startswith('job-') assert payload['status'] == 'failed' def test_restart_execute_fails_when_portainer_missing_config(api_client, monkeypatch): from backend.app.services import container_restart_service monkeypatch.setattr( container_restart_service.PortainerConnector, 'get_container_status', lambda self, container_name: {'status': 'missing_config', 'reason': 'missing_config'}, raising=True, ) monkeypatch.setattr( container_restart_service.PortainerConnector, 'restart_container', lambda self, target: {'status': 'missing_config', 'reason': 'missing_config'}, raising=True, ) response = api_client.post( '/api/actions/execute', json={'intent': 'container.restart', 'target': 'widefrog', 'source': 'manual', 'risk': 'medium'}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'failed' assert payload['message'] == 'missing_config' assert payload['jobId'].startswith('job-') assert payload['auditId'].startswith('audit-') assert payload['status'] == 'failed' assert payload['result'] == 'failed' def test_restart_manual_endpoint_fails_when_portainer_missing_config(api_client): response = api_client.post('/api/actions/container/widefrog/restart') assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'failed' assert payload['message'] == 'missing_config' assert payload['jobId'].startswith('job-') assert payload['auditId'].startswith('audit-') assert payload['status'] == 'failed' def test_restart_voice_endpoint_fails_when_portainer_missing_config(api_client): response = api_client.post( '/api/voice/actions/container/widefrog/restart', headers={'Authorization': 'Bearer test-voice-token'}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'failed' assert payload['message'] == 'missing_config' assert payload['jobId'].startswith('job-') assert payload['auditId'].startswith('audit-') assert payload['status'] == 'failed' def test_security_policy_blocks_volume_delete(api_client, restart_service): response = api_client.post( '/api/actions/execute', json={'intent': 'volume.delete', 'target': 'postgres-data', 'source': 'manual', 'risk': 'critical', 'plan': []}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'blocked' assert payload['message'] == 'Action uses default governance policy.' def test_action_registry_returns_policy_preview_without_secrets(api_client): response = api_client.get('/api/actions/registry') assert response.status_code == 200 payload = response.json() assert payload['summary']['total'] >= 2 assert isinstance(payload['items'], list) actions = {item['id']: item for item in payload['items']} assert actions['safety.summary']['risk'] == 'low' assert actions['safety.summary']['policyDecision'] == 'allowed' assert actions['safety.summary']['capability'] == 'read_only' assert actions['volume.delete']['risk'] == 'critical' assert actions['volume.delete']['policyDecision'] == 'blocked' assert actions['volume.delete']['capability'] == 'destructive' serialized = str(payload).lower() assert 'password' not in serialized assert 'bearer ' not in serialized assert 'test-voice-token' not in serialized def test_action_registry_detail_is_read_only_and_policy_aware(api_client): response = api_client.get('/api/actions/registry/volume.delete') assert response.status_code == 200 payload = response.json() assert payload['id'] == 'volume.delete' assert payload['risk'] == 'critical' assert payload['policyDecision'] == 'blocked' assert payload['capability'] == 'destructive' assert payload['executionSupported'] is False assert any('blocked' in item.lower() for item in payload['safeguards']) assert any('volume' in item.lower() for item in payload['blastRadius']) assert 'password' not in str(payload).lower() assert 'test-voice-token' not in str(payload).lower() def test_action_lifecycle_v1_excluded_actions_are_blocked_and_visible(api_client): response = api_client.get('/api/actions/registry') assert response.status_code == 200 actions = {item['id']: item for item in response.json()['items']} for action_id in ['container.stop', 'container.clear_logs', 'gateway.clear_cache']: assert action_id in actions assert actions[action_id]['policyDecision'] == 'blocked' assert actions[action_id]['capability'] == 'destructive' detail = api_client.get(f'/api/actions/registry/{action_id}').json() assert detail['executionSupported'] is False assert detail['policyDecision'] == 'blocked' assert any('v1' in item.lower() or 'blocked' in item.lower() for item in detail['safeguards']) assert 'v1' in detail['operatorGuidance'].lower() preflight = api_client.post('/api/actions/preflight', json={'action': action_id, 'target': actions[action_id]['target']}).json() assert preflight['policyDecision'] == 'blocked' assert preflight['rollbackAvailable'] is False def test_action_registry_detail_rejects_unknown_action(api_client): response = api_client.get('/api/actions/registry/not-a-real-action') assert response.status_code == 404 def test_security_voice_blocks_postgres_restart(api_client): response = api_client.post( '/api/actions/execute', json={'intent': 'container.restart', 'target': 'postgres', 'source': 'voice', 'risk': 'critical', 'plan': []}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'blocked' assert payload['status'] == 'failed' def test_security_voice_blocks_redis_restart(api_client): response = api_client.post( '/api/actions/execute', json={'intent': 'container.restart', 'target': 'redis', 'source': 'voice', 'risk': 'critical', 'plan': []}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'blocked' assert payload['status'] == 'failed' def test_security_voice_blocks_unraid_mover_start(api_client): response = api_client.post( '/api/actions/execute', json={'intent': 'unraid.mover.start', 'target': 'unraid', 'source': 'voice', 'risk': 'high', 'plan': []}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'blocked' assert payload['status'] == 'failed' def test_security_unraid_mover_start_requires_approval_manually(api_client): response = api_client.post( '/api/actions/execute', json={'intent': 'unraid.mover.start', 'target': 'unraid', 'source': 'manual', 'risk': 'high', 'plan': []}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'approval_required' assert payload['approvalId'].startswith('appr-') assert payload['auditId'].startswith('audit-') def test_security_stack_redeploy_requires_approval(api_client, restart_service): response = api_client.post( '/api/actions/execute', json={'intent': 'stack.redeploy', 'target': 'prod', 'source': 'manual', 'risk': 'high', 'plan': []}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'approval_required' assert payload['approvalId'].startswith('appr-') def test_security_authentik_restart_requires_approval_for_voice(api_client): response = api_client.post( '/api/actions/execute', json={'intent': 'container.restart', 'target': 'authentik', 'source': 'voice', 'risk': 'high', 'plan': []}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] in {'approval_required', 'blocked'} assert payload['status'] in {'failed'} def test_security_portainer_restart_requires_approval_or_blocked_for_voice(api_client): response = api_client.post( '/api/actions/execute', json={'intent': 'container.restart', 'target': 'portainer', 'source': 'voice', 'risk': 'high', 'plan': []}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] in {'approval_required', 'blocked'} assert payload['status'] == 'failed' def test_connector_test_reports_missing_config_for_portainer(api_client, monkeypatch): prev = _with_updated_connectors( portainer_url='', portainer_key=None, portainer_endpoint=None, unraid_url=None, unraid_key=None, ) response = api_client.post('/api/connectors/portainer/test') payload = response.json() assert response.status_code == 200 assert payload['success'] is False assert payload['status'] == 'missing_config' assert payload['reason'] == 'missing_config' assert payload['missing_config'] is True assert payload['required_env_vars'] == ['PORTAINER_URL', 'PORTAINER_API_KEY', 'PORTAINER_ENDPOINT_ID'] assert payload['secretRef'] == 'PORTAINER_API_KEY' _restore_connectors(prev) def test_connector_test_reports_missing_config_for_unraid(api_client): prev = _with_updated_connectors( portainer_url=None, portainer_key=None, portainer_endpoint=None, unraid_url='', unraid_key=None, ) response = api_client.post('/api/connectors/unraid/test') payload = response.json() assert response.status_code == 200 assert payload['success'] is False assert payload['status'] == 'missing_config' assert payload['reason'] == 'missing_config' assert payload['missing_config'] is True assert payload['required_env_vars'] == ['UNRAID_URL', 'UNRAID_API_KEY'] assert payload['secretRef'] == 'UNRAID_API_KEY' _restore_connectors(prev) def test_connector_error_redacts_portainer_secret_from_api_error(api_client, monkeypatch): prev = _with_updated_connectors( portainer_url='https://localhost:9443', portainer_key='PORTA_SECRET_TOKEN', portainer_endpoint='1', ) def fail_request(*_args, **_kwargs): raise RuntimeError('x-api-key=PORTA_SECRET_TOKEN') monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector._request', fail_request, raising=True) response = api_client.post('/api/connectors/portainer/test') payload = response.json() assert response.status_code == 200 assert payload['status'] in {'error', 'unreachable'} reason = str(payload.get('reason')) assert 'PORTA_SECRET_TOKEN' not in reason assert '[REDACTED]' in reason _restore_connectors(prev) def test_containers_endpoint_can_request_readonly_portainer_stats(api_client, monkeypatch): from backend.app.api import routes_containers class FakePortainerConnector: def list_containers(self, include_stats=False, stats_limit=200): assert include_stats is True assert stats_limit == 50 return { 'status': 'ok', 'containers': [{ 'id': 'abc123', 'name': 'widefrog', 'appId': 'widefrog', 'state': 'running', 'health': 'healthy', 'image': 'widefrog:latest', 'ports': [], 'cpu': '12.3%', 'ram': '256 MiB / 1 GiB', 'metricsStatus': 'measured', 'restartCount': 0, 'criticality': 'medium', 'voiceEnabled': False, 'protected': False, }], } monkeypatch.setattr(routes_containers, 'PortainerConnector', FakePortainerConnector, raising=True) response = api_client.get('/api/containers?include_stats=true&stats_limit=50') assert response.status_code == 200 payload = response.json() assert payload[0]['cpu'] == '12.3%' assert payload[0]['ram'] == '256 MiB / 1 GiB' assert payload[0]['metricsStatus'] == 'measured' def test_portainer_connector_maps_docker_stats_without_mutation(monkeypatch): from backend.app.connectors.portainer import PortainerConnector prev = _with_updated_connectors( portainer_url='https://portainer.local', portainer_key='PORTA_SECRET_TOKEN', portainer_endpoint='9', ) class FakeResponse: def __init__(self, payload): self._payload = payload self.text = '' self.status_code = 200 def json(self): return self._payload def fake_request(self, method, path, **_kwargs): assert method == 'GET' if path.endswith('/docker/containers/json?all=1'): return FakeResponse([{ 'Id': 'abc123', 'Names': ['/widefrog'], 'Image': 'widefrog:latest', 'State': 'running', 'Ports': [], }]) if path.endswith('/docker/containers/abc123/stats'): return FakeResponse({ 'cpu_stats': { 'online_cpus': 4, 'system_cpu_usage': 200_000_000, 'cpu_usage': {'total_usage': 120_000_000}, }, 'precpu_stats': { 'system_cpu_usage': 100_000_000, 'cpu_usage': {'total_usage': 100_000_000}, }, 'memory_stats': { 'usage': 268_435_456, 'limit': 1_073_741_824, }, }) raise AssertionError(f'unexpected path {path}') monkeypatch.setattr(PortainerConnector, '_request', fake_request, raising=True) result = PortainerConnector().list_containers(include_stats=True, stats_limit=10) assert result['status'] == 'ok' assert result['containers'][0]['cpu'] == '80.0%' assert result['containers'][0]['ram'] == '256 MiB / 1 GiB' assert result['containers'][0]['metricsStatus'] == 'measured' assert result['containers'][0]['metricsSource'] == 'portainer.docker.stats' _restore_connectors(prev) def test_connector_error_redacts_unraid_secret_from_api_error(api_client, monkeypatch): prev = _with_updated_connectors( unraid_url='https://localhost:9444', unraid_key='UNRAID_SECRET_TOKEN', ) def fail_request(*_args, **_kwargs): raise RuntimeError('api_key=UNRAID_SECRET_TOKEN') monkeypatch.setattr('backend.app.connectors.unraid.UnraidConnector._request', fail_request, raising=True) response = api_client.post('/api/connectors/unraid/test') payload = response.json() assert response.status_code == 200 assert payload['status'] in {'error', 'unreachable'} reason = str(payload.get('reason')) assert 'UNRAID_SECRET_TOKEN' not in reason assert '[REDACTED]' in reason _restore_connectors(prev) def test_action_execute_idempotency_replays_without_duplicate_execution(api_client): payload = { 'intent': 'container.restart', 'target': 'missing-app', 'source': 'manual', 'plan': ['Resolve container', 'Restart container'], } headers = {'Idempotency-Key': 'restart-missing-app-1'} first = api_client.post('/api/actions/execute', json=payload, headers=headers) replay = api_client.post('/api/actions/execute', json=payload, headers=headers) assert first.status_code == 200 assert replay.status_code == 200 assert replay.json()['idempotentReplay'] is True assert replay.json()['auditId'] == first.json()['auditId'] conflict = api_client.post( '/api/actions/execute', json={**payload, 'target': 'different-app'}, headers=headers, ) assert conflict.status_code == 409 @pytest.fixture def advisory_ai_provider(monkeypatch): from backend.app.services import managed_ai calls = [] def analyze(_session, question, context): calls.append((question, context)) return { 'content': 'Een herstart vereist uitvoering via het Command Center.', 'model': 'test-advisor', 'revision': 'test-r1', 'deploymentId': 'test-deployment', 'requestId': 'test-request', 'artifactDigest': 'sha256:' + 'a' * 64, } monkeypatch.setattr(managed_ai, 'analyze', analyze) return calls def test_ai_route_does_not_execute_actions(api_client, advisory_ai_provider): response = api_client.post( '/api/ai/chat', json={'content': 'Herstart authentik nu meteen'}, ) assert response.status_code == 200 payload = response.json() assert payload['id'].startswith('chat-') assert payload['role'] == 'assistant' assert 'success' not in payload assert 'decision' not in payload assert 'status' not in payload assert 'policy_decision' not in payload assert 'result' not in payload assert len(advisory_ai_provider) == 1 def test_ai_route_does_not_create_jobs_or_approvals(api_client, advisory_ai_provider): initial_jobs = api_client.get('/api/jobs').json() initial_approvals = api_client.get('/api/approvals').json() response = api_client.post( '/api/ai/chat', json={'content': 'Herstart authentik nu meteen, maak een job aan en stuur approval aan'}, ) assert response.status_code == 200 payload = response.json() assert payload['id'].startswith('chat-') assert payload['role'] == 'assistant' assert 'jobId' not in payload assert 'approvalId' not in payload assert 'auditId' not in payload jobs = api_client.get('/api/jobs').json() approvals = api_client.get('/api/approvals').json() assert jobs == initial_jobs assert approvals == initial_approvals assert len(advisory_ai_provider) == 1 def test_ai_history_does_not_return_fake_run_events(api_client): response = api_client.get('/api/ai/history') assert response.status_code == 200 payload = response.json() assert payload == [] def test_ai_chat_response_is_advisory_without_action_buttons(api_client, advisory_ai_provider): response = api_client.post( '/api/ai/chat', json={'content': 'Herstart widefrog en voer de fix uit'}, ) assert response.status_code == 200 payload = response.json() metadata = payload.get('metadata') or {} assert 'suggestedActions' not in metadata assert 'actionPlan' not in metadata assert 'approvalId' not in payload assert 'jobId' not in payload assert len(advisory_ai_provider) == 1 def test_ai_provider_unavailable_creates_no_jobs_approvals_or_history(api_client, monkeypatch): from backend.app.services import managed_ai def unavailable(*_args): raise managed_ai.ManagedAiError('NOT_CONFIGURED') monkeypatch.setattr(managed_ai, 'analyze', unavailable) before = {path: api_client.get(path).json() for path in ('/api/jobs', '/api/approvals', '/api/ai/history')} response = api_client.post('/api/ai/chat', json={'content': 'Herstart authentik meteen'}) assert response.status_code == 503 assert response.json()['detail']['code'] == 'NOT_CONFIGURED' assert {path: api_client.get(path).json() for path in before} == before def test_ai_status_returns_frontend_summary_contract(api_client): response = api_client.get('/api/ai/status') assert response.status_code == 200 payload = response.json() assert set(payload) >= { 'dailySummary', 'currentRisks', 'recommendedActions', 'storageForecast', 'securityScore', 'autopilotStatus', } assert isinstance(payload['dailySummary'], str) assert isinstance(payload['currentRisks'], int) assert isinstance(payload['recommendedActions'], int) assert isinstance(payload['storageForecast'], str) assert isinstance(payload['securityScore'], int) assert payload['autopilotStatus'] in {'active', 'paused', 'off'} assert 'tools' not in payload assert 'enabled' not in payload def test_ai_recommendations_return_advisory_frontend_contract(api_client): response = api_client.get('/api/ai/recommendations') assert response.status_code == 200 payload = response.json() assert isinstance(payload, list) assert payload for recommendation in payload: assert set(recommendation) >= { 'id', 'title', 'severity', 'confidence', 'evidence', 'affectedApps', 'suggestedAction', 'status', } assert recommendation['severity'] in {'low', 'medium', 'high', 'critical'} assert isinstance(recommendation['confidence'], float) assert recommendation['status'] in {'open', 'applied', 'snoozed', 'dismissed'} assert 'actionPlan' not in recommendation assert 'jobId' not in recommendation assert 'approvalId' not in recommendation assert 'auditId' not in recommendation def test_playbooks_endpoint_returns_live_contract_shape(api_client): response = api_client.get('/api/playbooks') assert response.status_code == 200 payload = response.json() assert isinstance(payload, list) assert payload first = payload[0] assert set(first) >= { 'id', 'name', 'description', 'risk', 'allowedSources', 'requiredConnectors', 'steps', 'successRate', } assert first['risk'] in {'low', 'medium', 'high', 'critical'} assert all(source in {'manual', 'voice', 'automation', 'system'} for source in first['allowedSources']) assert isinstance(first['requiredConnectors'], list) assert isinstance(first['steps'], list) assert isinstance(first['successRate'], int | float) def test_playbooks_catalog_is_live_readonly_and_reflects_connector_readiness(api_client): payload = api_client.get('/api/playbooks').json() by_id = {item['id']: item for item in payload} expected_operational_playbooks = { 'storage-pressure-review', 'exposure-auth-coverage-review', 'media-stalled-download-review', 'unhealthy-container-triage', 'app-inventory-normalization', 'backup-readiness-check', } assert expected_operational_playbooks <= set(by_id) assert len(payload) >= 8 assert 'connector-readiness-review' in by_id connector_review = by_id['connector-readiness-review'] assert connector_review['risk'] == 'medium' assert connector_review['successRate'] < 100 assert 'Deluge' in connector_review['requiredConnectors'] assert any('degraded connector' in step.lower() for step in connector_review['steps']) assert all('lastRuns' not in item for item in payload) assert all('delete' not in ' '.join(item['steps']).lower() for item in payload) def test_observability_endpoints_keep_frontend_contract_shape(api_client): routes = { '/api/incidents': ('id', 'severity', 'status', 'title', 'detectedTime', 'evidence', 'suggestedActions'), '/api/healthchecks': ('service', 'status', 'checkedAt'), '/api/network/exposure': ('source', 'host', 'port', 'protocol', 'protected', 'risk'), } for path, required_keys in routes.items(): response = api_client.get(path) assert response.status_code == 200 payload = response.json() assert isinstance(payload, list) if path != '/api/network/exposure': assert payload if payload: assert set(payload[0]) >= set(required_keys) statistics = api_client.get('/api/statistics/summary').json() assert set(statistics) >= {'cpuHistory', 'ramHistory', 'restartsByApp', 'blockedActions', 'voiceActionCount'} storage = api_client.get('/api/storage/summary').json() assert set(storage) >= {'cacheUsage', 'arrayUsage', 'shares', 'appDataUsage'} operations_map = api_client.get('/api/operations-map').json() assert set(operations_map) >= {'nodes', 'edges'} assert isinstance(operations_map['nodes'], list) assert isinstance(operations_map['edges'], list) def test_healthchecks_reflect_connector_readiness(api_client): from sqlmodel import Session from backend.app.core.config import settings from backend.app.db import session as db_session from backend.app.db.models import Connector settings.voice_token = "test-voice-token" with Session(db_session.engine) as session: portainer = session.get(Connector, "portainer") unraid = session.get(Connector, "unraid") home_assistant = session.get(Connector, "home_assistant") assert portainer is not None assert unraid is not None assert home_assistant is not None portainer.status = "connected" unraid.status = "connected" home_assistant.status = "missing_config" session.add(portainer) session.add(unraid) session.add(home_assistant) session.commit() response = api_client.get('/api/healthchecks') assert response.status_code == 200 checks = {item['service']: item for item in response.json()} assert checks['Portainer']['status'] == 'pass' assert checks['Unraid']['status'] == 'pass' assert checks['Voice Bridge']['status'] == 'warn' assert all(not item['checkedAt'].startswith('2026-06-25') for item in checks.values()) assert 'token' not in str(checks).lower() def test_storage_summary_uses_readonly_connector_sources(api_client, monkeypatch): monkeypatch.setattr( 'backend.app.connectors.unraid.UnraidConnector.get_cache_status', lambda self: {'status': 'ok', 'cacheUsage': 64}, raising=True, ) monkeypatch.setattr( 'backend.app.connectors.unraid.UnraidConnector.get_share_summary', lambda self: { 'status': 'ok', 'shares': [ {'name': 'appdata', 'usedGiB': 100, 'sizeGiB': 200, 'critical': False}, {'name': 'media', 'usedGiB': 900, 'sizeGiB': 1000, 'critical': True}, ], }, raising=True, ) monkeypatch.setattr( 'backend.app.connectors.unraid.UnraidConnector.get_disk_warnings', lambda self: { 'status': 'ok', 'diskWarnings': 0, 'disks': [ {'name': 'disk1', 'status': 'DISK_OK', 'healthy': True, 'usedGiB': 50, 'sizeGiB': 100, 'freeGiB': 50}, ], }, raising=True, ) monkeypatch.setattr( 'backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: { 'status': 'ok', 'containers': [ {'name': 'widefrog', 'appId': 'widefrog', 'mounts': [{'source': '/srv/example/appdata/widefrog', 'destination': '/data', 'type': 'bind'}]}, {'name': 'authentik-server', 'appId': 'authentik', 'mounts': [{'source': '/srv/example/appdata/authentik', 'destination': '/media', 'type': 'bind'}]}, {'name': 'authentik-worker', 'appId': 'authentik', 'mounts': [{'source': '/srv/example/appdata/authentik-worker', 'destination': '/worker', 'type': 'bind'}]}, ], }, raising=True, ) payload = api_client.get('/api/storage/summary').json() assert payload['cacheUsage'] == 64 assert payload['arrayUsage'] == 83 assert payload['disks'][0]['name'] == 'disk1' assert payload['shares'][1]['name'] == 'media' assert len(payload['appDataUsage']) == 3 assert payload['appDataUsage'][0]['usedGiB'] is None assert payload['appDataUsage'][0]['share'] == 'appdata' assert payload['orphanedVolumes'] == [] assert payload['sources'] == {'unraid': 'ok', 'unraidDisks': 'ok', 'portainer': 'ok'} def test_storage_summary_keeps_portainer_volume_hints_when_unraid_schema_partial(api_client, monkeypatch): monkeypatch.setattr( 'backend.app.connectors.unraid.UnraidConnector.get_cache_status', lambda self: {'status': 'error', 'reason': 'GraphQL validation failed', 'cacheUsage': 0}, raising=True, ) monkeypatch.setattr( 'backend.app.connectors.unraid.UnraidConnector.get_share_summary', lambda self: {'status': 'error', 'reason': 'Cannot query field shareSummary', 'shares': []}, raising=True, ) monkeypatch.setattr( 'backend.app.connectors.unraid.UnraidConnector.get_disk_warnings', lambda self: {'status': 'error', 'reason': 'Cannot query field disks', 'disks': [], 'diskWarnings': 0}, raising=True, ) monkeypatch.setattr( 'backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: { 'status': 'ok', 'containers': [ {'name': 'plex', 'appId': 'plex', 'mounts': [{'source': '/srv/example/appdata/plex', 'destination': '/config'}]}, {'name': 'plex-worker', 'appId': 'plex', 'mounts': [{'source': '/srv/example/media', 'destination': '/media'}]}, {'name': 'radarr', 'appId': 'radarr', 'mounts': [{'source': '/srv/example/appdata/radarr', 'destination': '/config'}]}, ], }, raising=True, ) payload = api_client.get('/api/storage/summary').json() assert payload['sources']['unraid'] == 'error' assert payload['sources']['unraidDisks'] == 'error' assert payload['sources']['portainer'] == 'ok' assert payload['sourceWarnings'][0]['source'] == 'unraid' assert payload['shares'] == [] assert len(payload['appDataUsage']) == 3 assert payload['appDataUsage'][0]['volume'] == '/srv/example/appdata/plex -> /config' assert payload['appDataUsage'][0]['usedGiB'] is None def test_safety_summary_partial_when_key_sources_missing_without_critical_evidence(api_client): payload = api_client.get('/api/safety/summary').json() assert payload['status'] in {'partial', 'warning'} assert payload['criticalCount'] == 0 assert payload['score'] >= 0 assert payload['dataCompleteness']['percentage'] < 100 assert {'npm', 'authentik', 'unraid'} & set(payload['missingSources']) assert len(payload['sections']) == 9 assert all(section['status'] != 'critical' for section in payload['sections'] if section['id'] in {'external-exposure', 'authentication-coverage', 'unraid-host-safety'}) def test_safety_routes_return_center_payloads_and_no_secret_values(api_client): summary = api_client.get('/api/safety/summary').json() sections = api_client.get('/api/safety/sections').json() findings = api_client.get('/api/safety/findings').json() recommendations = api_client.get('/api/safety/recommendations').json() breakdown = api_client.get('/api/safety/score-breakdown').json() assert summary['recommendationCount'] == len(recommendations) assert len(sections) == 9 assert isinstance(findings, list) assert breakdown['baseScore'] == 100 assert breakdown['finalScore'] == summary['score'] assert all(recommendation['executable'] is False for recommendation in recommendations) serialized = str({'summary': summary, 'sections': sections, 'findings': findings, 'recommendations': recommendations, 'breakdown': breakdown}) assert 'Bearer ' not in serialized assert 'test-voice-token' not in serialized assert 'TOKEN_VALUE' not in serialized def test_safety_section_completeness_counts_partial_sources(): from backend.app.services import safety_center_service findings = [{ 'id': 'ai-sec-media-pipeline-issue', 'title': 'Media pipeline needs review', 'description': 'Media pipeline has items requiring attention.', 'severity': 'medium', 'category': 'media', 'affectedResources': ['media_pipeline'], 'evidence': [], 'suggestedActions': [], 'source': 'AI Security Reviewer', 'status': 'open', }] coverage = {'media_pipeline': {'status': 'partial', 'label': 'Media Pipeline', 'effectOnConfidence': 'medium'}} sections = safety_center_service._sections(findings, [], coverage) media = next(section for section in sections if section['id'] == 'media-pipeline') assert media['status'] == 'partial' assert media['dataCompleteness'] == 50 def test_safety_dashboard_returns_combined_cached_payload(api_client, monkeypatch): from backend.app.services import safety_center_service safety_center_service.invalidate_cache() calls = {'count': 0} original_generate_review = safety_center_service.ai_security_reviewer.generate_review def counted_generate_review(session): calls['count'] += 1 return original_generate_review(session) monkeypatch.setattr(safety_center_service.ai_security_reviewer, 'generate_review', counted_generate_review) first = api_client.get('/api/safety/dashboard').json() second = api_client.get('/api/safety/dashboard').json() assert first['summary']['score'] == first['scoreBreakdown']['finalScore'] assert first['summary']['recommendationCount'] == len(first['recommendations']) assert len(first['sections']) == 9 assert isinstance(first['coverageSources'], list) assert first['cache']['hit'] is False assert first['cache']['source'] == 'fresh' assert first['cache']['ttlSeconds'] >= 1 assert first['cache']['generationDurationMs'] >= 0 assert second['cache']['hit'] is True assert second['cache']['source'] == 'cache' assert second['summary']['score'] == first['summary']['score'] assert calls['count'] == 1 def test_safety_dashboard_reuses_persistent_cache_after_process_cache_loss(api_client, monkeypatch): from backend.app.services import safety_center_service safety_center_service.invalidate_cache() first = api_client.get('/api/safety/dashboard').json() safety_center_service._dashboard_cache = None safety_center_service._skip_persistent_cache_once = False def fail_if_regenerated(session): raise AssertionError('persistent safety cache should avoid cold regeneration') monkeypatch.setattr(safety_center_service.ai_security_reviewer, 'generate_review', fail_if_regenerated) second = api_client.get('/api/safety/dashboard').json() assert second['cache']['hit'] is True assert second['cache']['source'] in {'cache', 'stale'} assert second['summary']['score'] == first['summary']['score'] def test_safety_dashboard_refreshes_expired_persistent_cache(api_client, monkeypatch): from datetime import datetime, timedelta, timezone from backend.app.services import safety_center_service safety_center_service.invalidate_cache() first = api_client.get('/api/safety/dashboard').json() persisted_payload = { key: first[key] for key in ('summary', 'sections', 'findings', 'recommendations', 'scoreBreakdown') } expired = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat().replace('+00:00', 'Z') safety_center_service._dashboard_cache = None safety_center_service._skip_persistent_cache_once = False monkeypatch.setattr( safety_center_service, '_persistent_cache', lambda session, signature: { 'key': safety_center_service.CACHE_KEY, 'signature': signature, 'payload': persisted_payload, 'generatedAt': first['summary']['generatedAt'], 'expiresAt': expired, 'generationDurationMs': 12, }, ) calls = {'scheduled': 0} monkeypatch.setattr( safety_center_service, '_schedule_background_refresh', lambda: calls.__setitem__('scheduled', calls['scheduled'] + 1) or True, ) second = api_client.get('/api/safety/dashboard').json() assert calls['scheduled'] == 1 assert second['cache']['source'] == 'stale-while-revalidate' assert second['cache']['hit'] is True def test_safety_refresh_invalidates_dashboard_cache(api_client): from backend.app.services import safety_center_service safety_center_service.invalidate_cache() first = api_client.get('/api/safety/dashboard').json() second = api_client.get('/api/safety/dashboard').json() refreshed = api_client.post('/api/safety/refresh').json() after_refresh = api_client.get('/api/safety/dashboard').json() assert first['cache']['source'] == 'fresh' assert second['cache']['hit'] is True assert refreshed['cache']['hit'] is False assert refreshed['cache']['source'] == 'fresh' assert after_refresh['cache']['hit'] is True assert refreshed['summary']['score'] == after_refresh['summary']['score'] def test_setup_connector_save_invalidates_safety_cache(api_client, monkeypatch): from backend.app.services import safety_center_service original_invalidate = safety_center_service.invalidate_cache calls = {'count': 0} def counted_invalidate(): calls['count'] += 1 original_invalidate() monkeypatch.setattr(safety_center_service, 'invalidate_cache', counted_invalidate) payload = api_client.patch( '/api/settings/connectors/unraid', json={'url': 'http://198.18.10.150', 'apiKey': 'test-unraid-key'}, ).json() assert payload['connectorId'] == 'unraid' assert calls['count'] >= 1 def test_app_mapping_invalidates_safety_cache(api_client, monkeypatch): from backend.app.db.models import App from backend.app.db.session import engine from backend.app.services import safety_center_service from sqlmodel import Session with Session(engine) as session: session.add(App(id='mapping-test', name='Mapping Test', category='Test')) session.commit() original_invalidate = safety_center_service.invalidate_cache calls = {'count': 0} def counted_invalidate(): calls['count'] += 1 original_invalidate() monkeypatch.setattr(safety_center_service, 'invalidate_cache', counted_invalidate) payload = api_client.patch( '/api/apps/mapping-test/container-mapping', json={'containerId': 'container-123', 'containerName': 'mapping-test'}, ).json() assert payload['success'] is True assert payload['message'] == 'mapping_updated' assert calls['count'] >= 1 def test_safety_legacy_endpoints_share_dashboard_snapshot(api_client): from backend.app.services import safety_center_service safety_center_service.invalidate_cache() dashboard = api_client.get('/api/safety/dashboard').json() summary = api_client.get('/api/safety/summary').json() sections = api_client.get('/api/safety/sections').json() findings = api_client.get('/api/safety/findings').json() recommendations = api_client.get('/api/safety/recommendations').json() breakdown = api_client.get('/api/safety/score-breakdown').json() assert summary == dashboard['summary'] assert sections == dashboard['sections'] assert findings == dashboard['findings'] assert recommendations == dashboard['recommendations'] assert breakdown == dashboard['scoreBreakdown'] def test_safety_summary_critical_when_destructive_policy_is_not_blocked(api_client): from backend.app.db.models import Policy from backend.app.db.session import engine from sqlmodel import Session with Session(engine) as session: policy = session.get(Policy, 'policy-volume-delete') policy.manual = 'allowed' session.add(policy) session.commit() payload = api_client.get('/api/safety/summary').json() findings = api_client.get('/api/safety/findings').json() assert payload['status'] == 'critical' assert payload['criticalCount'] >= 1 assert any(finding['id'] == 'ai-sec-policy-volume-delete-not-blocked' and finding['severity'] == 'critical' for finding in findings) def test_safety_summary_high_inventory_review_is_warning_not_global_critical(api_client): from backend.app.db.models import App from backend.app.db.session import engine from backend.app.services import safety_center_service from sqlmodel import Session safety_center_service.invalidate_cache() with Session(engine) as session: session.add(App( id='critical-review-app', name='Critical Review App', category='Infrastructure', criticality='critical', protected=False, needs_review=True, discovery_status='managed', connector_status='connected', container_status='running', )) session.commit() payload = api_client.get('/api/safety/summary').json() findings = api_client.get('/api/safety/findings').json() assert payload['criticalCount'] == 0 assert payload['highCount'] >= 1 assert payload['status'] != 'critical' assert any(finding['category'] == 'inventory' and finding['severity'] == 'high' for finding in findings) def test_safety_status_high_auth_exposure_without_critical_finding_is_warning(): from backend.app.services import safety_center_service from collections import Counter findings = [ { 'id': 'ai-sec-auth-public-host-without-authentik', 'severity': 'high', 'category': 'auth', 'status': 'open', }, { 'id': 'ai-sec-npm-host-without-ssl-force', 'severity': 'medium', 'category': 'exposure', 'status': 'open', }, ] assert safety_center_service._status(46, Counter({'high': 1, 'medium': 1}), [], findings) == 'warning' def test_safety_single_app_review_recommendation_links_to_app_detail(api_client): from backend.app.db.models import App from backend.app.db.session import engine from backend.app.services import safety_center_service from sqlmodel import Session, select safety_center_service.invalidate_cache() with Session(engine) as session: for app in session.exec(select(App)).all(): app.needs_review = False session.add(app) app = session.get(App, 'radarr') if not app: app = App(id='radarr', name='Radarr', category='Media') app.needs_review = True app.criticality = 'medium' session.add(app) session.commit() recommendations = api_client.get('/api/safety/recommendations').json() review = next(item for item in recommendations if item['id'] == 'safety-rec-safety-apps-need-review') assert review['href'] == '/apps/radarr' def test_safety_recommendations_are_non_executing_and_remediation_link_creates_no_jobs(api_client): recommendations = api_client.get('/api/safety/recommendations').json() assert recommendations assert all(recommendation['executable'] is False for recommendation in recommendations) before_jobs = api_client.get('/api/jobs').json() response = api_client.post(f"/api/safety/recommendations/{recommendations[0]['id']}/create-remediation-plan") after_jobs = api_client.get('/api/jobs').json() assert response.status_code == 200 payload = response.json() assert payload['success'] is True assert payload['executable'] is False assert payload['href'].startswith('/ai/remediation-planner') assert before_jobs == after_jobs def test_safety_finding_acknowledge_and_dismiss_are_safety_state_only(api_client): finding = api_client.get('/api/safety/findings').json()[0] acknowledged = api_client.post(f"/api/safety/findings/{finding['id']}/acknowledge").json() dismissed = api_client.post(f"/api/safety/findings/{finding['id']}/dismiss").json() assert acknowledged['success'] is True assert acknowledged['status'] == 'acknowledged' assert dismissed['success'] is True assert dismissed['status'] == 'dismissed' def test_incidents_are_derived_from_live_safety_sources(api_client): payload = api_client.get('/api/incidents').json() by_id = {item['id']: item for item in payload} assert 'incident-connectors-config' in by_id assert by_id['incident-connectors-config']['status'] == 'open' assert by_id['incident-connectors-config']['severity'] in {'medium', 'high'} assert by_id['incident-connectors-config']['evidence']['source'] == 'Connector Registry' assert 'connector' in by_id['incident-connectors-config']['title'].lower() assert 'i1' not in by_id assert all('503 Service Unavailable' not in str(item) for item in payload) def test_incident_status_lifecycle_is_persistent_and_affects_summary(api_client): incident = api_client.get('/api/incidents').json()[0] acknowledged = api_client.post(f"/api/incidents/{incident['id']}/status", json={'status': 'acknowledged'}).json() after_ack = {item['id']: item for item in api_client.get('/api/incidents').json()} summary_after_ack = api_client.get('/api/system/summary').json() assert acknowledged['success'] is True assert after_ack[incident['id']]['status'] == 'acknowledged' assert summary_after_ack['openIncidents'] == len([item for item in after_ack.values() if item['status'] == 'open']) resolved = api_client.post(f"/api/incidents/{incident['id']}/status", json={'status': 'resolved'}).json() after_resolve = {item['id']: item for item in api_client.get('/api/incidents').json()} assert resolved['success'] is True assert incident['id'] not in after_resolve def test_unknown_incident_status_update_fails_closed(api_client): payload = api_client.post('/api/incidents/not-a-real-incident/status', json={'status': 'resolved'}).json() assert payload['success'] is False assert payload['status'] == 'failed' assert payload['message'] == 'incident_not_found' def test_operations_map_uses_live_app_connector_and_policy_sources(api_client): payload = api_client.get('/api/operations-map').json() assert 'Connector Registry' in payload['nodes'] assert 'Policy Engine' in payload['nodes'] assert 'Secret Store' in payload['nodes'] assert 'Widefrog' in payload['nodes'] assert 'Plex' in payload['nodes'] assert 'Jellyfin' not in payload['nodes'] edges = {(edge['from'], edge['to']) for edge in payload['edges']} assert ('Connector Registry', 'Widefrog') in edges assert ('Connector Registry', 'Plex') in edges assert ('Policy Engine', 'Widefrog') in edges assert ('Secret Store', 'Connector Registry') in edges assert ('Widefrog', 'Home Assistant') not in edges assert payload['generatedAt'].endswith('Z') assert payload['sources'] == { 'apps': {'status': 'ok', 'count': len(api_client.get('/api/apps').json())}, 'connectors': {'status': 'ok', 'count': len(api_client.get('/api/connectors').json())}, 'policies': {'status': 'ok', 'count': len(api_client.get('/api/policies').json())}, 'secrets': {'status': 'metadata_only', 'count': 0}, } def test_system_summary_counts_live_open_incidents(api_client): incidents = api_client.get('/api/incidents').json() summary = api_client.get('/api/system/summary').json() open_incidents = [incident for incident in incidents if incident['status'] == 'open'] assert open_incidents assert summary['openIncidents'] == len(open_incidents) def test_container_logs_are_redacted_before_returning_to_ui(api_client, monkeypatch): secret = 'PORTAINER_LOG_SECRET' def fake_logs(self, container_name): return { 'status': 'ok', 'container': container_name, 'logs': [ f'Authorization: Bearer {secret}', f'api_key={secret}', 'normal startup line', ], } monkeypatch.setattr( 'backend.app.connectors.portainer.PortainerConnector.get_container_logs', fake_logs, raising=True, ) response = api_client.get('/api/containers/widefrog/logs') assert response.status_code == 200 payload = response.json() assert payload['container'] == 'widefrog' assert secret not in str(payload) assert '[REDACTED]' in str(payload) assert 'normal startup line' in payload['logs'] def test_portainer_read_routes_report_missing_config_instead_of_empty_data(api_client, monkeypatch): from backend.app.core.config import settings previous = dict(settings.apis) settings.apis['PORTAINER_URL'] = '' settings.apis.pop('PORTAINER_API_KEY', None) settings.apis.pop('PORTAINER_ENDPOINT_ID', None) containers = api_client.get('/api/containers') stacks = api_client.get('/api/stacks') assert containers.status_code == 503 assert containers.json()['detail']['status'] == 'missing_config' assert containers.json()['detail']['reason'] == 'missing_config' assert stacks.status_code == 503 assert stacks.json()['detail']['status'] == 'missing_config' assert stacks.json()['detail']['reason'] == 'missing_config' settings.apis.clear() settings.apis.update(previous) def test_portainer_container_mapping_adds_safe_ui_deep_link_without_secret(): from backend.app.connectors.portainer import PortainerConnector, _ConnectorConfig config = _ConnectorConfig('https://portainer.example.local/', 'SHOULD_NOT_LEAK', '7', True) payload = PortainerConnector._map_container( { 'Id': 'abc123', 'Names': ['/widefrog'], 'Image': 'widefrog/app:latest', 'State': 'running', 'Ports': [], }, config, ) assert payload['portainerUrl'] == 'https://portainer.example.local/#!/7/docker/containers/abc123' assert config.api_key not in str(payload) def test_portainer_container_mapping_omits_deep_link_for_invalid_base_url(): from backend.app.connectors.portainer import PortainerConnector, _ConnectorConfig config = _ConnectorConfig('javascript:alert(1)', 'SHOULD_NOT_LEAK', '7', True) payload = PortainerConnector._map_container( { 'Id': 'abc123', 'Names': ['/widefrog'], 'Image': 'widefrog/app:latest', 'State': 'running', 'Ports': [], }, config, ) assert payload['portainerUrl'] is None assert config.api_key not in str(payload) def test_ai_memory_create_redacts_secret_values(api_client): secret = 'AI_MEMORY_SECRET' response = api_client.post( '/api/ai/memory', json={ 'key': 'connector-token-note', 'value': f'api_key={secret}', 'category': 'known_issue', 'pinned': True, }, ) assert response.status_code == 200 payload = response.json() assert secret not in str(payload) assert '[REDACTED]' in str(payload) def test_ai_tools_return_governed_tool_catalog(api_client): response = api_client.get('/api/ai/tools') assert response.status_code == 200 payload = response.json() assert isinstance(payload, list) assert payload forbidden_tools = {'run_shell', 'delete_volume', 'show_secret', 'docker_prune'} returned_ids = {tool['id'] for tool in payload} returned_names = {tool['name'] for tool in payload} assert forbidden_tools.isdisjoint(returned_ids) assert forbidden_tools.isdisjoint(returned_names) for tool in payload: assert set(tool) >= { 'id', 'name', 'description', 'type', 'permissionLevel', 'requiresApproval', 'rateLimit', 'tool', 'enabled', 'allowedByDefault', 'scope', } assert tool['type'] in {'read', 'mutate'} assert tool['scope'] in {'read', 'mutate'} if tool['type'] == 'mutate': assert tool['requiresApproval'] is True assert tool['allowedByDefault'] is False def test_sonarr_connector_missing_config_reports_required_env(api_client): response = api_client.post('/api/connectors/sonarr/test') assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['status'] == 'missing_config' assert payload['missing_config'] is True assert payload['required_env_vars'] == ['SONARR_URL', 'SONARR_API_KEY'] assert payload['secretRef'] == 'SONARR_API_KEY' def test_sonarr_connector_error_redacts_api_key(api_client, monkeypatch): from backend.app.core.config import settings previous = dict(settings.apis) settings.apis['SONARR_URL'] = 'https://sonarr.local' settings.apis['SONARR_API_KEY'] = 'SONARR_SECRET_TOKEN' def fail_request(*_args, **_kwargs): raise RuntimeError('X-Api-Key=SONARR_SECRET_TOKEN') monkeypatch.setattr('backend.app.connectors.sonarr.SonarrConnector._request', fail_request, raising=True) response = api_client.post('/api/connectors/sonarr/test') payload = response.json() assert response.status_code == 200 assert payload['status'] == 'error' assert 'SONARR_SECRET_TOKEN' not in str(payload) assert '[REDACTED]' in str(payload) settings.apis.clear() settings.apis.update(previous) def test_sonarr_search_series_normalizes_lookup_response(monkeypatch): from backend.app.core.config import settings from backend.app.connectors.sonarr import SonarrConnector previous = dict(settings.apis) settings.apis['SONARR_URL'] = 'https://sonarr.local' settings.apis['SONARR_API_KEY'] = 'SONARR_SECRET_TOKEN' def fake_request(self, method, path, **kwargs): assert method == 'GET' assert path == '/api/v3/series/lookup' assert kwargs['params']['term'] == 'Foundation' return [ { 'id': 12, 'tvdbId': 366972, 'title': 'Foundation', 'year': 2021, 'overview': 'A sci-fi series.', 'status': 'continuing', 'remotePoster': 'https://image.example/poster.jpg', 'monitored': True, 'seasons': [{'seasonNumber': 1}], } ] monkeypatch.setattr(SonarrConnector, '_request', fake_request, raising=True) result = SonarrConnector().search_series('Foundation') assert result['success'] is True assert result['status'] == 'connected' assert result['items'][0] == { 'id': 12, 'tvdbId': 366972, 'title': 'Foundation', 'year': 2021, 'overview': 'A sci-fi series.', 'status': 'continuing', 'remotePoster': 'https://image.example/poster.jpg', 'existing': False, 'monitored': True, 'seasons': [{'seasonNumber': 1}], } settings.apis.clear() settings.apis.update(previous) def test_sonarr_queue_status_normalizes_counts(monkeypatch): from backend.app.core.config import settings from backend.app.connectors.sonarr import SonarrConnector previous = dict(settings.apis) settings.apis['SONARR_URL'] = 'https://sonarr.local' settings.apis['SONARR_API_KEY'] = 'SONARR_SECRET_TOKEN' def fake_request(self, method, path, **_kwargs): assert method == 'GET' assert path == '/api/v3/queue' return { 'records': [ {'id': 1, 'status': 'downloading', 'trackedDownloadStatus': 'ok', 'title': 'Episode 1'}, {'id': 2, 'status': 'queued', 'trackedDownloadStatus': 'warning', 'title': 'Episode 2'}, {'id': 3, 'status': 'completed', 'trackedDownloadStatus': 'error', 'title': 'Episode 3'}, {'id': 4, 'status': 'stalled', 'trackedDownloadStatus': 'warning', 'title': 'Episode 4'}, ], 'totalRecords': 4, } monkeypatch.setattr(SonarrConnector, '_request', fake_request, raising=True) result = SonarrConnector().queue_status() assert result['success'] is True assert result['total'] == 4 assert result['downloading'] == 1 assert result['queued'] == 1 assert result['failed'] == 1 assert result['stalled'] == 1 assert len(result['items']) == 4 settings.apis.clear() settings.apis.update(previous) def test_sonarr_add_series_policy_allowed_creates_job_and_audit(api_client, monkeypatch): def fake_add_series(self, payload): return { 'success': True, 'status': 'connected', 'message': 'series_added', 'data': {'id': 42, 'title': payload['title'], 'tvdbId': payload['tvdbId']}, } monkeypatch.setattr('backend.app.connectors.sonarr.SonarrConnector.add_series', fake_add_series, raising=True) response = api_client.post( '/api/apps/sonarr/series', json={'title': 'Foundation', 'tvdbId': 366972, 'rootFolderPath': '/tv', 'qualityProfileId': 1}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is True assert payload['decision'] == 'allowed' assert payload['status'] == 'success' assert payload['jobId'].startswith('job-') assert payload['auditId'].startswith('audit-') assert payload['data']['tvdbId'] == 366972 def test_sonarr_add_series_missing_config_fails_without_fake_success(api_client): response = api_client.post( '/api/apps/sonarr/series', json={'title': 'Foundation', 'tvdbId': 366972}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'failed' assert payload['status'] == 'failed' assert payload['message'] == 'missing_config' assert payload['jobId'].startswith('job-') assert payload['auditId'].startswith('audit-') def test_sonarr_destructive_actions_are_not_executable(api_client): delete_response = api_client.post('/api/apps/sonarr/series/delete', json={'id': 42}) mass_response = api_client.post('/api/apps/sonarr/mass-edit', json={'ids': [42]}) assert delete_response.status_code in {404, 405} assert mass_response.status_code in {404, 405} policy_delete = api_client.post( '/api/policies/evaluate', json={'action': 'sonarr.delete_series', 'target': 'sonarr', 'source': 'manual'}, ).json() policy_mass = api_client.post( '/api/policies/evaluate', json={'action': 'sonarr.mass_edit', 'target': 'sonarr', 'source': 'manual'}, ).json() assert policy_delete['decision'] == 'blocked' assert policy_mass['decision'] == 'blocked' def test_radarr_connector_missing_config_reports_required_env(api_client): response = api_client.post('/api/connectors/radarr/test') assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['status'] == 'missing_config' assert payload['missing_config'] is True assert payload['required_env_vars'] == ['RADARR_URL', 'RADARR_API_KEY'] assert payload['secretRef'] == 'RADARR_API_KEY' def test_radarr_connector_error_redacts_api_key(api_client, monkeypatch): from backend.app.core.config import settings previous = dict(settings.apis) settings.apis['RADARR_URL'] = 'https://radarr.local' settings.apis['RADARR_API_KEY'] = 'RADARR_SECRET_TOKEN' def fail_request(*_args, **_kwargs): raise RuntimeError('X-Api-Key=RADARR_SECRET_TOKEN') monkeypatch.setattr('backend.app.connectors.radarr.RadarrConnector._request', fail_request, raising=True) response = api_client.post('/api/connectors/radarr/test') payload = response.json() assert response.status_code == 200 assert payload['status'] in {'error', 'unreachable'} assert 'RADARR_SECRET_TOKEN' not in str(payload) assert '[REDACTED]' in str(payload) settings.apis.clear() settings.apis.update(previous) def test_radarr_connector_uses_radarr_tls_and_timeout_settings(monkeypatch): from backend.app.core.config import settings from backend.app.connectors.radarr import RadarrConnector previous = dict(settings.apis) settings.apis['RADARR_URL'] = 'https://radarr.local' settings.apis['RADARR_API_KEY'] = 'RADARR_SECRET_TOKEN' settings.apis['RADARR_VERIFY_TLS'] = True settings.apis['RADARR_TIMEOUT_SECONDS'] = '12.5' captured = {} class FakeResponse: status_code = 200 text = '{"version":"5.0.0"}' def json(self): return {'version': '5.0.0'} class FakeClient: def __init__(self, *, timeout, verify): captured['timeout'] = timeout captured['verify'] = verify def __enter__(self): return self def __exit__(self, *_args): return False def request(self, **kwargs): captured['headers'] = kwargs['headers'] return FakeResponse() monkeypatch.setattr('backend.app.connectors.radarr.httpx.Client', FakeClient, raising=True) result = RadarrConnector().test_connection() assert result['status'] == 'connected' assert captured['timeout'] == 12.5 assert captured['verify'] is True assert captured['headers']['X-Api-Key'] == 'RADARR_SECRET_TOKEN' settings.apis.clear() settings.apis.update(previous) def test_radarr_seed_status_requires_url_and_api_key(monkeypatch): from backend.app.core.config import settings from backend.app.db.init_db import _seed_connectors previous = dict(settings.apis) settings.apis['RADARR_URL'] = 'https://radarr.local' settings.apis['RADARR_API_KEY'] = None radarr = next(connector for connector in _seed_connectors() if connector.id == 'radarr') assert radarr.status == 'disconnected' settings.apis.clear() settings.apis.update(previous) def test_radarr_search_movie_normalizes_lookup_response(monkeypatch): from backend.app.core.config import settings from backend.app.connectors.radarr import RadarrConnector previous = dict(settings.apis) settings.apis['RADARR_URL'] = 'https://radarr.local' settings.apis['RADARR_API_KEY'] = 'RADARR_SECRET_TOKEN' def fake_request(self, method, path, **kwargs): assert method == 'GET' assert path == '/api/v3/movie/lookup' assert kwargs['params']['term'] == 'Arrival' return [ { 'id': 18, 'tmdbId': 329865, 'imdbId': 'tt2543164', 'title': 'Arrival', 'year': 2016, 'overview': 'A linguist works with an alien language.', 'status': 'released', 'remotePoster': 'https://image.example/arrival.jpg', 'monitored': True, 'minimumAvailability': 'released', } ] monkeypatch.setattr(RadarrConnector, '_request', fake_request, raising=True) result = RadarrConnector().search_movie('Arrival') assert result['success'] is True assert result['status'] == 'connected' assert result['items'][0] == { 'id': 18, 'tmdbId': 329865, 'imdbId': 'tt2543164', 'title': 'Arrival', 'year': 2016, 'overview': 'A linguist works with an alien language.', 'status': 'released', 'remotePoster': 'https://image.example/arrival.jpg', 'existing': False, 'monitored': True, 'minimumAvailability': 'released', } settings.apis.clear() settings.apis.update(previous) def test_radarr_queue_status_normalizes_counts(monkeypatch): from backend.app.core.config import settings from backend.app.connectors.radarr import RadarrConnector previous = dict(settings.apis) settings.apis['RADARR_URL'] = 'https://radarr.local' settings.apis['RADARR_API_KEY'] = 'RADARR_SECRET_TOKEN' def fake_request(self, method, path, **_kwargs): assert method == 'GET' assert path == '/api/v3/queue' return { 'records': [ {'id': 1, 'status': 'downloading', 'trackedDownloadStatus': 'ok', 'title': 'Movie 1'}, {'id': 2, 'status': 'queued', 'trackedDownloadStatus': 'warning', 'title': 'Movie 2'}, {'id': 3, 'status': 'completed', 'trackedDownloadStatus': 'error', 'title': 'Movie 3'}, {'id': 4, 'status': 'stalled', 'trackedDownloadStatus': 'warning', 'title': 'Movie 4'}, ], 'totalRecords': 4, } monkeypatch.setattr(RadarrConnector, '_request', fake_request, raising=True) result = RadarrConnector().queue_status() assert result['success'] is True assert result['status'] == 'ok' assert result['total'] == 4 assert result['downloading'] == 1 assert result['queued'] == 1 assert result['failed'] == 1 assert result['stalled'] == 1 assert len(result['items']) == 4 settings.apis.clear() settings.apis.update(previous) def test_radarr_queue_unavailable_on_connector_error(monkeypatch): from backend.app.core.config import settings from backend.app.connectors.radarr import RadarrConnector previous = dict(settings.apis) settings.apis['RADARR_URL'] = 'https://radarr.local' settings.apis['RADARR_API_KEY'] = 'RADARR_SECRET_TOKEN' def fake_request(self, method, path, **_kwargs): raise RuntimeError('connection refused') monkeypatch.setattr(RadarrConnector, '_request', fake_request, raising=True) result = RadarrConnector().queue_status() assert result['success'] is False assert result['status'] == 'unavailable' assert result['total'] == 0 settings.apis.clear() settings.apis.update(previous) def test_radarr_wanted_missing_normalizes_records(monkeypatch): from backend.app.core.config import settings from backend.app.connectors.radarr import RadarrConnector previous = dict(settings.apis) settings.apis['RADARR_URL'] = 'https://radarr.local' settings.apis['RADARR_API_KEY'] = 'RADARR_SECRET_TOKEN' def fake_request(self, method, path, **kwargs): assert method == 'GET' assert path == '/api/v3/wanted/missing' assert kwargs['params'] == {'page': 1, 'pageSize': 20} return {'records': [{'title': 'Missing Movie', 'tmdbId': 123}], 'totalRecords': 1} monkeypatch.setattr(RadarrConnector, '_request', fake_request, raising=True) result = RadarrConnector().wanted_missing() assert result['success'] is True assert result['status'] == 'connected' assert result['total'] == 1 assert result['items'] == [{'title': 'Missing Movie', 'tmdbId': 123}] settings.apis.clear() settings.apis.update(previous) def test_radarr_add_movie_policy_allowed_creates_job_and_audit(api_client, monkeypatch): def fake_add_movie(self, payload): return { 'success': True, 'status': 'connected', 'message': 'movie_added', 'data': {'id': 99, 'title': payload['title'], 'tmdbId': payload['tmdbId']}, } monkeypatch.setattr('backend.app.connectors.radarr.RadarrConnector.add_movie', fake_add_movie, raising=True) response = api_client.post( '/api/apps/radarr/movie', json={'title': 'Arrival', 'tmdbId': 329865, 'rootFolderPath': '/movies', 'qualityProfileId': 1}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is True assert payload['decision'] == 'allowed' assert payload['status'] == 'success' assert payload['jobId'].startswith('job-') assert payload['auditId'].startswith('audit-') assert payload['data']['tmdbId'] == 329865 def test_radarr_add_movie_missing_config_fails_without_fake_success(api_client): from backend.app.core.config import settings previous = dict(settings.apis) settings.apis['RADARR_URL'] = None settings.apis['RADARR_API_KEY'] = None response = api_client.post('/api/apps/radarr/movie', json={'title': 'Arrival', 'tmdbId': 329865}) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'failed' assert payload['status'] == 'failed' assert payload['message'] == 'missing_config' assert payload['jobId'].startswith('job-') assert payload['auditId'].startswith('audit-') settings.apis.clear() settings.apis.update(previous) def test_radarr_voice_add_movie_requires_policy_without_connector_call(api_client, monkeypatch): def fail_add_movie(*_args, **_kwargs): raise AssertionError('connector should not be called before approval') monkeypatch.setattr('backend.app.connectors.radarr.RadarrConnector.add_movie', fail_add_movie, raising=True) response = api_client.post( '/api/apps/radarr/movie', json={'title': 'Arrival', 'tmdbId': 329865, 'source': 'voice'}, ) assert response.status_code == 200 payload = response.json() assert payload['success'] is False assert payload['decision'] == 'approval_required' assert payload['status'] == 'failed' assert payload['approvalId'].startswith('appr-') assert payload['auditId'].startswith('audit-') def test_radarr_destructive_actions_are_not_executable(api_client): delete_response = api_client.post('/api/apps/radarr/movie/delete', json={'id': 42}) delete_files_response = api_client.post('/api/apps/radarr/movie/delete-files', json={'id': 42}) mass_response = api_client.post('/api/apps/radarr/mass-edit', json={'ids': [42]}) assert delete_response.status_code in {404, 405} assert delete_files_response.status_code in {404, 405} assert mass_response.status_code in {404, 405} policy_delete = api_client.post( '/api/policies/evaluate', json={'action': 'radarr.delete_movie', 'target': 'radarr', 'source': 'manual'}, ).json() policy_delete_files = api_client.post( '/api/policies/evaluate', json={'action': 'radarr.delete_files', 'target': 'radarr', 'source': 'manual'}, ).json() policy_mass = api_client.post( '/api/policies/evaluate', json={'action': 'radarr.mass_edit', 'target': 'radarr', 'source': 'manual'}, ).json() assert policy_delete['decision'] == 'blocked' assert policy_delete_files['decision'] == 'blocked' assert policy_mass['decision'] == 'blocked' def test_execute_blocked_action_is_never_success(api_client): response = api_client.post( '/api/actions/execute', json={'intent': 'volume.delete', 'target': 'postgres-data', 'source': 'manual', 'risk': 'critical', 'plan': []}, ) payload = response.json() assert response.status_code == 200 assert payload['success'] is False assert payload['decision'] == 'blocked' assert payload['status'] == 'failed' assert payload.get('result') == 'blocked' assert 'jobId' not in payload def test_approval_required_does_not_execute_job(api_client): jobs_before = api_client.get('/api/jobs').json() response = api_client.post( '/api/actions/execute', json={'intent': 'container.restart', 'target': 'authentik', 'source': 'manual', 'risk': 'high'}, ) payload = response.json() assert response.status_code == 200 assert payload['success'] is False assert payload['decision'] == 'approval_required' assert payload['status'] == 'failed' assert payload['result'] == 'approval_required' assert payload['approvalId'].startswith('appr-') jobs_after = api_client.get('/api/jobs').json() assert jobs_after == jobs_before def test_allowed_action_without_live_executor_is_not_fake_success(api_client): jobs_before = api_client.get('/api/jobs').json() response = api_client.post( '/api/actions/execute', json={ 'intent': 'unraid.health_summary', 'target': 'unraid', 'source': 'manual', 'risk': 'low', 'plan': ['Read array status'], }, ) payload = response.json() assert response.status_code == 200 assert payload['success'] is False assert payload['decision'] == 'allowed' assert payload['status'] == 'failed' assert payload['result'] == 'unsupported' assert 'No live executor' in payload['message'] assert 'jobId' not in payload jobs_after = api_client.get('/api/jobs').json() assert jobs_after == jobs_before def test_policy_update_is_persisted_and_audited(api_client): response = api_client.patch( '/api/policies/policy-widefrog-health', json={ 'voice': 'approval_required', 'manual': 'allowed', 'risk': 'medium', 'reason': 'Operator requires voice approval during maintenance.', }, ) assert response.status_code == 200 response_payload = response.json() assert response_payload.get('voice') == 'approval_required', response_payload evaluation = api_client.post( '/api/policies/evaluate', json={'action': 'widefrog.health', 'target': 'widefrog', 'source': 'voice'}, ).json() assert evaluation['decision'] == 'approval_required' audit = api_client.get('/api/audit').json() assert any(event['action_id'] == 'policy.update' and event['target'] == 'policy-widefrog-health' for event in audit) def test_policy_update_cannot_loosen_destructive_guardrail(api_client): response = api_client.patch( '/api/policies/policy-volume-delete', json={ 'voice': 'blocked', 'manual': 'allowed', 'risk': 'critical', 'reason': 'Attempted override.', }, ) assert response.status_code == 409 evaluation = api_client.post( '/api/policies/evaluate', json={'action': 'volume.delete', 'target': 'volume', 'source': 'manual'}, ).json() assert evaluation['decision'] == 'blocked'