2923 lines
106 KiB
Python
2923 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'
|
|
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'
|