Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e
This commit is contained in:
1 parent
c61fbfd87f
commit
1a54836efc
101 files changed
+4973
-1298
No files matched your search
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user