Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,673 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import SQLModel, create_engine
|
||||
|
||||
|
||||
def _reset_db(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "authentik_test.db"
|
||||
monkeypatch.setenv("APPOPS_DB_PATH", str(db_path))
|
||||
monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token")
|
||||
|
||||
from backend.app.db import session
|
||||
from backend.app.db.init_db import init_db
|
||||
|
||||
engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False})
|
||||
session.engine = engine
|
||||
SQLModel.metadata.drop_all(engine)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
init_db()
|
||||
|
||||
|
||||
def _set_authentik_config(
|
||||
url: str | None = "https://auth.example.test",
|
||||
token: str | None = "AUTHENTIK_TOKEN_VALUE",
|
||||
outpost_url: str | None = "https://outpost.example.test",
|
||||
):
|
||||
from backend.app.core.config import settings
|
||||
|
||||
previous = dict(settings.apis)
|
||||
settings.apis["AUTHENTIK_URL"] = url
|
||||
settings.apis["AUTHENTIK_TOKEN"] = token
|
||||
settings.apis["AUTHENTIK_VERIFY_TLS"] = False
|
||||
settings.apis["AUTHENTIK_TIMEOUT_SECONDS"] = "10"
|
||||
settings.apis["AUTHENTIK_OUTPOST_URL"] = outpost_url
|
||||
settings.apis["AUTHENTIK_EXPECT_EMBEDDED_OUTPOST"] = True
|
||||
return previous
|
||||
|
||||
|
||||
def _set_npm_config():
|
||||
from backend.app.core.config import settings
|
||||
|
||||
settings.apis["NPM_URL"] = "https://npm.example.test"
|
||||
settings.apis["NPM_IDENTITY"] = "admin@example.test"
|
||||
settings.apis["NPM_SECRET"] = "NPM_SECRET_VALUE"
|
||||
settings.apis["NPM_VERIFY_TLS"] = False
|
||||
settings.apis["NPM_TIMEOUT_SECONDS"] = "10"
|
||||
|
||||
|
||||
def _restore_config(previous):
|
||||
from backend.app.core.config import settings
|
||||
|
||||
settings.apis.clear()
|
||||
settings.apis.update(previous)
|
||||
|
||||
|
||||
def _applications():
|
||||
return [
|
||||
{"pk": "app-media", "name": "Media", "slug": "media", "launch_url": "https://media.example.com", "provider": 101},
|
||||
{"pk": "app-orphan", "name": "Orphan", "slug": "orphan", "launch_url": "https://orphan.example.com"},
|
||||
]
|
||||
|
||||
|
||||
def _providers():
|
||||
return [
|
||||
{"pk": 101, "name": "Media proxy", "type": "authentik_providers_proxy.proxyprovider", "assigned_application_slug": "media"},
|
||||
{"pk": 202, "name": "Dashboard oauth", "type": "authentik_providers_oauth2.oauth2provider", "assigned_application_slug": "dashboard"},
|
||||
]
|
||||
|
||||
|
||||
def _proxy_providers():
|
||||
return [
|
||||
{"pk": 101, "name": "Media proxy", "external_host": "https://media.example.com", "assigned_application_slug": "media"},
|
||||
{"pk": 303, "name": "Unused proxy", "external_host": "https://unused.example.com"},
|
||||
]
|
||||
|
||||
|
||||
def _oauth2_providers():
|
||||
return [
|
||||
{
|
||||
"pk": 202,
|
||||
"name": "Dashboard oauth",
|
||||
"assigned_application_slug": "dashboard",
|
||||
"redirect_uris": "https://a.example.com/cb\nhttps://b.example.com/cb\nhttps://c.example.com/cb\nhttps://d.example.com/cb",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _outposts():
|
||||
return [{"pk": "outpost-1", "name": "Embedded Outpost", "type": "proxy", "health": "healthy", "providers": [101]}]
|
||||
|
||||
|
||||
def _events():
|
||||
return [
|
||||
{"pk": "evt-1", "action": "login", "created": "2026-01-01T10:00:00Z", "client_ip": "198.18.10.25", "user": {"username": "jens"}},
|
||||
{"pk": "evt-2", "action": "authorize_application", "created": "2026-01-01T10:05:00Z", "client_ip": "198.19.0.2"},
|
||||
]
|
||||
|
||||
|
||||
def test_authentik_connector_missing_config_reports_required_env(tmp_path, monkeypatch):
|
||||
_reset_db(tmp_path, monkeypatch)
|
||||
previous = _set_authentik_config(url=None, token=None)
|
||||
|
||||
from backend.app.main import app
|
||||
|
||||
with TestClient(app) as client:
|
||||
payload = client.post("/api/connectors/authentik/test").json()
|
||||
|
||||
assert payload["success"] is False
|
||||
assert payload["status"] == "missing_config"
|
||||
assert payload["missing_config"] is True
|
||||
assert payload["required_env_vars"] == ["AUTHENTIK_URL", "AUTHENTIK_TOKEN"]
|
||||
assert payload["secretRef"] == "AUTHENTIK_TOKEN"
|
||||
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_authentik_token_accepts_optional_bearer_prefix():
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
|
||||
previous = _set_authentik_config(token="Bearer AUTHENTIK_TOKEN_VALUE")
|
||||
|
||||
try:
|
||||
config = AuthentikConnector()._config()
|
||||
|
||||
assert config is not None
|
||||
assert config.token == "AUTHENTIK_TOKEN_VALUE"
|
||||
finally:
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_oidc_template_requires_the_exact_enrolled_role_claim():
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
from backend.app.core.config import settings
|
||||
|
||||
previous = _set_authentik_config()
|
||||
settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth'
|
||||
settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation'
|
||||
settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups'
|
||||
settings.apis['AUTHENTIK_OIDC_ROLE_CLAIM'] = 'groups'
|
||||
try:
|
||||
assert AuthentikConnector.deployment_oidc_template_status(role_claim='roles') == {
|
||||
'status': 'missing_config',
|
||||
'missing': ['AUTHENTIK_OIDC_ROLE_CLAIM'],
|
||||
}
|
||||
settings.apis['AUTHENTIK_OIDC_ROLE_CLAIM'] = 'roles'
|
||||
assert AuthentikConnector.deployment_oidc_template_status(role_claim='roles') == {
|
||||
'status': 'ready',
|
||||
'missing': [],
|
||||
}
|
||||
finally:
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_typed_oidc_provisioning_is_bounded_and_never_returns_client_secret(monkeypatch):
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
from backend.app.core.config import settings
|
||||
|
||||
previous = _set_authentik_config()
|
||||
settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth'
|
||||
settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation'
|
||||
settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups'
|
||||
writes = []
|
||||
|
||||
def paged(self, path, params=None):
|
||||
assert params is None
|
||||
return {
|
||||
'/core/groups/': [{'name': 'Administrator'}, {'name': 'SupportAgent'}, {'name': 'ReadOnly'}],
|
||||
'/core/applications/': [],
|
||||
}[path]
|
||||
|
||||
def write(self, method, path, payload=None):
|
||||
writes.append((method, path, payload))
|
||||
return {'pk': 'provider-1'} if path == '/providers/oauth2/' else {'pk': 'application-1'}
|
||||
|
||||
monkeypatch.setattr(AuthentikConnector, '_paged', paged, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, '_write_request', write, raising=True)
|
||||
secret = 'generated-client-secret-value'
|
||||
result = AuthentikConnector().ensure_deployment_oidc_application(
|
||||
application_slug='wsbc-portal',
|
||||
launch_url='https://support.itworx.test',
|
||||
redirect_uri='https://support.itworx.test/signin-oidc',
|
||||
client_secret=secret,
|
||||
administrator_group='Administrator',
|
||||
support_agent_group='SupportAgent',
|
||||
read_only_group='ReadOnly',
|
||||
)
|
||||
|
||||
assert result == {
|
||||
'success': True,
|
||||
'status': 'created',
|
||||
'message': 'oidc_application_created_from_typed_template',
|
||||
'applicationId': 'application-1',
|
||||
'providerId': 'provider-1',
|
||||
'clientSecretCreated': True,
|
||||
}
|
||||
assert writes[0][0:2] == ('POST', '/providers/oauth2/')
|
||||
assert writes[0][2]['client_secret'] == secret
|
||||
assert secret not in str(result)
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_typed_oidc_provisioning_removes_new_provider_when_application_create_fails(monkeypatch):
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
from backend.app.core.config import settings
|
||||
|
||||
previous = _set_authentik_config()
|
||||
settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth'
|
||||
settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation'
|
||||
settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups'
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
AuthentikConnector,
|
||||
'_paged',
|
||||
lambda self, path, params=None: (
|
||||
[{'name': 'Administrator'}, {'name': 'SupportAgent'}, {'name': 'ReadOnly'}]
|
||||
if path == '/core/groups/' else []
|
||||
),
|
||||
raising=True,
|
||||
)
|
||||
|
||||
def write(self, method, path, payload=None):
|
||||
calls.append((method, path, payload))
|
||||
if path == '/providers/oauth2/':
|
||||
return {'pk': 'provider-1'}
|
||||
if path == '/core/applications/':
|
||||
raise RuntimeError('request failed client_secret=never-return-this')
|
||||
assert (method, path, payload) == ('DELETE', '/providers/oauth2/provider-1/', None)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(AuthentikConnector, '_write_request', write, raising=True)
|
||||
result = AuthentikConnector().ensure_deployment_oidc_application(
|
||||
application_slug='wsbc-portal',
|
||||
launch_url='https://support.itworx.test',
|
||||
redirect_uri='https://support.itworx.test/signin-oidc',
|
||||
client_secret='never-return-this',
|
||||
administrator_group='Administrator',
|
||||
support_agent_group='SupportAgent',
|
||||
read_only_group='ReadOnly',
|
||||
)
|
||||
|
||||
assert result == {
|
||||
'success': False,
|
||||
'status': 'failed',
|
||||
'message': 'oidc_application_creation_failed_provider_removed',
|
||||
}
|
||||
assert calls[-1] == ('DELETE', '/providers/oauth2/provider-1/', None)
|
||||
assert 'never-return-this' not in str(result)
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_typed_oidc_existing_application_requires_matching_provider_contract(monkeypatch):
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
from backend.app.core.config import settings
|
||||
|
||||
previous = _set_authentik_config()
|
||||
settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth'
|
||||
settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation'
|
||||
settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups'
|
||||
monkeypatch.setattr(
|
||||
AuthentikConnector,
|
||||
'_paged',
|
||||
lambda self, path, params=None: {
|
||||
'/core/groups/': [{'name': 'Administrator'}, {'name': 'SupportAgent'}, {'name': 'ReadOnly'}],
|
||||
'/core/applications/': [{'pk': 'application-1', 'slug': 'wsbc-portal', 'meta_launch_url': 'https://support.itworx.test', 'provider': 'provider-1'}],
|
||||
'/providers/oauth2/': [{'pk': 'provider-1', 'client_id': 'unexpected-client', 'client_type': 'confidential', 'redirect_uris': ['https://support.itworx.test/signin-oidc'], 'property_mappings': ['mapping-groups'], 'authorization_flow': 'flow-default-auth', 'invalidation_flow': 'flow-default-invalidation'}],
|
||||
}[path],
|
||||
raising=True,
|
||||
)
|
||||
try:
|
||||
result = AuthentikConnector().ensure_deployment_oidc_application(
|
||||
application_slug='wsbc-portal',
|
||||
launch_url='https://support.itworx.test',
|
||||
redirect_uri='https://support.itworx.test/signin-oidc',
|
||||
client_secret='never-return-this',
|
||||
administrator_group='Administrator',
|
||||
support_agent_group='SupportAgent',
|
||||
read_only_group='ReadOnly',
|
||||
)
|
||||
finally:
|
||||
_restore_config(previous)
|
||||
|
||||
assert result == {
|
||||
'success': False,
|
||||
'status': 'conflict',
|
||||
'message': 'existing_oidc_provider_does_not_match_managed_contract',
|
||||
}
|
||||
|
||||
|
||||
def test_authentik_health_success_and_auth_failure_redact_token(monkeypatch):
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
|
||||
previous = _set_authentik_config()
|
||||
requested_paths = []
|
||||
|
||||
def ok_request(self, path, params=None):
|
||||
requested_paths.append(path)
|
||||
return {"version": "2026.1.0"} if path == "/root/config/" else {}
|
||||
|
||||
monkeypatch.setattr(AuthentikConnector, "_request", ok_request, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "applications", lambda self: {"success": True, "items": _applications(), "total": 2}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "outpost_health", lambda self: {"success": True, "status": "ok", "available": True, "items": []}, raising=True)
|
||||
|
||||
healthy = AuthentikConnector().health()
|
||||
|
||||
assert healthy["success"] is True
|
||||
assert healthy["status"] == "connected"
|
||||
assert healthy["version"] == "2026.1.0"
|
||||
assert healthy["applicationCount"] == 2
|
||||
assert healthy["url"] == "https://auth.example.test"
|
||||
assert "AUTHENTIK_TOKEN_VALUE" not in str(healthy)
|
||||
assert "/root/config/" in requested_paths
|
||||
assert "/core/root/" not in requested_paths
|
||||
|
||||
def fail_request(self, path, params=None):
|
||||
raise RuntimeError("auth_failed token=AUTHENTIK_TOKEN_VALUE")
|
||||
|
||||
monkeypatch.setattr(AuthentikConnector, "_request", fail_request, raising=True)
|
||||
failed = AuthentikConnector().health()
|
||||
|
||||
assert failed["success"] is False
|
||||
assert failed["status"] == "auth_failed"
|
||||
assert "AUTHENTIK_TOKEN_VALUE" not in str(failed)
|
||||
assert "[REDACTED]" in str(failed)
|
||||
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_authentik_resources_normalize_and_events_mask_ips(monkeypatch):
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
|
||||
previous = _set_authentik_config()
|
||||
monkeypatch.setattr(
|
||||
AuthentikConnector,
|
||||
"_paged",
|
||||
lambda self, path, params=None: {
|
||||
"/core/applications/": _applications(),
|
||||
"/core/providers/": _providers(),
|
||||
"/providers/proxy/": _proxy_providers(),
|
||||
"/providers/oauth2/": _oauth2_providers(),
|
||||
"/outposts/instances/": _outposts(),
|
||||
"/events/events/": _events(),
|
||||
"/flows/instances/": [{"pk": "flow-1", "name": "Login", "designation": "authentication"}],
|
||||
"/policies/all/": [{"pk": "policy-1", "name": "Require MFA", "enabled": True}],
|
||||
}[path],
|
||||
raising=True,
|
||||
)
|
||||
|
||||
connector = AuthentikConnector()
|
||||
apps = connector.applications()
|
||||
providers = connector.providers()
|
||||
proxy = connector.proxy_providers()
|
||||
oauth = connector.oauth2_providers()
|
||||
outposts = connector.outposts()
|
||||
events = connector.events()
|
||||
flows = connector.flows_summary()
|
||||
policies = connector.policies_summary()
|
||||
|
||||
assert apps["items"][0]["slug"] == "media"
|
||||
assert providers["items"][0]["providerType"] == "proxy"
|
||||
assert proxy["items"][0]["externalHost"] == "https://media.example.com"
|
||||
assert oauth["items"][0]["redirectUriCount"] == 4
|
||||
assert outposts["items"][0]["providerCount"] == 1
|
||||
assert events["items"][0]["clientIp"] == "192.168.10.x"
|
||||
assert flows["total"] == 1
|
||||
assert policies["enabled"] == 1
|
||||
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_authentik_auth_coverage_and_security_warnings_with_npm(monkeypatch):
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
previous = _set_authentik_config()
|
||||
_set_npm_config()
|
||||
monkeypatch.setattr(AuthentikConnector, "applications", lambda self: {"success": True, "items": AuthentikConnector._map_applications(_applications())}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "providers", lambda self: {"success": True, "items": AuthentikConnector._map_providers(_providers())}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "proxy_providers", lambda self: {"success": True, "items": AuthentikConnector._map_proxy_providers(_proxy_providers())}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "oauth2_providers", lambda self: {"success": True, "items": AuthentikConnector._map_oauth2_providers(_oauth2_providers())}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "outposts", lambda self: {"success": True, "items": []}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "outpost_health", lambda self: {"success": True, "available": False, "embeddedExpected": True, "status": "warning", "items": []}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "events", lambda self, limit=25: {"success": True, "items": []}, raising=True)
|
||||
monkeypatch.setattr(
|
||||
NginxProxyManagerConnector,
|
||||
"proxy_hosts",
|
||||
lambda self: {
|
||||
"success": True,
|
||||
"items": [
|
||||
{"id": 1, "domainNames": ["media.example.com"], "enabled": True, "accessListId": 2, "forwardHost": "plex", "forwardPort": 32400, "forwardScheme": "http"},
|
||||
{"id": 2, "domainNames": ["open.example.com"], "enabled": True, "accessListId": None, "forwardHost": "app", "forwardPort": 8080, "forwardScheme": "http"},
|
||||
],
|
||||
},
|
||||
raising=True,
|
||||
)
|
||||
|
||||
coverage = AuthentikConnector().auth_coverage()
|
||||
warnings = AuthentikConnector().security_warnings()
|
||||
|
||||
assert coverage["summary"]["totalPublicHosts"] == 2
|
||||
assert coverage["summary"]["protectedHosts"] == 1
|
||||
assert coverage["summary"]["unprotectedHosts"] == 1
|
||||
assert any(item["coverageStatus"] == "protected" for item in coverage["publicHosts"])
|
||||
assert any(item["coverageStatus"] == "unprotected" for item in coverage["publicHosts"])
|
||||
assert any(item["type"] == "no_outposts" for item in warnings["items"])
|
||||
assert any(item["type"] == "public_host_without_authentik" for item in warnings["items"])
|
||||
assert any(item["type"] == "oauth_many_redirect_uris" for item in warnings["items"])
|
||||
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_authentik_reuses_read_results_within_connector_instance(monkeypatch):
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
previous = _set_authentik_config()
|
||||
_set_npm_config()
|
||||
calls = []
|
||||
|
||||
def paged(self, path, params=None):
|
||||
calls.append(path)
|
||||
return {
|
||||
"/core/applications/": _applications(),
|
||||
"/core/providers/": _providers(),
|
||||
"/providers/proxy/": _proxy_providers(),
|
||||
"/providers/oauth2/": _oauth2_providers(),
|
||||
"/outposts/instances/": _outposts(),
|
||||
"/events/events/": _events(),
|
||||
}[path]
|
||||
|
||||
monkeypatch.setattr(AuthentikConnector, "_paged", paged, raising=True)
|
||||
monkeypatch.setattr(
|
||||
NginxProxyManagerConnector,
|
||||
"proxy_hosts",
|
||||
lambda self: {
|
||||
"success": True,
|
||||
"status": "ok",
|
||||
"items": [
|
||||
{"id": 1, "domainNames": ["media.example.com"], "enabled": True, "accessListId": 2, "forwardHost": "plex", "forwardPort": 32400, "forwardScheme": "http"},
|
||||
{"id": 2, "domainNames": ["open.example.com"], "enabled": True, "accessListId": None, "forwardHost": "app", "forwardPort": 8080, "forwardScheme": "http"},
|
||||
],
|
||||
},
|
||||
raising=True,
|
||||
)
|
||||
|
||||
connector = AuthentikConnector()
|
||||
coverage = connector.auth_coverage()
|
||||
warnings = connector.security_warnings()
|
||||
outpost = connector.outpost_health()
|
||||
|
||||
assert coverage["summary"]["totalPublicHosts"] == 2
|
||||
assert warnings["total"] >= 1
|
||||
assert outpost["available"] is True
|
||||
assert calls.count("/core/applications/") == 1
|
||||
assert calls.count("/core/providers/") == 1
|
||||
assert calls.count("/providers/proxy/") == 1
|
||||
assert calls.count("/providers/oauth2/") == 1
|
||||
assert calls.count("/outposts/instances/") == 1
|
||||
assert calls.count("/events/events/") == 1
|
||||
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_authentik_unknown_embedded_outpost_is_warning_not_high(monkeypatch):
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
|
||||
previous = _set_authentik_config(outpost_url=None)
|
||||
try:
|
||||
monkeypatch.setattr(AuthentikConnector, "applications", lambda self: {"success": True, "items": AuthentikConnector._map_applications(_applications())}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "providers", lambda self: {"success": True, "items": []}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "proxy_providers", lambda self: {"success": True, "items": []}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "oauth2_providers", lambda self: {"success": True, "items": []}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "outposts", lambda self: {"success": True, "items": [{"id": "embedded", "name": "Embedded Outpost", "health": "unknown"}]}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "outpost_health", lambda self: {"success": True, "available": False, "embeddedExpected": True, "status": "warning", "items": [{"id": "embedded", "name": "Embedded Outpost", "health": "unknown"}]}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "events", lambda self, limit=25: {"success": True, "items": []}, raising=True)
|
||||
|
||||
warnings = AuthentikConnector().security_warnings()
|
||||
embedded = next(item for item in warnings["items"] if item["type"] == "embedded_outpost_unavailable")
|
||||
|
||||
assert embedded["severity"] == "warning"
|
||||
finally:
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_authentik_numeric_pagination_reuses_collection_path(monkeypatch):
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
|
||||
previous = _set_authentik_config()
|
||||
calls = []
|
||||
|
||||
def request(self, path, params=None):
|
||||
calls.append((path, params))
|
||||
page = int((params or {}).get("page", 1))
|
||||
return {
|
||||
"results": [{"pk": f"app-{page}"}],
|
||||
"pagination": {"next": page + 1 if page < 3 else 0},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(AuthentikConnector, "_request", request, raising=True)
|
||||
try:
|
||||
items = AuthentikConnector()._paged("/core/applications/", {"page_size": 20})
|
||||
|
||||
assert [item["pk"] for item in items] == ["app-1", "app-2", "app-3"]
|
||||
assert calls == [
|
||||
("/core/applications/", {"page_size": 20}),
|
||||
("/core/applications/", {"page_size": 20, "page": 2}),
|
||||
("/core/applications/", {"page_size": 20, "page": 3}),
|
||||
]
|
||||
finally:
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_authentik_outpost_reachability_is_not_mislabeled_as_health(monkeypatch):
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
|
||||
previous = _set_authentik_config(outpost_url="https://outpost.example.test")
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
AuthentikConnector,
|
||||
"outposts",
|
||||
lambda self: {
|
||||
"success": True,
|
||||
"items": [{"id": "embedded", "name": "Embedded Outpost", "health": "unknown"}],
|
||||
},
|
||||
raising=True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AuthentikConnector,
|
||||
"_probe_outpost",
|
||||
lambda self, config: {"status": "reachable", "reachable": True, "statusCode": 302},
|
||||
raising=True,
|
||||
)
|
||||
|
||||
result = AuthentikConnector().outpost_health()
|
||||
|
||||
assert result["available"] is False
|
||||
assert result["reachable"] is True
|
||||
assert result["status"] == "reachable_unverified"
|
||||
assert result["telemetryStatus"] == "unverified"
|
||||
assert result["message"] == "outpost_reachable_health_unverified"
|
||||
finally:
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_authentik_outpost_probe_never_forwards_api_token(monkeypatch):
|
||||
from backend.app.connectors import authentik
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
|
||||
previous = _set_authentik_config(token="SENSITIVE_AUTHENTIK_TOKEN")
|
||||
captured = {}
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 401
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
captured["client"] = kwargs
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def get(self, url, headers):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(authentik.httpx, "Client", FakeClient)
|
||||
try:
|
||||
config = AuthentikConnector()._config()
|
||||
result = AuthentikConnector._probe_outpost(config)
|
||||
finally:
|
||||
_restore_config(previous)
|
||||
|
||||
assert result == {"status": "reachable", "reachable": True, "statusCode": 401}
|
||||
assert "Authorization" not in captured["headers"]
|
||||
assert "SENSITIVE_AUTHENTIK_TOKEN" not in str(captured)
|
||||
|
||||
|
||||
def test_authentik_routes_are_read_only_and_shape_is_consistent(tmp_path, monkeypatch):
|
||||
_reset_db(tmp_path, monkeypatch)
|
||||
previous = _set_authentik_config()
|
||||
|
||||
from backend.app.connectors.authentik import AuthentikConnector
|
||||
from backend.app.main import app
|
||||
|
||||
monkeypatch.setattr(AuthentikConnector, "_request", lambda self, path, params=None: {"version": "2026.1.0"}, raising=True)
|
||||
monkeypatch.setattr(AuthentikConnector, "_paged", lambda self, path, params=None: [], raising=True)
|
||||
|
||||
with TestClient(app) as client:
|
||||
for path in [
|
||||
"/api/apps/authentik/health",
|
||||
"/api/apps/authentik/system",
|
||||
"/api/apps/authentik/applications",
|
||||
"/api/apps/authentik/providers",
|
||||
"/api/apps/authentik/providers/proxy",
|
||||
"/api/apps/authentik/providers/oauth2",
|
||||
"/api/apps/authentik/outposts",
|
||||
"/api/apps/authentik/outposts/health",
|
||||
"/api/apps/authentik/flows",
|
||||
"/api/apps/authentik/policies",
|
||||
"/api/apps/authentik/events",
|
||||
"/api/apps/authentik/auth-coverage",
|
||||
"/api/apps/authentik/security-warnings",
|
||||
]:
|
||||
response = client.get(path)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert set(["success", "status", "message", "data"]).issubset(payload.keys())
|
||||
assert client.post("/api/apps/authentik/applications").status_code == 405
|
||||
|
||||
_restore_config(previous)
|
||||
|
||||
|
||||
def test_authentik_policy_blocks_mutations_and_unknowns_fail_closed(tmp_path, monkeypatch):
|
||||
_reset_db(tmp_path, monkeypatch)
|
||||
|
||||
from backend.app.main import app
|
||||
|
||||
with TestClient(app) as client:
|
||||
for action in [
|
||||
"authentik.create_application",
|
||||
"authentik.update_application",
|
||||
"authentik.delete_application",
|
||||
"authentik.create_provider",
|
||||
"authentik.delete_provider",
|
||||
"authentik.create_outpost",
|
||||
"authentik.delete_policy",
|
||||
"authentik.create_user",
|
||||
"authentik.delete_group",
|
||||
"authentik.rotate_token",
|
||||
"authentik.force_sync",
|
||||
"authentik.mass_edit",
|
||||
"authentik.unknown_mutation",
|
||||
]:
|
||||
payload = client.post("/api/policies/evaluate", json={"action": action, "target": "authentik", "source": "manual"}).json()
|
||||
assert payload["decision"] == "blocked"
|
||||
events_voice = client.post("/api/policies/evaluate", json={"action": "authentik.events", "target": "authentik", "source": "voice"}).json()
|
||||
assert events_voice["decision"] == "approval_required"
|
||||
|
||||
|
||||
def test_authentik_inventory_classification_and_candidate(tmp_path, monkeypatch):
|
||||
_reset_db(tmp_path, monkeypatch)
|
||||
|
||||
from backend.app.main import app
|
||||
from backend.app.services.app_classifier import classify_container
|
||||
|
||||
result = classify_container({"name": "authentik-server", "image": "ghcr.io/goauthentik/server:latest", "ports": ["9000:9000"]})
|
||||
|
||||
assert result["app_type"] == "authentik"
|
||||
assert result["category"] == "auth"
|
||||
assert result["subcategory"] == "identity_provider"
|
||||
assert result["connector"] == "authentik"
|
||||
assert result["criticality"] == "critical"
|
||||
assert result["policy_profile"] == "auth_provider"
|
||||
assert result["protected"] is True
|
||||
|
||||
monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.test_connection", lambda self: {"status": "connected"}, raising=True)
|
||||
monkeypatch.setattr(
|
||||
"backend.app.connectors.portainer.PortainerConnector.list_containers",
|
||||
lambda self: {"status": "ok", "containers": [{"id": "auth-container", "name": "authentik-worker", "image": "ghcr.io/goauthentik/proxy", "state": "running", "status": "running", "ports": ["9000:9000"]}]},
|
||||
raising=True,
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/inventory/discover")
|
||||
candidates = client.get("/api/connectors/authentik/candidates").json()
|
||||
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0]["connectorId"] == "authentik"
|
||||
assert candidates[0]["appType"] == "authentik"
|
||||
assert candidates[0]["missingSecrets"] == ["AUTHENTIK_URL", "AUTHENTIK_TOKEN"]
|
||||
Reference in new issue
Block a user