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

781 lines
33 KiB
Python

from pathlib import Path
import pytest
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"] = True
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()
_set_deployment_identity_template()
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 _set_deployment_identity_template():
from backend.app.core.config import settings
settings.apis.update({
'AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING': 'openid-id',
'AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING': 'email-id',
'AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING': 'profile-id',
'AUTHENTIK_OIDC_SIGNING_KEY_ID': 'rsa-key-id',
})
@pytest.mark.parametrize("failure", [None, "flow", "flow_missing", "flow_duplicate", "mapping", "scope", "signing", "unreachable", "openid_roles", "email_roles", "profile_roles"])
def test_enrollment_template_verifies_remote_flows_and_filtered_role_claim(monkeypatch, failure):
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.core.config import settings
previous = _set_authentik_config()
_set_deployment_identity_template()
settings.apis.update({
'AUTHENTIK_OIDC_AUTHORIZATION_FLOW': 'authorization-id',
'AUTHENTIK_OIDC_INVALIDATION_FLOW': 'invalidation-id',
'AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING': 'roles-id',
'AUTHENTIK_OIDC_ROLE_CLAIM': 'roles',
})
expression = 'return {"roles": sorted({group.name for group in request.user.ak_groups.all() if group.name in ["Administrator", "ReadOnly", "SupportAgent"]})}'
def request(self, path, params=None):
if failure == 'unreachable':
raise RuntimeError('SECRET_SENTINEL')
for scope in ('openid', 'email', 'profile'):
if path == f'/propertymappings/provider/scope/{scope}-id/':
expressions = {
'openid': '# This scope is required by the OpenID-spec, and must as such exist in authentik.\n# The scope by itself does not grant any information\nreturn {}',
'email': 'return {\n "email": request.user.email,\n "email_verified": False\n}',
'profile': 'return delete_none_values({\n "name": request.user.name,\n "preferred_username": request.user.username,\n})',
}
return {
'pk': f'{scope}-id', 'scope_name': 'wrong' if failure == 'scope' else scope,
'managed': f'goauthentik.io/providers/oauth2/scope-{scope}' if scope != 'profile' else None,
'expression': 'return {"roles": ["Administrator"]}' if failure == f'{scope}_roles' else expressions[scope],
}
if path.startswith('/flows/instances/'):
raise RuntimeError('HTTP 404: flow detail requires slug, not UUID')
return {
'/propertymappings/provider/scope/roles-id/': {
'pk': 'roles-id', 'scope_name': 'roles',
'expression': 'return {"roles": ["Administrator"]}' if failure == 'mapping' else expression,
},
}[path]
monkeypatch.setattr(AuthentikConnector, '_request', request)
def paged(self, path, params=None):
if path == '/flows/instances/':
authorization = {'pk': 'authorization-id', 'slug': 'default-provider-authorization-implicit-consent', 'designation': 'authentication' if failure == 'flow' else 'authorization'}
flows = [{'pk': 'invalidation-id', 'slug': 'default-provider-invalidation-flow', 'designation': 'invalidation'}]
if failure != 'flow_missing':
flows.append(authorization)
if failure == 'flow_duplicate':
flows.append(authorization)
return flows
assert path == '/crypto/certificatekeypairs/'
return [{'pk': 'rsa-key-id', 'key_type': 'ec' if failure == 'signing' else 'rsa', 'private_key_available': True}]
monkeypatch.setattr(AuthentikConnector, '_paged', paged)
try:
result = AuthentikConnector().validate_deployment_oidc_template(
role_claim='roles', role_groups=('Administrator', 'SupportAgent', 'ReadOnly'),
)
assert (result['status'] == 'ready') is (failure is None)
assert 'SECRET_SENTINEL' not in str(result)
assert 'expression' not in result
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()
_set_deployment_identity_template()
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 writes[0][2]['redirect_uris'] == [{'matching_mode': 'strict', 'url': 'https://support.itworx.test/signin-oidc'}]
assert writes[0][2]['signing_key'] == 'rsa-key-id'
assert set(writes[0][2]['property_mappings']) == {'openid-id', 'email-id', 'profile-id', 'mapping-groups'}
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()
_set_deployment_identity_template()
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)
@pytest.mark.parametrize('damage', [None, 'client', 'regex', 'extra_mapping', 'signing_key', 'issuer'])
def test_typed_oidc_existing_application_requires_matching_provider_contract(monkeypatch, damage):
from backend.app.connectors.authentik import AuthentikConnector
from backend.app.core.config import settings
previous = _set_authentik_config()
_set_deployment_identity_template()
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'
provider = {
'pk': 'provider-1', 'client_id': 'wsbc-portal', 'client_type': 'confidential',
'redirect_uris': [{'matching_mode': 'strict', 'url': 'https://support.itworx.test/signin-oidc'}],
'property_mappings': ['openid-id', 'email-id', 'profile-id', 'mapping-groups'],
'authorization_flow': 'flow-default-auth', 'invalidation_flow': 'flow-default-invalidation',
'signing_key': 'rsa-key-id', 'issuer_mode': 'per_provider',
}
if damage == 'client':
provider['client_id'] = 'unexpected-client'
elif damage == 'regex':
provider['redirect_uris'][0]['matching_mode'] = 'regex'
elif damage == 'extra_mapping':
provider['property_mappings'].append('other-roles')
elif damage == 'signing_key':
provider['signing_key'] = None
elif damage == 'issuer':
provider['issuer_mode'] = 'global'
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/': [provider],
}[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': True,
'status': 'already_managed',
'message': 'oidc_application_already_matches_typed_plan',
'applicationId': 'application-1',
'clientSecretCreated': False,
} if damage is None else {
'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"]