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"]