Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e

This commit is contained in:
Public Source Publisher committed 2026-10-01 19:39:31 +00:00
1 parent c61fbfd87f
commit 1a54836efc
101 files changed
+4973 -1298

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
"""Existing feature tests run as a signed-in admin; identity tests use real auth.
This is a test-only principal, never a runtime authentication bypass. The auth,
proxy, OIDC and machine-boundary suites exercise real middleware separately.
"""
import pytest
@pytest.fixture(autouse=True)
def authenticated_feature_tests(request, monkeypatch):
# Load route modules before patching. Otherwise their direct imports can
# retain this test principal after monkeypatch restores the service module.
from backend.app import main as app_main
app_main._MUTATION_WINDOWS.clear()
if request.path.name in {"test_user_auth.py", "test_oidc_login.py", "test_security_boundaries.py", "test_mcp_rag_n8n_integrations.py"}:
return
from backend.app.db.models import GatewayUser
from backend.app.services import user_access
principal = GatewayUser(id=f"feature-test:{request.node.nodeid}", provider="authentik", issuer="test", subject="feature-test-admin", role="admin")
monkeypatch.setattr(user_access, "current_user", lambda _request: principal)
+58 -6
View File
@@ -798,7 +798,12 @@ def test_restart_voice_endpoint_succeeds_with_valid_token(api_client, restart_se
assert payload['status'] == 'success'
def test_restart_voice_endpoint_rejects_missing_or_invalid_token(api_client, restart_service):
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
@@ -809,7 +814,12 @@ def test_restart_voice_endpoint_rejects_missing_or_invalid_token(api_client, res
assert wrong.status_code == 403
def test_voice_unraid_health_endpoint_rejects_missing_or_invalid_token(api_client, restart_service):
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
@@ -820,7 +830,12 @@ def test_voice_unraid_health_endpoint_rejects_missing_or_invalid_token(api_clien
assert wrong.status_code == 403
def test_voice_playbook_endpoint_rejects_missing_or_invalid_token(api_client, restart_service):
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
@@ -1347,7 +1362,26 @@ def test_action_execute_idempotency_replays_without_duplicate_execution(api_clie
assert conflict.status_code == 409
def test_ai_route_does_not_execute_actions(api_client):
@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'},
@@ -1362,9 +1396,10 @@ def test_ai_route_does_not_execute_actions(api_client):
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):
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()
@@ -1385,6 +1420,7 @@ def test_ai_route_does_not_create_jobs_or_approvals(api_client):
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):
@@ -1395,7 +1431,7 @@ def test_ai_history_does_not_return_fake_run_events(api_client):
assert payload == []
def test_ai_chat_response_is_advisory_without_action_buttons(api_client):
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'},
@@ -1408,6 +1444,22 @@ def test_ai_chat_response_is_advisory_without_action_buttons(api_client):
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):
+34 -3
View File
@@ -1,5 +1,9 @@
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
@@ -39,8 +43,10 @@ def test_canonical_compose_forwards_trusted_operator_proxy_configuration():
assert 'APPOPS_OPERATOR_PROXY_AUTH: "${APPOPS_OPERATOR_PROXY_AUTH:-false}"' in compose
assert 'APPOPS_OPERATOR_PROXY_TRUSTED_NETWORKS: "${APPOPS_OPERATOR_PROXY_TRUSTED_NETWORKS:-}"' in compose
assert 'APPOPS_OPERATOR_PROXY_USERS: "${APPOPS_OPERATOR_PROXY_USERS:-}"' in compose
assert 'APPOPS_OPERATOR_PROXY_GROUPS: "${APPOPS_OPERATOR_PROXY_GROUPS:-appops-operators}"' in compose
assert 'APPOPS_AUTH_ADMIN_SUBJECTS: "${APPOPS_AUTH_ADMIN_SUBJECTS:-}"' in compose
assert 'APPOPS_AUTHENTIK_CLIENT_SECRET_FILE: /data/authentik-oidc-secret' in compose
assert 'APPOPS_OPERATOR_PROXY_USERS:' not in compose
assert 'APPOPS_OPERATOR_PROXY_GROUPS:' not in compose
def test_canonical_compose_mounts_only_the_file_based_gitea_observer_secret():
@@ -73,7 +79,7 @@ def test_runtime_image_label_matches_the_app_version():
assert f'{build_argument}: "${{{build_argument}:-' in compose
assert 'io.itworx.app.version="1.8.0"' in dockerfile
assert 'io.itworx.app.operator-auth="http-only-session"' in dockerfile
assert 'io.itworx.app.user-auth="oidc-rbac"' in dockerfile
assert 'APP_VERSION = "1.8.0"' in version
@@ -83,3 +89,28 @@ def test_unraid_upgrade_can_restore_voice_token_without_logging_it():
assert "for env_key in \\\n APP_OPS_VOICE_TOKEN \\" in script
assert 'printf \'%s=%s\\n\' "$env_key" "$env_value" >> "$ENV_FILE"' in script
assert 'log "${env_value}"' not in script
def test_upgrade_preserves_file_based_voice_credential(tmp_path):
bash = shutil.which("bash")
git_bash = Path("C:/Program Files/Git/bin/bash.exe")
if git_bash.exists():
bash = str(git_bash)
if not bash:
pytest.skip("Bash is unavailable")
script = (ROOT / "scripts" / "upgrade_single_1216.sh").read_text(encoding="utf-8")
voice = script.split('VOICE_TOKEN_VALUE="$(awk', 1)[1].split("# Optional, explicit deployment-time overrides.", 1)[0]
voice = 'VOICE_TOKEN_VALUE="$(awk' + voice
credential = "existing-service-credential-must-survive-upgrade"
(tmp_path / "voice-token").write_text(credential)
(tmp_path / "runtime.env").write_text("APP_OPS_VOICE_TOKEN_FILE=/data/voice-token\n")
result = subprocess.run([bash, "-s"], input=f'''set -eu
APPDATA='{tmp_path.as_posix()}'
ENV_FILE="$APPDATA/runtime.env"
log() {{ echo unexpected_rotation; }}
{voice}
test "$VOICE_TOKEN_VALUE" = '{credential}'
! grep -q '^APP_OPS_VOICE_TOKEN=' "$ENV_FILE"
''', text=True, capture_output=True, check=False)
assert result.returncode == 0, result.stderr
assert "unexpected_rotation" not in result.stdout
+4 -1
View File
@@ -31,7 +31,10 @@ def test_upgrade_replaces_the_canonical_container_without_versioned_copies():
assert '/^VITE_APPOPS_ADMIN_TOKEN=/d' in script
assert '/^VITE_APPOPS_VOICE_TOKEN=/d' in script
assert '/^APPOPS_PORT=/d' in script
assert '/^APPOPS_FRONTEND_ORIGIN=/d' in script
assert '$1 == "APPOPS_FRONTEND_ORIGIN"' in script
assert '$1 == "APPOPS_AUTH_PUBLIC_URL"' in script
assert '-e APPOPS_FRONTEND_ORIGIN="$frontend_origin"' in script
assert '/^APPOPS_FRONTEND_ORIGIN=/d' not in script
assert 'attempt ${attempt}/3 failed; retrying' in script
assert 'if ! check_endpoint "$path"' in script
assert 'return 1' in script
+148
View File
@@ -0,0 +1,148 @@
"""Exposure preferences suppress ACL diagnostics without granting protection."""
from fastapi.testclient import TestClient
import pytest
from sqlmodel import Session, select
from backend.app.db.models import AppSetting, AuditEvent, GatewayUser
from backend.tests.test_nginx_proxy_manager_connector import _reset_db, _proxy_hosts, _certificates, _set_npm_config, _restore_config
def _stored_preference(engine, enabled):
with Session(engine) as db:
db.add(AppSetting(key="exposure:preferences", value_json={"nginxAccessListChecksEnabled": enabled}, is_secret=False))
db.commit()
def test_disabling_acl_checks_removes_only_acl_connector_warnings(tmp_path, monkeypatch):
_reset_db(tmp_path, monkeypatch)
from backend.app.db import session
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
_stored_preference(session.engine, False)
previous = _set_npm_config()
calls = []
def request(_connector, method, path, **_kwargs):
calls.append((method, path))
return {"/nginx/proxy-hosts": _proxy_hosts(), "/nginx/certificates": _certificates(), "/nginx/access-lists": []}[path]
monkeypatch.setattr(NginxProxyManagerConnector, "_request", request)
try:
connector = NginxProxyManagerConnector()
warnings = connector.security_warnings()["items"]
summary = connector.exposure_summary()
assert not any(item["type"] == "public_host_without_access_list" for item in warnings)
assert not any(item["type"] == "public_host_without_access_list" for item in summary["warnings"])
assert {"host_without_certificate", "ssl_not_forced", "unknown_proxy_target"} <= {item["type"] for item in warnings}
assert summary["hostsWithoutAccessList"] == 1 # Metadata remains truthful.
assert ("GET", "/nginx/access-lists") not in calls
assert all(method == "GET" for method, _path in calls)
finally:
_restore_config(previous)
def test_disabled_acl_checks_do_not_mark_public_hosts_protected(tmp_path, monkeypatch):
_reset_db(tmp_path, monkeypatch)
from backend.app.db import session
from backend.app.api import routes_observability
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
_stored_preference(session.engine, False)
monkeypatch.setattr(NginxProxyManagerConnector, "proxy_hosts", lambda _self: {"success": True, "items": [
{"id": 1, "domainNames": ["open.example.test"], "enabled": True, "certificateId": 1, "sslForced": True, "accessListId": None},
{"id": 2, "domainNames": ["sso.example.test"], "enabled": True, "certificateId": 1, "sslForced": True, "accessListId": None},
{"id": 3, "domainNames": ["acl.example.test"], "enabled": True, "certificateId": 1, "sslForced": True, "accessListId": 2},
]})
monkeypatch.setattr(NginxProxyManagerConnector, "certificates", lambda _self: {"success": True, "items": [{"id": 1, "isExpired": False, "isExpiringSoon": False}]})
monkeypatch.setattr(routes_observability, "_auth_coverage_by_host", lambda: {"2": {"coverageStatus": "protected"}})
rows = routes_observability._build_network_exposure()
assert rows[0]["protected"] is False
assert rows[0]["risk"] == "high"
assert all("access list" not in reason.lower() for reason in rows[0]["riskReasons"])
assert rows[1]["protected"] is True
assert rows[1]["protectionSource"] == "authentik"
assert rows[2]["protected"] is True
assert rows[2]["protectionSource"] == "npm_access_list"
def test_exposure_preferences_admin_write_is_strict_persistent_and_audited(tmp_path, monkeypatch):
_reset_db(tmp_path, monkeypatch)
from backend.app.db import session
from backend.app.main import app
with TestClient(app) as client:
assert client.get("/api/settings/exposure").json() == {"nginxAccessListChecksEnabled": True}
for invalid in ({"nginxAccessListChecksEnabled": "false"}, {"nginxAccessListChecksEnabled": 0}, {"nginxAccessListChecksEnabled": False, "other": 1}, {}):
assert client.patch("/api/settings/exposure", json=invalid).status_code == 422
response = client.patch("/api/settings/exposure", json={"nginxAccessListChecksEnabled": False})
assert response.status_code == 200
assert response.json() == {"nginxAccessListChecksEnabled": False}
assert client.get("/api/settings/exposure").json() == response.json()
with Session(session.engine) as db:
row = db.get(AppSetting, "exposure:preferences")
assert row and row.is_secret is False
assert row.value_json == {"nginxAccessListChecksEnabled": False}
events = db.exec(select(AuditEvent).where(AuditEvent.action_id == "settings.exposure.update")).all()
assert len(events) == 1
assert events[0].before_state == {"nginxAccessListChecksEnabled": True}
assert events[0].after_state == {"nginxAccessListChecksEnabled": False}
def test_non_admin_with_settings_permission_cannot_change_exposure_preferences(tmp_path, monkeypatch):
_reset_db(tmp_path, monkeypatch)
from backend.app.main import app
from backend.app.services import user_access
user = GatewayUser(id="custom-settings", provider="authentik", issuer="test", subject="custom", role="custom", permissions=["settings.manage"])
monkeypatch.setattr(user_access, "current_user", lambda _request: user)
with TestClient(app) as client:
assert client.get("/api/settings/exposure").status_code == 200
assert client.patch("/api/settings/exposure", json={"nginxAccessListChecksEnabled": False}).status_code == 403
def test_corrupt_exposure_preference_keeps_acl_checks_enabled(tmp_path, monkeypatch):
_reset_db(tmp_path, monkeypatch)
from backend.app.db import session
from backend.app.services.exposure_preferences import get_exposure_preferences
_stored_preference(session.engine, "false")
with Session(session.engine) as db:
assert get_exposure_preferences(db).nginxAccessListChecksEnabled is True
def test_safety_signature_tracks_effective_preference_without_timestamp_change(tmp_path, monkeypatch):
_reset_db(tmp_path, monkeypatch)
from backend.app.db import session
from backend.app.services import safety_center_service
_stored_preference(session.engine, True)
with Session(session.engine) as db:
before = safety_center_service._source_signature(db)
row = db.get(AppSetting, "exposure:preferences")
row.value_json = {"nginxAccessListChecksEnabled": False}
db.add(row)
db.commit()
assert safety_center_service._source_signature(db) != before
def test_safety_does_not_reuse_warnings_from_different_preferences_when_refresh_fails(tmp_path, monkeypatch):
_reset_db(tmp_path, monkeypatch)
from backend.app.db import session
from backend.app.services import safety_center_service
monkeypatch.setattr(safety_center_service, "_dashboard_cache", {
"signature": "previous-preferences", "expiresAt": "2099-01-01T00:00:00Z", "generatedAt": "2026-01-01T00:00:00Z",
"payload": {"warnings": [{"type": "public_host_without_access_list"}]}, "generationDurationMs": 0,
})
def unavailable(_session):
raise RuntimeError("snapshot unavailable")
monkeypatch.setattr(safety_center_service, "_payload", unavailable)
with Session(session.engine) as db, pytest.raises(RuntimeError, match="snapshot unavailable"):
safety_center_service.get_dashboard(db, force_refresh=True)
def test_disabled_acl_checks_drop_stale_reviewer_acl_findings_only(tmp_path, monkeypatch):
_reset_db(tmp_path, monkeypatch)
from backend.app.db import session
from backend.app.services import ai_security_reviewer
_stored_preference(session.engine, False)
data = {"npmExposure": {"success": True, "status": "ok", "warnings": [
{"type": "public_host_without_access_list", "title": "No ACL", "evidence": ["open.example.test"]},
{"type": "certificate_expired", "title": "Certificate expired", "evidence": ["open.example.test"]},
]}, "authCoverage": {"status": "missing_config"}}
with Session(session.engine) as db:
findings = ai_security_reviewer._exposure_findings(db, data)
assert [finding.id for finding in findings] == ["ai-sec-npm-certificate-expired"]
@@ -0,0 +1,39 @@
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def frontend(tmp_path, monkeypatch):
from backend.app import main
root = tmp_path / 'dist'
root.mkdir()
(root / 'index.html').write_text('<html>Public AppOps frontend</html>')
(root / 'icon.svg').write_text('public-icon')
(tmp_path / 'outside.txt').write_text('private-test-marker')
monkeypatch.setattr(main, 'static_root', root)
return TestClient(main.app), root
@pytest.mark.parametrize('path', ['/%2e%2e/outside.txt', '/%2e%2e%2foutside.txt', '/%2fetc/passwd'])
def test_public_frontend_rejects_encoded_path_escapes(frontend, path):
client, _ = frontend
response = client.get(path)
assert response.status_code == 404
assert 'private-test-marker' not in response.text
def test_public_frontend_serves_assets_and_spa_routes(frontend):
client, _ = frontend
assert client.get('/icon.svg').text == 'public-icon'
assert 'Public AppOps frontend' in client.get('/settings').text
assert client.get('/api/nonexistent').status_code in {401, 404}
def test_public_frontend_rejects_symlink_escape(frontend):
client, root = frontend
try:
(root / 'escaped.txt').symlink_to(root.parent / 'outside.txt')
except OSError:
pytest.skip('Symlinks unavailable on this host')
assert client.get('/escaped.txt').status_code == 404
+235
View File
@@ -0,0 +1,235 @@
from datetime import datetime, timezone
from functools import partial
import httpx
import pytest
from backend.app.connectors import gpu_metrics
from backend.app.connectors.gpu_metrics import (
GpuMetricsConnector,
normalize_gpu_snapshot,
)
NOW = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
def snapshot(**device_values):
return {
'gpus': {
'0': {
'index': '0',
'name': 'NVIDIA GeForce RTX 4080 SUPER',
'utilization': 27.5,
'memory_used': 1559.0,
'memory_total': 16376.0,
'temperature': 47.0,
'timestamp': '2026-10-01T11:59:59+00:00',
'uuid': 'private-device-uuid',
'serial': 'private-device-serial',
'compute_processes_count': 1,
**device_values,
},
},
'timestamp': 'async',
'processes': [{'pid': '42', 'name': 'private-process', 'command': 'private-command'}],
'system': {'hostname': 'private-host'},
}
def test_gpu_hot_snapshot_normalizes_allowlisted_device_metrics_only():
result = normalize_gpu_snapshot(snapshot(), now=NOW)
assert result == {
'status': 'ok',
'observedAt': '2026-10-01T11:59:59Z',
'devices': [{
'id': 'gpu-0',
'name': 'NVIDIA GeForce RTX 4080 SUPER',
'utilizationPercent': 27.5,
'memoryUsedMiB': 1559.0,
'memoryTotalMiB': 16376.0,
'temperatureC': 47.0,
}],
'reason': None,
'source': 'gpu-hot',
}
assert 'private' not in str(result)
def test_gpu_hot_numeric_zero_is_a_real_idle_reading():
result = normalize_gpu_snapshot(snapshot(utilization=0, memory_used=0), now=NOW)
assert result['status'] == 'ok'
assert result['devices'][0]['utilizationPercent'] == 0.0
assert result['devices'][0]['memoryUsedMiB'] == 0.0
@pytest.mark.parametrize('value', [None, True, 'n/a', float('nan'), float('inf'), -1, 101])
def test_missing_or_invalid_utilization_remains_null(value):
result = normalize_gpu_snapshot(snapshot(utilization=value), now=NOW)
assert result['status'] == 'partial'
assert result['devices'][0]['utilizationPercent'] is None
assert result['devices'][0]['memoryUsedMiB'] == 1559.0
@pytest.mark.parametrize('field,value,normalized', [
('memory_used', -1, 'memoryUsedMiB'),
('memory_used', float('inf'), 'memoryUsedMiB'),
('memory_total', 0, 'memoryTotalMiB'),
('temperature', 151, 'temperatureC'),
('temperature', float('nan'), 'temperatureC'),
])
def test_invalid_secondary_metrics_do_not_replace_valid_utilization(field, value, normalized):
result = normalize_gpu_snapshot(snapshot(**{field: value}), now=NOW)
assert result['status'] == 'partial'
assert result['devices'][0][normalized] is None
assert result['devices'][0]['utilizationPercent'] == 27.5
def test_used_memory_larger_than_capacity_is_not_clamped_to_capacity():
result = normalize_gpu_snapshot(snapshot(memory_used=20000), now=NOW)
assert result['status'] == 'partial'
assert result['devices'][0]['memoryUsedMiB'] is None
assert result['devices'][0]['memoryTotalMiB'] == 16376.0
@pytest.mark.parametrize('timestamp', [None, 'async', 'not-a-date', '2026-10-01T11:59:59'])
def test_absent_or_timezone_naive_source_time_is_not_invented(timestamp):
result = normalize_gpu_snapshot(snapshot(timestamp=timestamp), now=NOW)
assert result['status'] == 'partial'
assert result['observedAt'] is None
assert result['devices'][0]['utilizationPercent'] == 27.5
@pytest.mark.parametrize('timestamp', ['2026-10-01T11:59:00Z', '2026-10-01T12:01:00Z'])
def test_stale_or_future_device_values_are_not_presented_as_live(timestamp):
result = normalize_gpu_snapshot(snapshot(timestamp=timestamp), now=NOW)
assert result['status'] == 'unavailable'
assert result['devices'] == []
assert result['observedAt'] is None
def test_one_stale_gpu_does_not_hide_another_fresh_gpu():
payload = snapshot()
payload['gpus']['1'] = {
**payload['gpus']['0'],
'index': '1',
'timestamp': '2026-10-01T11:50:00Z',
'utilization': 99,
}
result = normalize_gpu_snapshot(payload, now=NOW)
assert result['status'] == 'partial'
assert [device['id'] for device in result['devices']] == ['gpu-0']
assert result['observedAt'] == '2026-10-01T11:59:59Z'
@pytest.mark.parametrize('payload', [{'gpus': {}, 'timestamp': 'async'}, {'gpus': []}, None, [], {'gpus': 'bad'}, {'gpus': {'bad': 'bad'}}])
def test_empty_or_malformed_source_never_reports_zero_gpu_usage(payload):
result = normalize_gpu_snapshot(payload, now=NOW)
assert result['status'] in {'unavailable', 'error'}
assert result['devices'] == []
assert result['observedAt'] is None
def install_transport(monkeypatch, handler):
monkeypatch.setattr(gpu_metrics.httpx, 'Client', partial(httpx.Client, transport=httpx.MockTransport(handler)))
def test_missing_config_performs_no_network_request(monkeypatch):
def unexpected_request(_request):
pytest.fail('missing config must not perform a request')
install_transport(monkeypatch, unexpected_request)
result = GpuMetricsConnector(snapshot_url='').get_metrics()
assert result['status'] == 'missing_config'
assert result['devices'] == []
@pytest.mark.parametrize('url', ['file:///private/path', 'http://user:secret@metrics/api/gpu-data', 'invalid-url'])
def test_invalid_or_embedded_credential_urls_fail_without_leaking_values(monkeypatch, url):
def unexpected_request(_request):
pytest.fail('invalid config must not perform a request')
install_transport(monkeypatch, unexpected_request)
result = GpuMetricsConnector(snapshot_url=url).get_metrics()
assert result['status'] == 'error'
assert result['devices'] == []
assert url not in str(result)
assert 'secret' not in str(result)
def test_collector_uses_read_only_get_and_caps_timeout(monkeypatch):
requests = []
def handler(request):
requests.append(request)
return httpx.Response(200, json=snapshot(timestamp=None))
install_transport(monkeypatch, handler)
result = GpuMetricsConnector(snapshot_url='http://metrics:1312/api/gpu-data', timeout_seconds=100).get_metrics()
assert result['status'] == 'partial'
assert result['devices'][0]['utilizationPercent'] == 27.5
assert len(requests) == 1
assert requests[0].method == 'GET'
assert str(requests[0].url) == 'http://metrics:1312/api/gpu-data'
assert all(value <= 3.0 for value in requests[0].extensions['timeout'].values())
@pytest.mark.parametrize('error', [httpx.ConnectError('private-host private-token'), httpx.ReadTimeout('private-host private-token')])
def test_network_failures_return_fixed_safe_reasons(monkeypatch, error):
def handler(_request):
raise error
install_transport(monkeypatch, handler)
result = GpuMetricsConnector(snapshot_url='http://metrics/api/gpu-data').get_metrics()
assert result['status'] == 'unavailable'
assert result['devices'] == []
assert 'private' not in str(result)
@pytest.mark.parametrize('status', [301, 302, 401, 403, 500])
def test_http_failures_do_not_follow_redirects_or_expose_response_bodies(monkeypatch, status):
requests = []
def handler(request):
requests.append(request)
return httpx.Response(status, text='private-token', headers={'Location': 'http://private-host'})
install_transport(monkeypatch, handler)
result = GpuMetricsConnector(snapshot_url='http://metrics/api/gpu-data').get_metrics()
assert result['status'] in {'error', 'unavailable'}
assert len(requests) == 1
assert result['devices'] == []
assert 'private' not in str(result)
def test_invalid_json_is_reported_without_source_content(monkeypatch):
install_transport(monkeypatch, lambda _request: httpx.Response(200, text='private-token invalid-json'))
result = GpuMetricsConnector(snapshot_url='http://metrics/api/gpu-data').get_metrics()
assert result['status'] == 'error'
assert result['devices'] == []
assert 'private' not in str(result)
def test_oversized_response_is_rejected(monkeypatch):
install_transport(monkeypatch, lambda _request: httpx.Response(200, content=b' ' * 1_048_577))
result = GpuMetricsConnector(snapshot_url='http://metrics/api/gpu-data').get_metrics()
assert result['status'] == 'error'
assert result['devices'] == []
@@ -91,7 +91,12 @@ def test_voice_commands_expose_full_command_contract(api_client):
assert all(command["policyDecision"] != "blocked" for command in commands)
def test_voice_export_endpoints_require_voice_token(api_client):
def test_voice_export_endpoints_require_voice_token_or_signed_in_user(api_client, monkeypatch):
from fastapi import HTTPException
from backend.app.services import user_access
def anonymous(_request):
raise HTTPException(401, "Sign in required")
monkeypatch.setattr(user_access, "current_user", anonymous)
missing = api_client.get("/api/voice/home-assistant/export")
wrong = api_client.get(
"/api/voice/home-assistant/export",
@@ -113,7 +118,7 @@ def test_operator_voice_catalog_is_read_only_and_does_not_require_browser_secret
assert 'test-voice-token' not in str(export.json())
assert dry_run.status_code == 200
assert dry_run.json()['executed'] is False
assert api_client.post('/api/voice/actions/setup/status').status_code == 401
assert api_client.post('/api/voice/actions/setup/status').status_code == 200
def test_operator_voice_readiness_uses_live_home_assistant_state(api_client, monkeypatch):
+63 -3
View File
@@ -12,9 +12,9 @@ def api_client(tmp_path, monkeypatch):
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
from backend.app.main import app
engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False})
session.engine = engine
@@ -27,8 +27,8 @@ def api_client(tmp_path, monkeypatch):
def test_statistics_summary_uses_live_audit_job_and_unraid_data(api_client, monkeypatch):
from backend.app.connectors.unraid import UnraidConnector
from backend.app.api import routes_observability
from backend.app.connectors.unraid import UnraidConnector
monkeypatch.setattr(
UnraidConnector,
@@ -69,13 +69,22 @@ def test_statistics_summary_uses_live_audit_job_and_unraid_data(api_client, monk
assert payload["ramHistory"][-1]["value"] == 64
assert payload["blockedActions"] >= 1
assert payload["voiceActionCount"] >= 1
from sqlmodel import Session, select
from backend.app.db import session as database
from backend.app.db.models import AuditEvent
with Session(database.engine) as db:
events = db.exec(select(AuditEvent)).all()
voice_events = [event for event in events if (event.redacted_metadata or {}).get("requestSource") == "voice"]
assert voice_events
assert all(event.identity == "authentik:feature-test-admin" for event in voice_events)
assert all(point["time"] != "00:00" for point in payload["cpuHistory"])
assert payload["generatedAt"] >= (now - timedelta(minutes=1)).isoformat()
def test_statistics_summary_keeps_partial_unraid_metrics_explicit(api_client, monkeypatch):
from backend.app.connectors.unraid import UnraidConnector
from backend.app.api import routes_observability
from backend.app.connectors.unraid import UnraidConnector
monkeypatch.setattr(
UnraidConnector,
@@ -101,6 +110,57 @@ def test_statistics_summary_keeps_partial_unraid_metrics_explicit(api_client, mo
assert payload["sourceWarnings"][0]["surface"] == "metrics"
def test_statistics_preserves_precision_and_measured_gpu_load(api_client, monkeypatch):
from types import SimpleNamespace
from backend.app.api import routes_observability as routes
from backend.app.connectors.unraid import UnraidConnector
monkeypatch.setattr(UnraidConnector, 'get_system_metrics', lambda self: {'status': 'ok', 'cpuUsage': 12.34, 'ramUsage': 45.67})
gpu = {'status': 'partial', 'source': 'gpu_hot', 'observedAt': None, 'reason': 'gpu_metrics_timestamp_unavailable', 'devices': [
{'id': '0', 'name': 'GPU A', 'utilizationPercent': 68.91, 'memoryUsedMiB': 1800, 'memoryTotalMiB': 16000, 'temperatureC': 51},
{'id': '1', 'name': 'GPU B', 'utilizationPercent': 12.35, 'memoryUsedMiB': None, 'memoryTotalMiB': None, 'temperatureC': None},
]}
monkeypatch.setattr(routes, 'GpuMetricsConnector', lambda **kwargs: SimpleNamespace(get_metrics=lambda: gpu), raising=False)
routes._STATISTICS_HISTORY.clear()
payload = api_client.get('/api/statistics/summary').json()
assert payload['cpuHistory'][-1]['value'] == 12.34
assert payload['ramHistory'][-1]['value'] == 45.67
assert payload['gpuHistory'][-1]['value'] == 68.91
assert payload['gpu']['devices'] == gpu['devices']
assert payload['gpu']['receivedAt'] == payload['generatedAt']
assert payload['gpu']['observedAt'] is None
assert payload['sources']['gpu'] == 'partial'
def test_statistics_records_unavailable_metrics_as_a_gap(api_client, monkeypatch):
from types import SimpleNamespace
from backend.app.api import routes_observability as routes
from backend.app.connectors.unraid import UnraidConnector
monkeypatch.setattr(UnraidConnector, 'get_system_metrics', lambda self: {'status': 'error', 'reason': 'unavailable', 'cpuUsage': None, 'ramUsage': None})
monkeypatch.setattr(routes, 'GpuMetricsConnector', lambda **kwargs: SimpleNamespace(get_metrics=lambda: {'status': 'unavailable', 'devices': []}), raising=False)
routes._STATISTICS_HISTORY[:] = [{'time': '2026-01-01T00:00:00Z', 'cpu': 34, 'ram': 56}]
payload = api_client.get('/api/statistics/summary').json()
assert payload['cpuHistory'][-1]['value'] is None
assert payload['ramHistory'][-1]['value'] is None
assert payload['gpuHistory'][-1]['value'] is None
def test_statistics_history_grows_with_interleaved_viewers():
from backend.app.api import routes_observability as routes
routes._STATISTICS_HISTORY.clear()
start = datetime(2026, 10, 1, 12, 0, tzinfo=UTC)
for index in range(25):
timestamp = (start + timedelta(seconds=index * 2.5)).isoformat()
samples = routes._record_statistics_sample(timestamp, 12.34, 45.67, 0.0)
assert len(samples) == 13
assert samples[-1]['time'] == (start + timedelta(minutes=1)).isoformat()
assert samples[-1]['gpu'] == 0.0
samples = routes._record_statistics_sample((start + timedelta(minutes=31)).isoformat(), None, None, None)
assert len(samples) == 2
assert samples[-1]['cpu'] is None
def test_storage_summary_exposes_cache_pool_inventory(api_client, monkeypatch):
from backend.app.connectors.portainer import PortainerConnector
from backend.app.connectors.unraid import UnraidConnector
+236
View File
@@ -0,0 +1,236 @@
import json
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
@pytest.fixture
def ai_db(tmp_path, monkeypatch):
from backend.app.db import session as database
engine = create_engine(f"sqlite:///{tmp_path / 'managed-ai.db'}", connect_args={"check_same_thread": False})
monkeypatch.setattr(database, "engine", engine)
SQLModel.metadata.create_all(engine)
with Session(engine) as db:
yield db
def _configure(db):
from backend.app.services import managed_ai
managed_ai.save_config(db, {"enabled": True, "gatewayUrl": "http://198.18.10.150:8000", "capability": "assistant.ops@1", "credential": "mf_" + "a" * 48})
def _response():
return {"content": "Controleer de beschikbare evidence [1].", "request_id": "request-1", "deployment_id": "deployment-1", "model_key": "gpt-oss-20b", "model_revision": "immutable-revision", "artifact_digest": "sha256:" + "b" * 64, "latency_ms": 100, "usage": {"input_tokens": 50, "output_tokens": 9}}
def test_invocation_uses_exact_capability_and_redacts_known_secrets(ai_db, monkeypatch):
from backend.app.services import managed_ai
from backend.app.services.secret_store import set_secret
_configure(ai_db)
set_secret(ai_db, "UNRELATED_API_KEY", "private-value-to-omit")
requests = []
def handle(request):
requests.append(request)
assert request.url.path == "/api/v1/capabilities/assistant.ops@1/invoke"
body = json.loads(request.content)
assert "private-value-to-omit" not in request.content.decode()
assert body["max_output_tokens"] <= 1024
assert "model" not in body
assert request.headers["Authorization"] == "Bearer mf_" + "a" * 48
return httpx.Response(200, json=_response())
monkeypatch.setattr(managed_ai, "_transport", httpx.MockTransport(handle))
result = managed_ai.analyze(ai_db, "Wat betekent private-value-to-omit?", [{"rank": 1, "excerpt": "token=private-value-to-omit"}])
assert len(requests) == 1
assert result["model"] == "gpt-oss-20b"
assert result["content"].startswith("Controleer")
assert managed_ai.get_config(ai_db)["credentialConfigured"] is True
assert "credential" not in managed_ai.get_config(ai_db)
def test_no_retry_or_fallback_on_gateway_error(ai_db, monkeypatch):
from backend.app.services import managed_ai
_configure(ai_db)
requests = []
def handle(request):
requests.append(request)
return httpx.Response(503, json={"code": "CAPACITY_CONSTRAINED", "message": "Bearer secret-error-content", "correlation_id": "corr-1"})
monkeypatch.setattr(managed_ai, "_transport", httpx.MockTransport(handle))
with pytest.raises(managed_ai.ManagedAiError) as error:
managed_ai.analyze(ai_db, "Vraag", [])
assert len(requests) == 1
assert error.value.code == "CAPACITY_CONSTRAINED"
assert "secret-error-content" not in str(error.value)
@pytest.mark.parametrize("body", [{}, {"content": "", "model_key": "x"}, {"content": "x", "deployment_id": "d"}])
def test_invalid_response_never_claims_inference_success(ai_db, monkeypatch, body):
from backend.app.services import managed_ai
_configure(ai_db)
monkeypatch.setattr(managed_ai, "_transport", httpx.MockTransport(lambda _: httpx.Response(200, json=body)))
with pytest.raises(managed_ai.ManagedAiError, match="INVALID_RESPONSE"):
managed_ai.analyze(ai_db, "Vraag", [])
def test_missing_config_is_explicit_and_does_not_call_transport(ai_db, monkeypatch):
from backend.app.services import managed_ai
monkeypatch.setattr(managed_ai, "_transport", httpx.MockTransport(lambda _: pytest.fail("network request without config")))
assert managed_ai.status(ai_db)["state"] == "not_configured"
with pytest.raises(managed_ai.ManagedAiError, match="NOT_CONFIGURED"):
managed_ai.analyze(ai_db, "Vraag", [])
@pytest.mark.parametrize("url", ["http://169.254.169.254", "https://user:pass@example.com", "https://example.com/api", "https://example.com?token=a", "http://127.0.0.1"])
def test_admin_config_rejects_unsafe_gateway_origins(ai_db, url):
from backend.app.services import managed_ai
with pytest.raises(ValueError):
managed_ai.save_config(ai_db, {"gatewayUrl": url})
def test_capability_check_does_not_imply_model_ready(ai_db, monkeypatch):
from backend.app.services import managed_ai
_configure(ai_db)
monkeypatch.setattr(managed_ai, "_transport", httpx.MockTransport(lambda _: httpx.Response(200, json={"capability": "assistant.ops@1", "project_key": "appops"})))
assert managed_ai.status(ai_db)["state"] == "unverified"
def test_read_only_can_chat_but_cannot_change_configuration(ai_db, monkeypatch):
from backend.app.db.models import GatewayUser
from backend.app.main import app
from backend.app.services import managed_ai, user_access
readonly = GatewayUser(id="readonly-ai", provider="authentik", issuer="test", subject="readonly-ai", role="read_only")
monkeypatch.setattr(user_access, "current_user", lambda _: readonly)
monkeypatch.setattr(managed_ai, "analyze", lambda *_: {"content": "Beschikbare context [1].", "model": "gpt-oss-20b", "revision": "r1", "deploymentId": "d1", "requestId": "q1", "artifactDigest": "sha256:" + "b" * 64})
from backend.app.services import knowledge_service
monkeypatch.setattr(knowledge_service, "query", lambda *_args, **_kwargs: {"citations": [{"rank": 1, "title": "Context", "uri": "appops://state", "excerpt": "safe context"}], "confidence": "medium"})
client = TestClient(app)
result = client.post("/api/ai/chat", json={"content": "Wat is de status?"})
assert result.status_code == 200
assert result.json()["metadata"]["provider"] == "modelforge"
assert client.patch("/api/settings/ai", json={"enabled": True}).status_code == 403
history = client.get("/api/ai/history").json()
assert len(history) == 2
other = GatewayUser(id="other", provider="authentik", issuer="test", subject="other", role="read_only")
monkeypatch.setattr(user_access, "current_user", lambda _: other)
assert client.get("/api/ai/history").json() == []
def test_chat_failures_return_named_unavailable_not_fixed_answer(ai_db, monkeypatch):
from backend.app.main import app
from backend.app.services import knowledge_service, managed_ai
monkeypatch.setattr(knowledge_service, "query", lambda *_args, **_kwargs: {"citations": [], "confidence": "none"})
monkeypatch.setattr(managed_ai, "analyze", lambda *_: (_ for _ in ()).throw(managed_ai.ManagedAiError("NOT_CONFIGURED")))
response = TestClient(app).post("/api/ai/chat", json={"content": "Is alles gezond?"})
assert response.status_code == 503
assert response.json()["detail"]["code"] == "NOT_CONFIGURED"
def test_log_analysis_uses_redacted_input_and_real_result(ai_db, monkeypatch):
from backend.app.main import app
from backend.app.services import managed_ai
_configure(ai_db)
bodies = []
def handle(request):
bodies.append(json.loads(request.content))
assert "unsafe-password" not in request.content.decode()
result = _response()
result["content"] = "Er staat een ERROR in de meegegeven log."
return httpx.Response(200, json=result)
monkeypatch.setattr(managed_ai, "_transport", httpx.MockTransport(handle))
response = TestClient(app).post("/api/ai/summarize/logs", json={"logs": "ERROR connection failed password=unsafe-password"})
assert response.status_code == 200
assert response.json()["summary"] == "Er staat een ERROR in de meegegeven log."
assert response.json()["readOnly"] is True
assert len(bodies) == 1
def test_memory_crud_persists_and_redacts(ai_db):
from backend.app.main import app
client = TestClient(app)
response = client.post("/api/ai/memory", json={"key": "server-note", "value": "password=unsafe-password", "pinned": True})
assert response.status_code == 200
memory = response.json()["memory"]
assert memory["value"] == "password=[REDACTED]"
assert client.get("/api/ai/memory").json()[0]["id"] == memory["id"]
assert client.patch(f"/api/ai/memory/{memory['id']}", json={"key": "server-note", "value": "New note"}).status_code == 200
assert client.get("/api/ai/memory").json()[0]["value"] == "New note"
assert client.delete(f"/api/ai/memory/{memory['id']}").status_code == 200
assert client.get("/api/ai/memory").json() == []
def test_unknown_incident_does_not_claim_analysis(ai_db):
from backend.app.main import app
response = TestClient(app).post("/api/ai/analyze/incident/not-existing")
assert response.status_code == 404
def test_citations_evidence_and_history_do_not_expose_known_raw_secret(ai_db, monkeypatch):
from backend.app.main import app
from backend.app.services import knowledge_service, managed_ai
from backend.app.services.secret_store import set_secret
set_secret(ai_db, "UNRELATED_API_KEY", "raw-private-citation-value")
monkeypatch.setattr(knowledge_service, "query", lambda *_args, **_kwargs: {"citations": [{"rank": 1, "title": "raw-private-citation-value", "uri": "appops://context", "excerpt": "raw-private-citation-value"}], "confidence": "medium"})
monkeypatch.setattr(managed_ai, "analyze", lambda *_: {"content": "Beschikbare context [1].", "model": "gpt-oss-20b", "revision": "r1", "deploymentId": "d1", "requestId": "q1", "artifactDigest": "sha256:" + "b" * 64})
client = TestClient(app)
response = client.post("/api/ai/chat", json={"content": "Wat is de status?"})
assert response.status_code == 200
assert "raw-private-citation-value" not in response.text
assert "raw-private-citation-value" not in client.get("/api/ai/history").text
def test_validation_errors_do_not_echo_sensitive_inputs(ai_db):
from backend.app.main import app
value = "mf_" + "a" * 48 + " " * 14
response = TestClient(app).patch("/api/settings/ai", json={"credential": value})
assert response.status_code == 422
assert "mf_" not in response.text
response = TestClient(app).post("/api/ai/summarize/logs", json={"logs": "password=too-sensitive" * 1000})
assert response.status_code == 422
assert "too-sensitive" not in response.text
def test_json_escaping_cannot_bypass_known_secret_redaction(ai_db, monkeypatch):
from backend.app.services import managed_ai
from backend.app.services.secret_store import set_secret
_configure(ai_db)
private_value = 'complex"private\\value'
set_secret(ai_db, "UNRELATED_API_KEY", private_value)
def handle(request):
body = json.loads(request.content)
assert json.dumps(private_value, ensure_ascii=False)[1:-1] not in body['messages'][1]['content']
assert private_value not in body['messages'][1]['content']
return httpx.Response(200, json=_response())
monkeypatch.setattr(managed_ai, "_transport", httpx.MockTransport(handle))
managed_ai.analyze(ai_db, "Vraag", {'logs': 'Unlabeled: ' + private_value})
def test_advisory_activity_does_not_invalidate_security_sources(ai_db):
from backend.app.db.models import AppSetting
from backend.app.services import ai_security_reviewer, safety_center_service
before_review = ai_security_reviewer._source_signature(ai_db, 'all')
before_safety = safety_center_service._source_signature(ai_db)
ai_db.add(AppSetting(key='ai:chat:test-user', value_json={'messages': []}))
ai_db.add(AppSetting(key='ai:modelforge:last_inference', value_json={'model': 'gpt-oss-20b'}))
ai_db.commit()
assert ai_security_reviewer._source_signature(ai_db, 'all') == before_review
assert safety_center_service._source_signature(ai_db) == before_safety
ai_db.add(AppSetting(key='ai:modelforge', value_json={'enabled': True}))
ai_db.commit()
assert ai_security_reviewer._source_signature(ai_db, 'all') != before_review
assert safety_center_service._source_signature(ai_db) != before_safety
@pytest.mark.parametrize("text", ["evidence " * 2000, "😀漢字" * 3000], ids=["ascii", "unicode"])
def test_prompt_fits_qualified_runtime_in_utf8_bytes(ai_db, monkeypatch, text):
from backend.app.services import managed_ai
_configure(ai_db)
def handle(request):
payload = json.loads(request.content)
input_bytes = sum(len(message["content"].encode("utf-8")) for message in payload["messages"])
assert input_bytes + payload["max_output_tokens"] + 512 <= 8192
assert "afgekapt" in payload["messages"][1]["content"]
return httpx.Response(200, json=_response())
monkeypatch.setattr(managed_ai, "_transport", httpx.MockTransport(handle))
result = managed_ai.analyze(ai_db, "Wat betekent dit? " + "😀" * 2048, {"logs": text})
assert result["contextTruncated"] is True
+24 -17
View File
@@ -37,8 +37,6 @@ def integration_client(tmp_path, monkeypatch):
"mcp_token": settings.mcp_token,
"n8n_enabled": settings.n8n_enabled,
"n8n_token": settings.n8n_token,
"operator_token": settings.operator_token,
"operator_auth_required": settings.operator_auth_required,
"environment": settings.environment,
"mutation_rate_limit_per_minute": settings.mutation_rate_limit_per_minute,
}
@@ -50,8 +48,6 @@ def integration_client(tmp_path, monkeypatch):
settings.mcp_token = MCP_TOKEN
settings.n8n_enabled = True
settings.n8n_token = N8N_TOKEN
settings.operator_token = OPERATOR_TOKEN
settings.operator_auth_required = True
settings.environment = "production"
settings.mutation_rate_limit_per_minute = 10
security.settings = settings
@@ -61,6 +57,13 @@ def integration_client(tmp_path, monkeypatch):
init_db()
with TestClient(app) as client:
settings.mcp_enabled = True
from backend.app.db.models import GatewayUser
from backend.app.services.user_access import issue_session
with Session(engine) as session:
user = GatewayUser(id="integration-admin", provider="authentik", issuer="test", subject="integration-admin", role="admin")
session.add(user)
session.commit()
client.appops_admin_cookie = issue_session(session, user)
yield client, engine
db_session.engine = previous_engine
@@ -70,6 +73,10 @@ def integration_client(tmp_path, monkeypatch):
app_main._MUTATION_WINDOWS.clear()
def human_headers(client):
return {"Cookie": f"appops_user_session={client.appops_admin_cookie}", "X-AppOps-Session-CSRF": "1"}
def test_integrations_use_separate_auth_scopes(integration_client):
client, _ = integration_client
@@ -77,7 +84,7 @@ def test_integrations_use_separate_auth_scopes(integration_client):
assert (
client.get(
"/api/integrations/status",
headers={"Authorization": f"Bearer {OPERATOR_TOKEN}"},
headers=human_headers(client),
).status_code
== 200
)
@@ -105,7 +112,7 @@ def test_deployment_http_surface_requires_operator_and_rejects_raw_fields(integr
response = client.post(
"/api/deployments",
headers={
"Authorization": f"Bearer {OPERATOR_TOKEN}",
**human_headers(client),
"Idempotency-Key": "deployment-schema-test-0001",
},
json={
@@ -213,7 +220,7 @@ def test_n8n_uses_the_shared_grounded_knowledge_service(integration_client):
evaluation = client.get(
"/api/ai/rag/evaluate",
headers={"X-AppOps-Operator-Token": OPERATOR_TOKEN},
headers=human_headers(client),
)
assert evaluation.status_code == 200
assert evaluation.json()["total"] == 5
@@ -709,7 +716,7 @@ def test_n8n_event_and_audit_fields_are_redacted(integration_client):
def test_n8n_heartbeat_makes_reachability_truthful(integration_client):
client, _ = integration_client
n8n_headers = {"Authorization": f"Bearer {N8N_TOKEN}"}
operator_headers = {"Authorization": f"Bearer {OPERATOR_TOKEN}"}
operator_headers = human_headers(client)
before = client.get("/api/integrations/status", headers=operator_headers).json()
assert before["n8n"]["state"] == "configured"
@@ -768,8 +775,8 @@ def test_audit_endpoint_is_paginated(integration_client):
)
session.commit()
first = client.get("/api/audit?limit=2&offset=0").json()
second = client.get("/api/audit?limit=2&offset=2").json()
first = client.get("/api/audit?limit=2&offset=0", headers=human_headers(client)).json()
second = client.get("/api/audit?limit=2&offset=2", headers=human_headers(client)).json()
assert len(first) == 2
assert len(second) == 2
assert {item["id"] for item in first}.isdisjoint({item["id"] for item in second})
@@ -865,12 +872,12 @@ def test_workmesh_promotion_preflight_creates_sha_bound_approval_and_receipt_job
monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_preflight", preflight)
preflight_headers = {
"Authorization": f"Bearer {OPERATOR_TOKEN}",
**human_headers(client),
"Idempotency-Key": "workmesh-promote-preflight-0001",
}
direct_action = client.post(
"/api/actions/execute",
headers={"Authorization": f"Bearer {OPERATOR_TOKEN}"},
headers=human_headers(client),
json={
"plan": {
"intent": "deployment.target.promote_workmesh",
@@ -914,7 +921,7 @@ def test_workmesh_promotion_preflight_creates_sha_bound_approval_and_receipt_job
listed = client.get(
"/api/deployments/workmesh/promotions",
headers={"Authorization": f"Bearer {OPERATOR_TOKEN}"},
headers=human_headers(client),
)
assert listed.status_code == 200
assert listed.json()["items"][0]["promotionId"] == requested["promotionId"]
@@ -979,7 +986,7 @@ def test_workmesh_promotion_preflight_creates_sha_bound_approval_and_receipt_job
monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_apply", apply)
monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_status", status)
auth = {"Authorization": f"Bearer {OPERATOR_TOKEN}"}
auth = human_headers(client)
assert client.patch(
f"/api/approvals/{requested['approvalId']}",
headers=auth,
@@ -1057,7 +1064,7 @@ def test_workmesh_ambiguous_controller_apply_is_consumed_and_only_reconciled(
monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_apply", apply)
monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_status", status)
auth = {"Authorization": f"Bearer {OPERATOR_TOKEN}"}
auth = human_headers(client)
requested = client.post(
"/api/deployments/workmesh/promotions/preflight",
headers={**auth, "Idempotency-Key": "workmesh-promote-uncertain-0001"},
@@ -1155,7 +1162,7 @@ def test_workmesh_changed_preflight_expires_approval_before_apply(
monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_preflight", preflight)
monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_apply", apply)
auth = {"Authorization": f"Bearer {OPERATOR_TOKEN}"}
auth = human_headers(client)
requested = client.post(
"/api/deployments/workmesh/promotions/preflight",
headers={**auth, "Idempotency-Key": "workmesh-promote-drift-0001"},
@@ -1239,7 +1246,7 @@ def test_controller_preflight_changed_identity_expires_approval_without_unknown_
monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_preflight", preflight)
monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_apply", apply)
auth = {"Authorization": f"Bearer {OPERATOR_TOKEN}"}
auth = human_headers(client)
requested = client.post(
"/api/deployments/workmesh/promotions/preflight",
headers={**auth, "Idempotency-Key": "workmesh-promote-controller-drift-01"},
+84 -1
View File
@@ -155,6 +155,7 @@ def test_media_pipeline_summary_reuses_persistent_cache_after_snapshot_loss(api_
def test_media_pipeline_snapshot_cache_prevents_concurrent_stampede(api_client, monkeypatch):
from concurrent.futures import ThreadPoolExecutor
from time import sleep
from backend.app.services import media_pipeline_service
payloads = _patch_pipeline(monkeypatch)
@@ -177,6 +178,7 @@ def test_media_pipeline_snapshot_cache_prevents_concurrent_stampede(api_client,
def test_media_pipeline_cold_snapshot_runs_connector_reads_in_parallel(api_client, monkeypatch):
from threading import Barrier
from backend.app.services import media_pipeline_service
payloads = _patch_pipeline(monkeypatch)
@@ -230,6 +232,86 @@ def test_media_pipeline_flow_returns_nodes_and_edges(api_client, monkeypatch):
assert not any("delete" in action or "remove" in action for action in action_ids)
def test_media_pipeline_flow_has_no_healthy_edges_when_configuration_is_missing(api_client, monkeypatch):
payloads = _healthy_payloads()
for connector in ("sonarr", "prowlarr", "deluge", "widefrog", "plex", "tautulli"):
payloads[f"{connector}_health"] = {"success": False, "status": "missing_config"}
_patch_pipeline(monkeypatch, payloads)
response = api_client.get("/api/media-pipeline/flow")
assert response.status_code == 200
payload = response.json()
assert len(payload["edges"]) == 4
assert all(edge["status"] == "unknown" for edge in payload["edges"])
assert all(node["status"] == "missing_config" for node in payload["nodes"])
@pytest.mark.parametrize("connector", ["sonarr", "prowlarr", "deluge", "widefrog", "plex", "tautulli"])
@pytest.mark.parametrize("health_status,expected", [
("missing_config", "unknown"),
("unknown", "unknown"),
("unavailable", "unknown"),
("unsupported", "unknown"),
("unexpected_state", "unknown"),
("auth_failed", "broken"),
("unreachable", "broken"),
("error", "broken"),
])
def test_media_pipeline_flow_checks_each_endpoint_before_queue_warnings(api_client, monkeypatch, connector, health_status, expected):
payloads = _healthy_payloads()
payloads[f"{connector}_health"] = {"success": False, "status": health_status}
# Nonzero counts must never turn unavailable or failed endpoints amber/green.
payloads["prowlarr_indexers"]["failing"] = 2
payloads["widefrog_failed"]["failed"] = 3
_patch_pipeline(monkeypatch, payloads)
response = api_client.get("/api/media-pipeline/flow")
assert response.status_code == 200
edges = response.json()["edges"]
unrelated_statuses = {
("sonarr", "prowlarr"): "healthy",
("prowlarr", "deluge"): "warning",
("widefrog", "plex"): "warning",
("plex", "tautulli"): "healthy",
}
for edge in edges:
if connector in (edge["from"], edge["to"]):
assert edge["status"] == expected
else:
assert edge["status"] == unrelated_statuses[(edge["from"], edge["to"])]
def test_media_pipeline_flow_unknown_endpoint_precedes_failure_on_the_other_endpoint(api_client, monkeypatch):
payloads = _healthy_payloads()
payloads["prowlarr_health"] = {"success": False, "status": "unreachable"}
payloads["deluge_health"] = {"success": False, "status": "missing_config"}
_patch_pipeline(monkeypatch, payloads)
payload = api_client.get("/api/media-pipeline/flow").json()
edge_statuses = {(edge["from"], edge["to"]): edge["status"] for edge in payload["edges"]}
assert edge_statuses[("sonarr", "prowlarr")] == "broken"
assert edge_statuses[("prowlarr", "deluge")] == "unknown"
def test_media_pipeline_flow_applies_queue_warnings_only_with_connected_endpoints(api_client, monkeypatch):
payloads = _healthy_payloads()
payloads["prowlarr_indexers"]["failing"] = 2
payloads["widefrog_failed"]["failed"] = 3
_patch_pipeline(monkeypatch, payloads)
payload = api_client.get("/api/media-pipeline/flow").json()
assert {(edge["from"], edge["to"]): edge["status"] for edge in payload["edges"]} == {
("sonarr", "prowlarr"): "healthy",
("prowlarr", "deluge"): "warning",
("widefrog", "plex"): "warning",
("plex", "tautulli"): "healthy",
}
def test_media_pipeline_issues_detect_cross_connector_problems(api_client, monkeypatch):
payloads = _healthy_payloads()
payloads["prowlarr_indexers"] = {"success": True, "status": "ok", "total": 3, "enabled": 2, "failing": 1, "items": [{"name": "Indexer", "status": "error"}]}
@@ -267,9 +349,10 @@ def test_deluge_stalled_issue_includes_source_and_timestamp(api_client, monkeypa
def test_media_pipeline_issues_handles_review_apps_without_connector_type(api_client, monkeypatch):
from sqlmodel import Session
from backend.app.db.models import App
from backend.app.db.session import engine
from sqlmodel import Session
_patch_pipeline(monkeypatch)
with Session(engine) as session:
+182
View File
@@ -0,0 +1,182 @@
import json
import logging
from time import time
from urllib.parse import parse_qs, urlsplit
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi import HTTPException
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
@pytest.fixture
def oidc(tmp_path, monkeypatch):
from backend.app.core.config import settings
from backend.app.db import session as database
from backend.app.main import app
from backend.app.services import oidc_login
engine = create_engine(f"sqlite:///{tmp_path / 'oidc.db'}", connect_args={"check_same_thread": False})
monkeypatch.setattr(database, "engine", engine)
SQLModel.metadata.create_all(engine)
monkeypatch.setattr(settings, "auth_public_url", "https://appops.example")
monkeypatch.setattr(settings, "frontend_origin", "https://appops.example")
monkeypatch.setattr(settings, "authentik_issuer", "https://auth.example/application/o/appops/")
monkeypatch.setattr(settings, "authentik_client_id", "appops-test")
monkeypatch.setattr(settings, "authentik_client_secret", "not-a-real-client-secret")
monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:owner"])
monkeypatch.setattr(settings, "auth_admin_emails", [])
monkeypatch.setattr(settings, "operator_proxy_auth_enabled", False)
monkeypatch.setattr(settings, "entra_tenant_id", "00000000-0000-0000-0000-000000000001")
monkeypatch.setattr(settings, "entra_client_id", "entra-test")
monkeypatch.setattr(settings, "entra_client_secret", "not-a-real-entra-secret")
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key()))
jwk.update({"kid": "test-key", "alg": "RS256", "use": "sig"})
state = {"nonce": "", "claim_overrides": {}, "token_calls": 0}
def transport(request):
if request.url.path.endswith("openid-configuration"):
return httpx.Response(200, json={
"issuer": settings.authentik_issuer, "authorization_endpoint": "https://auth.example/authorize",
"token_endpoint": "https://auth.example/token", "jwks_uri": "https://auth.example/keys",
})
if request.url.path == "/token":
state["token_calls"] += 1
form = parse_qs(request.content.decode())
assert form["grant_type"] == ["authorization_code"]
assert len(form["code_verifier"][0]) >= 43
assert form["redirect_uri"] == ["https://appops.example/api/auth/callback/authentik"]
claims = {"iss": settings.authentik_issuer, "sub": "owner", "aud": "appops-test", "iat": int(time()),
"exp": int(time()) + 300, "nonce": state["nonce"], "email": "jens@example.com", **state["claim_overrides"]}
token = jwt.encode(claims, key, algorithm="RS256", headers={"kid": "test-key"})
return httpx.Response(200, json={"id_token": token, "access_token": "discard-this-token"})
if request.url.path == "/keys":
return httpx.Response(200, json={"keys": [jwk]})
return httpx.Response(404)
original_client = httpx.Client
monkeypatch.setattr(oidc_login.httpx, "Client", lambda **kwargs: original_client(transport=httpx.MockTransport(transport), **kwargs))
client = TestClient(app, base_url="https://appops.example")
yield client, state, key, {"keys": [jwk]}, engine
engine.dispose()
def begin(client, state):
response = client.get("/api/auth/login/authentik", follow_redirects=False)
assert response.status_code == 302
params = parse_qs(urlsplit(response.headers["location"]).query)
state["nonce"] = params["nonce"][0]
assert params["code_challenge_method"] == ["S256"]
assert "HttpOnly" in response.headers["set-cookie"]
assert "Secure" in response.headers["set-cookie"]
return params["state"][0]
def test_oidc_real_code_flow_issues_secure_admin_session_and_rejects_replay(oidc):
client, state, _, _, _ = oidc
login_state = begin(client, state)
response = client.get(f"/api/auth/callback/authentik?state={login_state}&code=test-code", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/"
assert "HttpOnly" in response.headers["set-cookie"] and "Secure" in response.headers["set-cookie"]
assert "discard-this-token" not in response.text + response.headers["set-cookie"]
status = client.get("/api/auth/session").json()
assert status["authenticated"] is True and status["user"]["role"] == "admin"
replay = client.get(f"/api/auth/callback/authentik?state={login_state}&code=test-code", follow_redirects=False)
assert replay.status_code == 303 and replay.headers["location"] == "/?signin_error=expired"
assert state["token_calls"] == 1
assert client.post("/api/auth/logout", headers={"X-AppOps-Session-CSRF": "1", "Origin": "https://evil.example"}).status_code == 403
assert client.post("/api/auth/logout", headers={"X-AppOps-Session-CSRF": "1", "Origin": "https://appops.example"}).status_code == 200
assert client.get("/api/auth/session").json()["authenticated"] is False
def test_state_is_bound_to_original_browser_before_token_exchange(oidc):
client, state, _, _, _ = oidc
login_state = begin(client, state)
client.cookies.clear()
response = client.get(f"/api/auth/callback/authentik?state={login_state}&code=test-code", follow_redirects=False)
assert response.status_code == 303 and response.headers["location"] == "/?signin_error=expired"
assert state["token_calls"] == 0
assert client.get("/api/auth/session").json()["authenticated"] is False
def test_local_address_login_redirects_before_creating_browser_flow(oidc):
from sqlmodel import select
from backend.app.db.models import LoginFlow
client, _, _, _, engine = oidc
response = client.get("http://198.18.10.150:1216/api/auth/login/authentik", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "https://appops.example/api/auth/login/authentik"
assert "set-cookie" not in response.headers
with Session(engine) as db:
assert db.exec(select(LoginFlow)).all() == []
def test_provider_cancellation_returns_to_login_without_echoing_provider_input(oidc):
client, state, _, _, _ = oidc
response = client.get("/api/auth/callback/authentik?error=access_denied&error_description=sensitive-provider-input", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/?signin_error=cancelled"
assert "sensitive-provider-input" not in response.text + str(response.headers)
assert state["token_calls"] == 0
def test_entra_federation_keeps_authentik_callback_and_identity_validation(oidc, monkeypatch):
from backend.app.core.config import settings
client, state, _, _, _ = oidc
monkeypatch.setattr(settings, "entra_tenant_id", "")
monkeypatch.setattr(settings, "authentik_entra_source", "entra-id")
providers = client.get("/api/auth/providers").json()
assert providers[1] == {"id": "entra", "label": "Microsoft Entra via Authentik", "configured": True}
response = client.get("/api/auth/login/entra", follow_redirects=False)
params = parse_qs(urlsplit(response.headers["location"]).query)
assert params["redirect_uri"] == ["https://appops.example/api/auth/callback/authentik"]
assert params["code_challenge_method"] == ["S256"]
state["nonce"] = params["nonce"][0]
response = client.get(f"/api/auth/callback/authentik?state={params['state'][0]}&code=test-code", follow_redirects=False)
assert response.status_code == 303
assert client.get("/api/auth/session").json()["user"]["provider"] == "authentik"
@pytest.mark.parametrize("overrides", [{"nonce": "wrong"}, {"iss": "https://evil.example"}, {"aud": "other-app"}, {"exp": 1}, {"azp": "other-app"}])
def test_oidc_rejects_invalid_signed_identity_claims(oidc, overrides):
client, state, _, _, _ = oidc
login_state = begin(client, state)
state["claim_overrides"] = overrides
response = client.get(f"/api/auth/callback/authentik?state={login_state}&code=test-code", follow_redirects=False)
assert response.status_code == 303 and response.headers["location"] == "/?signin_error=failed"
assert client.get("/api/auth/session").json()["authenticated"] is False
def test_entra_tenant_is_pinned_and_uses_fixed_object_id(oidc, monkeypatch):
from backend.app.core.config import settings
from backend.app.services.oidc_login import provider_config, verify_id_token
from backend.app.services.user_access import provision_user
_, _, key, jwks, engine = oidc
config = provider_config("entra")
claims = {"iss": config["issuer"], "sub": "app-specific-sub", "oid": "fixed-object-id", "tid": settings.entra_tenant_id,
"aud": config["client_id"], "iat": int(time()), "exp": int(time()) + 300, "nonce": "expected", "preferred_username": "jens@example.com"}
encoded = jwt.encode(claims, key, algorithm="RS256", headers={"kid": "test-key"})
verified = verify_id_token(encoded, jwks, config, "expected", "entra")
monkeypatch.setattr(settings, "auth_admin_subjects", ["entra:fixed-object-id"])
with Session(engine) as db:
user = provision_user(db, "entra", config["issuer"], verified)
assert user.subject == "fixed-object-id" and user.role == "admin"
claims["tid"] = "other-tenant"
with pytest.raises(HTTPException):
verify_id_token(jwt.encode(claims, key, algorithm="RS256", headers={"kid": "test-key"}), jwks, config, "expected", "entra")
monkeypatch.setattr(settings, "entra_tenant_id", "common")
with pytest.raises(HTTPException):
provider_config("entra")
def test_login_codes_are_removed_from_access_logs():
from backend.app.main import LoginAccessLogFilter
record = logging.LogRecord("uvicorn.access", logging.INFO, "", 0, "%s %s %s %s %s", ("client", "GET", "/api/auth/callback/entra?code=secret&state=secret", "1.1", 303), None)
assert LoginAccessLogFilter().filter(record)
assert "secret" not in record.getMessage()
+4 -72
View File
@@ -64,7 +64,7 @@ def api_client(tmp_path, monkeypatch):
settings.environment = "production"
settings.voice_token = "test-voice-token"
security.settings.voice_token = "test-voice-token"
for key in ["PORTAINER_URL", "PORTAINER_API_KEY", "PORTAINER_ENDPOINT_ID"]:
for key in ["PORTAINER_URL", "PORTAINER_API_KEY", "PORTAINER_ENDPOINT_ID", "UNRAID_URL", "UNRAID_API_KEY"]:
settings.apis[key] = None
engine = create_engine(
@@ -252,77 +252,9 @@ def test_deep_health_is_available_without_admin_token(api_client):
assert "setup" in payload
def test_operator_token_is_exchanged_for_a_revocable_browser_session(api_client):
from backend.app.core import security
from backend.app.core.config import settings
token = "strong-session-test-operator-token-value"
previous = (settings.operator_auth_required, settings.operator_token)
settings.operator_auth_required = True
settings.operator_token = token
try:
unlocked = api_client.post(
"/api/operator/session",
headers={"X-AppOps-Operator-Token": token},
)
assert unlocked.status_code == 200
assert unlocked.json() == {"authorized": True, "mode": "session", "identity": None}
cookie = unlocked.headers["set-cookie"]
assert "appops_operator_session=" in cookie
assert "HttpOnly" in cookie
assert "SameSite=strict" in cookie
assert "Max-Age" not in cookie
assert token not in cookie
restored = api_client.get("/api/operator/session")
assert restored.json() == {"authorized": True, "mode": "session", "identity": None}
settings.operator_token = "a-new-strong-session-test-operator-token"
assert api_client.get("/api/operator/session").json()["authorized"] is False
settings.operator_token = token
assert api_client.get("/api/operator/session").json()["authorized"] is False
wrong_token = api_client.post(
"/api/operator/session",
headers={"X-AppOps-Operator-Token": "wrong-operator-token"},
)
assert wrong_token.status_code == 403
assert "set-cookie" not in wrong_token.headers
unlocked = api_client.post(
"/api/operator/session",
headers={"X-AppOps-Operator-Token": token},
)
assert unlocked.status_code == 200
missing_csrf = api_client.delete("/api/operator/session")
assert missing_csrf.status_code == 403
locked = api_client.delete(
"/api/operator/session",
headers={security.OPERATOR_SESSION_CSRF_HEADER: "1"},
)
assert locked.status_code == 200
assert api_client.get("/api/operator/session").json()["authorized"] is False
finally:
settings.operator_auth_required, settings.operator_token = previous
def test_operator_auth_policy_can_be_enabled_and_persists(api_client):
from backend.app.core.config import settings
from backend.app.db.models import AppSetting
from backend.app.db.session import engine
previous = settings.operator_auth_required
settings.operator_auth_required = False
try:
assert api_client.get("/api/runtime/operator-auth").json() == {"required": False}
response = api_client.patch("/api/runtime/operator-auth", json={"required": True})
assert response.status_code == 200
assert response.json() == {"required": True}
with Session(engine) as session:
assert session.get(AppSetting, "security.operator_auth").value_json == {"required": True}
finally:
settings.operator_auth_required = previous
def test_legacy_unlock_and_anonymous_admin_switch_are_removed(api_client):
assert api_client.post("/api/operator/session").status_code in {404, 405}
assert api_client.patch("/api/runtime/operator-auth", json={"required": False}).status_code in {404, 405}
def test_admin_exports_are_secret_safe(api_client):
+4 -77
View File
@@ -40,83 +40,10 @@ def _request(headers: list[tuple[bytes, bytes]] | None = None, client_host: str
return Request({"type": "http", "method": "POST", "path": "/api/actions/plan", "headers": headers or [], "client": (client_host, 12345)})
def test_operator_boundary_protects_mutations_and_accepts_valid_token():
def test_human_boundary_rejects_anonymous_and_legacy_tokens(monkeypatch):
from backend.app.core import security
from backend.app.core.config import settings
previous = (settings.operator_auth_required, settings.operator_token, security.settings.operator_auth_required, security.settings.operator_token)
settings.operator_auth_required = True
settings.operator_token = "test-operator-boundary-token"
security.settings.operator_auth_required = True
security.settings.operator_token = "test-operator-boundary-token"
try:
with pytest.raises(HTTPException) as missing:
security.require_operator_request(_request())
assert missing.value.status_code == 401
request = _request([(b"x-appops-operator-token", b"test-operator-boundary-token")])
assert security.require_operator_request(request) == "operator"
finally:
settings.operator_auth_required, settings.operator_token, security.settings.operator_auth_required, security.settings.operator_token = previous
def test_disabled_operator_boundary_ignores_stale_configured_token():
from backend.app.core import security
from backend.app.core.config import settings
previous = (settings.operator_auth_required, settings.operator_token)
settings.operator_auth_required = False
settings.operator_token = "stale-operator-token-that-no-longer-unlocks"
try:
assert security.require_operator_request(_request()) == "private_unlocked"
assert security.operator_session_status(_request()) == {
"authorized": True,
"mode": "private",
"identity": None,
}
finally:
settings.operator_auth_required, settings.operator_token = previous
def test_operator_boundary_accepts_allowed_authentik_account_only_from_trusted_proxy():
from backend.app.core import security
from backend.app.core.config import settings
previous = (
settings.operator_auth_required,
settings.operator_token,
settings.operator_proxy_auth_enabled,
settings.operator_proxy_trusted_networks,
settings.operator_proxy_users,
settings.operator_proxy_groups,
)
settings.operator_auth_required = True
settings.operator_token = "fallback-operator-token-value"
settings.operator_proxy_auth_enabled = True
settings.operator_proxy_trusted_networks = ["203.0.113.0/16"]
settings.operator_proxy_users = ["example-host"]
settings.operator_proxy_groups = ["appops-operators"]
headers = [(b"x-authentik-username", b"example-host"), (b"x-authentik-groups", b"users|appops-operators")]
try:
assert security.require_operator_request(_request(headers, "203.0.113.8")) == "authentik:example-host"
assert security.operator_session_status(_request(headers, "203.0.113.8")) == {
"authorized": True,
"mode": "account",
"identity": "example-host",
}
assert security.require_operator_request(_request(headers + [(b"x-appops-operator-token", b"stale-token")], "203.0.113.8")) == "authentik:example-host"
with pytest.raises(HTTPException) as spoofed:
security.require_operator_request(_request(headers, "198.18.10.20"))
assert spoofed.value.status_code == 401
denied_headers = [(b"x-authentik-username", b"someone-else"), (b"x-authentik-groups", b"users")]
monkeypatch.setenv("APPOPS_REQUIRE_OPERATOR_AUTH", "false")
for headers in [[], [(b"x-appops-operator-token", b"legacy-token")]]:
with pytest.raises(HTTPException) as denied:
security.require_operator_request(_request(denied_headers, "203.0.113.8"))
security.require_operator_request(_request(headers))
assert denied.value.status_code == 401
finally:
(
settings.operator_auth_required,
settings.operator_token,
settings.operator_proxy_auth_enabled,
settings.operator_proxy_trusted_networks,
settings.operator_proxy_users,
settings.operator_proxy_groups,
) = previous
+22
View File
@@ -476,3 +476,25 @@ def test_unraid_system_metrics_reports_partial_instead_of_inventing_zero(monkeyp
assert result["cpuUsage"] is None
assert result["ramUsage"] == 92
assert "cpu_unavailable" in result["reason"]
def test_unraid_system_metrics_preserves_fractional_load(monkeypatch):
connector = UnraidConnector()
monkeypatch.setattr(connector, '_query', lambda queries: ({'metrics': {
'cpu': {'cpus': [{'percentIdle': 79.15}, {'percentIdle': 80.25}]},
'memory': {'total': 10000, 'available': 5432, 'used': 9000},
}}, 'ok'))
result = connector.get_system_metrics()
assert result['cpuUsage'] == 20.3
assert result['ramUsage'] == 45.68
def test_unraid_system_metrics_rejects_non_finite_load(monkeypatch):
connector = UnraidConnector()
monkeypatch.setattr(connector, '_query', lambda queries: ({'metrics': {
'cpu': {'cpus': [{'percentIdle': float('nan')}]},
'memory': {'total': 10000, 'used': float('inf')},
}}, 'ok'))
result = connector.get_system_metrics()
assert result['cpuUsage'] is None
assert result['ramUsage'] is None
+204
View File
@@ -0,0 +1,204 @@
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
@pytest.fixture
def identity_client(tmp_path, monkeypatch):
from backend.app.core.config import settings
from backend.app.db import session as database
from backend.app.main import app
engine = create_engine(f"sqlite:///{tmp_path / 'identities.db'}", connect_args={"check_same_thread": False})
monkeypatch.setattr(database, "engine", engine)
monkeypatch.setenv("APPOPS_REQUIRE_OPERATOR_AUTH", "false")
monkeypatch.setattr(settings, "operator_proxy_auth_enabled", False)
monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:owner"], raising=False)
monkeypatch.setattr(settings, "auth_admin_emails", ["jens@example.com"], raising=False)
SQLModel.metadata.create_all(engine)
# Avoid unrelated schedulers and external connectors in boundary tests.
from backend.app.db.init_db import init_db
init_db()
yield TestClient(app, client=("198.18.10.20", 42000))
engine.dispose()
def sign_in(client, subject, **claims):
from backend.app.db import session as database
from backend.app.services.user_access import issue_session, provision_user
with Session(database.engine) as db:
user = provision_user(db, "authentik", "https://auth.example/application/o/appops/", {
"sub": subject, "email": f"{subject}@example.com", **claims,
})
cookie = issue_session(db, user)
user_id = user.id
client.cookies.set("appops_user_session", cookie)
return user_id
def test_shared_token_and_disabled_operator_switch_do_not_grant_access(identity_client):
client = identity_client
assert client.get("/api/apps").status_code == 401
assert client.post("/api/actions/plan", json={}, headers={"X-AppOps-Operator-Token": "old-shared-token"}).status_code == 401
assert client.get("/api/auth/session").json()["authenticated"] is False
assert client.get("/api/health").status_code == 200
def test_new_user_is_read_only_and_cannot_change_permissions(identity_client):
user_id = sign_in(identity_client, "reader")
assert identity_client.get("/api/apps").status_code == 200
status = identity_client.get("/api/auth/session").json()
assert status["user"]["role"] == "read_only"
assert status["permissions"] == ["read"]
for path in ["/api/actions/execute", "/api/voice/execute", "/api/settings/connectors/portainer", f"/api/auth/users/{user_id}"]:
assert identity_client.post(path, json={}, headers={"X-AppOps-Session-CSRF": "1"}).status_code == 403
assert identity_client.get("/api/auth/users").status_code == 403
def test_admin_can_manage_users_without_unlock_and_changes_apply_immediately(identity_client):
reader_id = sign_in(identity_client, "reader")
sign_in(identity_client, "owner")
status = identity_client.get("/api/auth/session").json()
assert status["user"]["role"] == "admin"
assert "users.manage" in status["permissions"]
assert identity_client.get("/api/auth/users").status_code == 200
update = identity_client.patch(f"/api/auth/users/{reader_id}", json={"role": "custom", "permissions": ["read", "inventory.manage"], "enabled": True}, headers={"X-AppOps-Session-CSRF": "1"})
assert update.status_code == 200
sign_in(identity_client, "reader")
assert identity_client.get("/api/auth/session").json()["permissions"] == ["inventory.manage", "read"]
assert identity_client.post("/api/actions/execute", json={}, headers={"X-AppOps-Session-CSRF": "1"}).status_code == 403
def test_csrf_logout_revocation_and_last_admin_protection(identity_client):
admin_id = sign_in(identity_client, "owner")
assert identity_client.patch(f"/api/auth/users/{admin_id}", json={"role": "read_only", "permissions": [], "enabled": True}, headers={"X-AppOps-Session-CSRF": "1"}).status_code == 409
assert identity_client.post("/api/auth/logout").status_code == 403
assert identity_client.post("/api/auth/logout", headers={"X-AppOps-Session-CSRF": "1"}).status_code == 200
assert identity_client.get("/api/apps").status_code == 401
def test_last_enabled_admin_guard_counts_another_enabled_admin(identity_client):
admin_id = sign_in(identity_client, "owner")
second_id = sign_in(identity_client, "second-admin")
sign_in(identity_client, "owner")
headers = {"X-AppOps-Session-CSRF": "1"}
promoted = identity_client.patch(
f"/api/auth/users/{second_id}",
json={"role": "admin", "enabled": True, "permissions": []},
headers=headers,
)
assert promoted.status_code == 200
disabled = identity_client.patch(
f"/api/auth/users/{second_id}",
json={"role": "admin", "enabled": False, "permissions": []},
headers=headers,
)
assert disabled.status_code == 200
last_admin = identity_client.patch(
f"/api/auth/users/{admin_id}",
json={"role": "admin", "enabled": False, "permissions": []},
headers=headers,
)
assert last_admin.status_code == 409
def test_session_expiration_and_forgery_fail_closed(identity_client):
from backend.app.db import session as database
from backend.app.db.models import UserSession
from backend.app.services.user_access import digest
sign_in(identity_client, "reader")
with Session(database.engine) as db:
record = db.get(UserSession, digest(identity_client.cookies.get("appops_user_session")))
record.expires_at = 1
db.add(record)
db.commit()
assert identity_client.get("/api/apps").status_code == 401
identity_client.cookies.set("appops_user_session", "forged")
assert identity_client.get("/api/apps").status_code == 401
def test_email_bootstrap_requires_verified_claim_and_is_pinned(identity_client):
sign_in(identity_client, "username-only", email=None, preferred_username="jens@example.com", email_verified=True)
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "read_only"
sign_in(identity_client, "unverified", email="jens@example.com")
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "read_only"
sign_in(identity_client, "verified", email="jens@example.com", email_verified=True)
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "admin"
sign_in(identity_client, "replacement", email="jens@example.com", email_verified=True)
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "read_only"
def test_bootstrap_recovers_early_login_without_restoring_demoted_admin(identity_client, monkeypatch):
from backend.app.core.config import settings
user_id = sign_in(identity_client, "early")
monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:early", "authentik:owner"])
sign_in(identity_client, "early")
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "admin"
sign_in(identity_client, "owner")
response = identity_client.patch(f"/api/auth/users/{user_id}", json={"role": "read_only", "enabled": True, "permissions": []}, headers={"X-AppOps-Session-CSRF": "1"})
assert response.status_code == 200
sign_in(identity_client, "early")
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "read_only"
monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:owner"])
sign_in(identity_client, "early", email="jens@example.com", email_verified=True)
assert identity_client.get("/api/auth/session").json()["user"]["role"] == "read_only"
def test_expired_disabled_and_forged_sessions_fail_closed(identity_client):
from backend.app.db import session as database
from backend.app.db.models import GatewayUser
user_id = sign_in(identity_client, "reader")
with Session(database.engine) as db:
user = db.get(GatewayUser, user_id)
user.enabled = False
db.add(user)
db.commit()
assert identity_client.get("/api/apps").status_code == 401
def test_proxy_headers_require_trusted_peer_and_fixed_uid(identity_client, monkeypatch):
from fastapi import HTTPException
from starlette.requests import Request
from backend.app.core.config import settings
from backend.app.services.user_access import current_user
monkeypatch.setattr(settings, "operator_proxy_auth_enabled", True)
monkeypatch.setattr(settings, "operator_proxy_trusted_networks", ["203.0.113.8/32"])
headers = [(b"x-authentik-uid", b"owner"), (b"x-authentik-username", b"jens"), (b"x-authentik-email", b"jens@example.com")]
def request(peer, supplied_headers):
return Request({"type": "http", "method": "GET", "path": "/api/apps", "headers": supplied_headers, "client": (peer, 1234)})
with pytest.raises(HTTPException) as rejected:
current_user(request("198.18.10.20", headers))
assert rejected.value.status_code == 401
assert current_user(request("203.0.113.8", headers)).role == "admin"
with pytest.raises(HTTPException) as rejected:
current_user(request("203.0.113.8", headers[1:]))
assert rejected.value.status_code == 401
def test_custom_voice_and_approval_permissions_do_not_grant_execution(identity_client):
reader_id = sign_in(identity_client, "reader")
sign_in(identity_client, "owner")
response = identity_client.patch(f"/api/auth/users/{reader_id}", json={"role": "custom", "enabled": True, "permissions": ["voice.use", "approvals.manage"]}, headers={"X-AppOps-Session-CSRF": "1"})
assert response.status_code == 200
sign_in(identity_client, "reader")
for path in ["/api/voice/actions/container/plex/restart", "/api/approvals/approval-1/execute", "/api/connectors/portainer/bind", "/api/ai/rag/reindex"]:
assert identity_client.post(path, json={}, headers={"X-AppOps-Session-CSRF": "1"}).status_code == 403
def test_operational_users_cannot_change_enrollment_trust(identity_client):
reader_id = sign_in(identity_client, "reader")
sign_in(identity_client, "owner")
headers = {"X-AppOps-Session-CSRF": "1"}
assert identity_client.patch(f"/api/auth/users/{reader_id}", json={"role": "operator", "enabled": True}, headers=headers).status_code == 200
sign_in(identity_client, "reader")
assert identity_client.put("/api/enrollments/trust", json={}, headers=headers).status_code == 403
sign_in(identity_client, "owner")
# Administrator reaches schema validation; an operator is rejected first.
assert identity_client.put("/api/enrollments/trust", json={}, headers=headers).status_code == 422