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

2929 lines
106 KiB
Python

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'
for action in ('runner.acceptance.preflight', 'runner.acceptance.quiesce'):
assert items[action]['target'] == 'tower/ci-acceptance-residue'
assert items[action]['executionMode'] == 'governance_only'
assert items[action]['executionSupported'] is False
assert items[action]['commandExecutionSupported'] is False
assert items[action]['executionSurface'] is None
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("/controller-config/projects.json")',
f'Path({json.dumps(str(registry_path))})',
),
(
'Path("/controller-hub-releases/controller-config/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, '<workmesh-first-deploy>', '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'