diff --git a/Todo.md b/Todo.md index dae19fe2..1f1408cb 100644 --- a/Todo.md +++ b/Todo.md @@ -1,3 +1,4 @@ # Todos - Implement multi language -- Implement rbac administration interface \ No newline at end of file +- Implement rbac administration interface +- Implement ``MASK_CREDENTIALS_IN_LOGS`` for all sensible tasks \ No newline at end of file diff --git a/filter_plugins/get_url.py b/filter_plugins/get_url.py index a673b763..3322d4f6 100644 --- a/filter_plugins/get_url.py +++ b/filter_plugins/get_url.py @@ -1,27 +1,11 @@ #!/usr/bin/python -import os -import sys -from ansible.errors import AnsibleFilterError +import sys, os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from module_utils.get_url import get_url class FilterModule(object): + ''' Infinito.Nexus application config extraction filters ''' def filters(self): - return {'get_url': self.get_url} - - def get_url(self, domains, application_id, protocol): - # 1) module_util-Verzeichnis in den Pfad aufnehmen - plugin_dir = os.path.dirname(__file__) - project_root = os.path.dirname(plugin_dir) - module_utils = os.path.join(project_root, 'module_utils') - if module_utils not in sys.path: - sys.path.append(module_utils) - - # 2) jetzt domain_utils importieren - try: - from domain_utils import get_domain - except ImportError as e: - raise AnsibleFilterError(f"could not import domain_utils: {e}") - - # 3) Validierung und Aufruf - if not isinstance(protocol, str): - raise AnsibleFilterError("Protocol must be a string") - return f"{protocol}://{ get_domain(domains, application_id) }" + return { + 'get_url': get_url, + } diff --git a/group_vars/all/00_general.yml b/group_vars/all/00_general.yml index be2d3396..1f061c8d 100644 --- a/group_vars/all/00_general.yml +++ b/group_vars/all/00_general.yml @@ -1,5 +1,10 @@ INFINITO_ENVIRONMENT: "production" # Possible values: production, development +# If true, sensitive credentials will be masked or hidden from all Ansible task logs +# Recommendet to set to true +# @todo needs to be implemented everywhere +MASK_CREDENTIALS_IN_LOGS: true + HOST_CURRENCY: "EUR" HOST_TIMEZONE: "UTC" diff --git a/module_utils/get_url.py b/module_utils/get_url.py new file mode 100644 index 00000000..96cd320d --- /dev/null +++ b/module_utils/get_url.py @@ -0,0 +1,18 @@ +from ansible.errors import AnsibleFilterError +import sys, os + +def get_url(domains, application_id, protocol): + plugin_dir = os.path.dirname(__file__) + project_root = os.path.dirname(plugin_dir) + module_utils = os.path.join(project_root, 'module_utils') + if module_utils not in sys.path: + sys.path.append(module_utils) + + try: + from domain_utils import get_domain + except ImportError as e: + raise AnsibleFilterError(f"could not import domain_utils: {e}") + + if not isinstance(protocol, str): + raise AnsibleFilterError("Protocol must be a string") + return f"{protocol}://{ get_domain(domains, application_id) }" \ No newline at end of file diff --git a/roles/web-app-keycloak/config/main.yml b/roles/web-app-keycloak/config/main.yml index 74abe509..6d1f9faa 100644 --- a/roles/web-app-keycloak/config/main.yml +++ b/roles/web-app-keycloak/config/main.yml @@ -1,4 +1,4 @@ -import_realm: True # If True realm will be imported. If false skip. +import_realm: True # If True realm will be imported. If false skip. features: matomo: true css: true @@ -6,7 +6,12 @@ features: ldap: true central_database: true recaptcha: true - logout: true + + # Doesn't make sense to activate logout page for keycloak, because the logout page + # anyhow should be included via iframe in keycloak. + # The JS is also messing with the keycloak config fields + # @todo optimize the JS + logout: false server: csp: flags: diff --git a/roles/web-app-keycloak/filter_plugins/__init__.py b/roles/web-app-keycloak/filter_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/roles/web-app-keycloak/filter_plugins/redirect_uris.py b/roles/web-app-keycloak/filter_plugins/redirect_uris.py new file mode 100644 index 00000000..6b189a50 --- /dev/null +++ b/roles/web-app-keycloak/filter_plugins/redirect_uris.py @@ -0,0 +1,82 @@ +# roles/web-app-keycloak/filter_plugins/redirect_uris.py +from __future__ import annotations +import os, sys +from typing import Iterable, Sequence +from ansible.errors import AnsibleFilterError + +# --- Locate project root that contains `module_utils/` dynamically (up to 5 levels) --- +def _ensure_module_utils_on_path(): + here = os.path.dirname(__file__) + for depth in range(1, 6): + candidate = os.path.abspath(os.path.join(here, *(['..'] * depth))) + if os.path.isdir(os.path.join(candidate, 'module_utils')): + if candidate not in sys.path: + sys.path.insert(0, candidate) + return + # If not found, imports below will raise a clear error +_ensure_module_utils_on_path() + +# Import your existing helpers +from module_utils.config_utils import get_app_conf, AppConfigKeyError, ConfigEntryNotSetError +from module_utils.get_url import get_url # returns "://" + +def _stable_dedup(items: Sequence[str]) -> list[str]: + seen = set() + out: list[str] = [] + for x in items: + if x not in seen: + seen.add(x) + out.append(x) + return out + +def redirect_uris(domains: dict, + applications: dict, + web_protocol: str = "https", + wildcard: str = "/*", + features: Iterable[str] = ("features.oauth2", "features.oidc"), + dedup: bool = True) -> list[str]: + """ + Build redirect URIs using: + - get_app_conf(applications, app_id, dotted_key, default) for feature gating + - get_url(domains_subset, app_id, web_protocol) to form "://" + + For domain lists, we call get_url() once per domain by passing a minimal + per-app subset like {app_id: "example.org"} to preserve your original + 'one entry per domain' behavior. + """ + if not isinstance(domains, dict): + raise AnsibleFilterError("redirect_uris: 'domains' must be a dict mapping app_id -> domain or list of domains") + + uris: list[str] = [] + + for app_id, domain_value in domains.items(): + # Feature check via get_app_conf + try: + has_feature = any(bool(get_app_conf(applications, app_id, f, False)) for f in features) + except (AppConfigKeyError, ConfigEntryNotSetError): + has_feature = False + + if not has_feature: + continue + + # Normalize to iterable of domains + doms = [domain_value] if isinstance(domain_value, str) else list(domain_value or []) + + for d in doms: + # Use get_url() to produce "://" + # Pass a minimal per-app mapping so get_domain() resolves to 'd' + try: + url = get_url({app_id: d}, app_id, web_protocol) + except Exception as e: + raise AnsibleFilterError(f"redirect_uris: get_url failed for app '{app_id}' with domain '{d}': {e}") + uris.append(f"{url}{wildcard}") + + return _stable_dedup(uris) if dedup else uris + + +class FilterModule(object): + """Infinito.Nexus redirect URI builder (uses get_app_conf + get_url)""" + def filters(self): + return { + "redirect_uris": redirect_uris, + } diff --git a/roles/web-app-keycloak/tasks/02_update_client_redirects.yml b/roles/web-app-keycloak/tasks/02_update_client_redirects.yml new file mode 100644 index 00000000..ce9ef816 --- /dev/null +++ b/roles/web-app-keycloak/tasks/02_update_client_redirects.yml @@ -0,0 +1,130 @@ +--- +# Update redirectUris/webOrigins per kcadm.sh — no defaults used. + +# ── REQUIRED VARS (must be provided by caller) ─────────────────────────────── +# - WEB_PROTOCOL e.g. "https" +# - keycloak_realm target realm name +# - keycloak_server_host_url e.g. "http://127.0.0.1:8080" +# - keycloak_server_internal_url e.g. "http://127.0.0.1:8080" +# - keycloak_kcadm_path e.g. "docker exec -i keycloak /opt/keycloak/bin/kcadm.sh" +# - keycloak_master_api_user_name +# - keycloak_master_api_user_password +# - keycloak_client_id clientId to update (e.g. same as realm or an app client) +# - domains your domain map +# - applications your applications map + +- name: "Assert required variables are present (no defaults allowed)" + assert: + that: + - WEB_PROTOCOL is defined + - keycloak_realm is defined + - keycloak_server_host_url is defined + - keycloak_server_internal_url is defined + - keycloak_kcadm_path is defined + - keycloak_master_api_user_name is defined + - keycloak_master_api_user_password is defined + - keycloak_client_id is defined + - keycloak_redirect_features is defined + - domains is defined + - applications is defined + fail_msg: "Missing required variable(s). Provide all vars listed at the top of 10_update_client_redirects.yml." + +# 0) Wait & login +- name: "Wait until Keycloak is reachable at {{ keycloak_server_host_url }}" + uri: + url: "{{ keycloak_server_host_url }}/realms/master" + method: GET + status_code: 200 + validate_certs: false + register: kc_up + retries: 30 + delay: 5 + until: kc_up.status == 200 + +- name: "kcadm login" + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + shell: > + {{ keycloak_kcadm_path }} config credentials + --server {{ keycloak_server_internal_url }} + --realm master + --user {{ keycloak_master_api_user_name }} + --password {{ keycloak_master_api_user_password }} + changed_when: false + +# 1) Build desired sets (NO defaults) +- name: "Build desired redirect URIs from config via filter" + set_fact: + kc_redirect_uris: >- + {{ domains | redirect_uris(applications, WEB_PROTOCOL, '/*', keycloak_redirect_features, True) }} + +- name: Build desired web origins (scheme://host[:port]) + set_fact: + kc_web_origins: >- + {{ kc_redirect_uris + | map('regex_replace','/\\*$','') + | map('regex_search','^(https?://[^/]+)') + | select('string') + | list | unique }} + +#- name: "Build post.logout.redirect.uris value ('+' plus explicit URIs without /*)" +# set_fact: +# kc_desired_post_logout_uris: >- +# {{ (['+'] + (kc_redirect_uris | map('regex_replace','/\\*$','') | list)) | join('\n') }} + +# 2) Resolve client id (strictly by provided clientId, no fallback) +- name: "Resolve client internal id for {{ keycloak_client_id }}" + shell: > + {{ keycloak_kcadm_path }} get clients + -r {{ keycloak_realm }} + --query 'clientId={{ keycloak_client_id }}' + --fields id --format json | jq -r '.[0].id' + register: kc_client + changed_when: false + +- name: "Fail if client not found" + assert: + that: kc_client.stdout is match('^[0-9a-f-]+$') + fail_msg: "Client '{{ keycloak_client_id }}' not found in realm '{{ keycloak_realm }}'." + +# 3) Read current config (assume keys exist; we don't use defaults) +- name: "Read current client configuration" + shell: > + {{ keycloak_kcadm_path }} get clients/{{ kc_client.stdout }} + -r {{ keycloak_realm }} --format json + register: kc_client_obj + changed_when: false + +- name: "Normalize current vs desired for comparison" + set_fact: + kc_current_redirect_uris: "{{ (kc_client_obj.stdout | from_json).redirectUris | sort }}" + kc_current_web_origins: "{{ (kc_client_obj.stdout | from_json).webOrigins | sort }}" + kc_current_logout_uris: >- + {{ + ( + (kc_client_obj.stdout | from_json).attributes['post.logout.redirect.uris'] + if 'post.logout.redirect.uris' in (kc_client_obj.stdout | from_json).attributes + else '' + ) + | regex_replace('\r','') + | split('\n') + | reject('equalto','') + | list | sort + }} + kc_desired_redirect_uris: "{{ kc_redirect_uris | sort }}" + kc_desired_web_origins: "{{ kc_web_origins | sort }}" + kc_desired_post_logout_uris: "+" + kc_desired_post_logout_uris_list: >- + {{ "+" | split('\n') | reject('equalto','') | list | sort }} + +# 4) Update only when changed +- name: "Update redirectUris, webOrigins, post.logout.redirect.uris" + shell: > + {{ keycloak_kcadm_path }} update clients/{{ kc_client.stdout }} + -r {{ keycloak_realm }} + -s 'redirectUris={{ kc_redirect_uris | to_json }}' + -s 'webOrigins={{ kc_web_origins | to_json }}' + -s 'attributes."post.logout.redirect.uris"={{ kc_desired_post_logout_uris | to_json }}' + when: kc_current_redirect_uris != kc_desired_redirect_uris + or kc_current_web_origins != kc_desired_web_origins + or kc_current_logout_uris != kc_desired_post_logout_uris_list + diff --git a/roles/web-app-keycloak/tasks/update-ldap-bind.yml b/roles/web-app-keycloak/tasks/03_update-ldap-bind.yml similarity index 86% rename from roles/web-app-keycloak/tasks/update-ldap-bind.yml rename to roles/web-app-keycloak/tasks/03_update-ldap-bind.yml index 9873f2b0..a6234d81 100644 --- a/roles/web-app-keycloak/tasks/update-ldap-bind.yml +++ b/roles/web-app-keycloak/tasks/03_update-ldap-bind.yml @@ -16,8 +16,8 @@ {{ keycloak_kcadm_path }} config credentials \ --server {{ keycloak_server_internal_url }} \ --realm master \ - --user {{ keycloak_administrator_username }} \ - --password {{ keycloak_administrator_password }} + --user {{ keycloak_master_api_user_name }} \ + --password {{ keycloak_master_api_user_password }} - name: Retrieve LDAP component ID shell: | @@ -37,6 +37,6 @@ {{ keycloak_kcadm_path }} update components/{{ ldap_component.stdout }} \ -r {{ keycloak_realm }} \ -s 'config.bindCredential=["{{ new_bind_password }}"]' - no_log: true + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" register: update_bind changed_when: update_bind.rc == 0 diff --git a/roles/web-app-keycloak/tasks/attributes/ssh_public_key.yml b/roles/web-app-keycloak/tasks/04_ssh_public_key.yml similarity index 92% rename from roles/web-app-keycloak/tasks/attributes/ssh_public_key.yml rename to roles/web-app-keycloak/tasks/04_ssh_public_key.yml index ece1c97b..19c2d179 100644 --- a/roles/web-app-keycloak/tasks/attributes/ssh_public_key.yml +++ b/roles/web-app-keycloak/tasks/04_ssh_public_key.yml @@ -15,8 +15,8 @@ {{ keycloak_kcadm_path }} config credentials \ --server {{ keycloak_server_internal_url }} \ --realm master \ - --user {{ keycloak_administrator_username }} \ - --password {{ keycloak_administrator_password }} + --user {{ keycloak_master_api_user_name }} \ + --password {{ keycloak_master_api_user_password }} # LDAP Source - name: Get ID of LDAP storage provider @@ -66,8 +66,8 @@ {{ keycloak_kcadm_path }} config credentials \ --server {{ keycloak_server_internal_url }} \ --realm master \ - --user {{ keycloak_administrator_username }} \ - --password {{ keycloak_administrator_password }} + --user {{ keycloak_master_api_user_name }} \ + --password {{ keycloak_master_api_user_password }} - name: Render user-profile JSON for SSH key template: diff --git a/roles/web-app-keycloak/tasks/Todo.md b/roles/web-app-keycloak/tasks/Todo.md new file mode 100644 index 00000000..92bd2fc9 --- /dev/null +++ b/roles/web-app-keycloak/tasks/Todo.md @@ -0,0 +1,3 @@ +# Todos +- Include 03_update-ldap-bind.yml +- Include 04_ssh_public_key.yml \ No newline at end of file diff --git a/roles/web-app-keycloak/tasks/main.yml b/roles/web-app-keycloak/tasks/main.yml index ae44b53b..8f3effcb 100644 --- a/roles/web-app-keycloak/tasks/main.yml +++ b/roles/web-app-keycloak/tasks/main.yml @@ -1,11 +1,14 @@ --- -- name: "create import files for {{application_id}}" - include_tasks: 01_import.yml +#- name: "create import files for {{application_id}}" +# include_tasks: 01_import.yml +# +#- name: "load docker, db and proxy for {{application_id}}" +# include_role: +# name: cmp-db-docker-proxy -- name: "load docker, db and proxy for {{application_id}}" - include_role: - name: cmp-db-docker-proxy +- name: "Apply client redirects without realm import" + include_tasks: 02_update_client_redirects.yml # Deactivated temporary. Import now via realm.yml #- name: Implement SSH Public Key Attribut -# include_tasks: attributes/ssh_public_key.yml +# include_tasks: 03_ssh_public_key.yml \ No newline at end of file diff --git a/roles/web-app-keycloak/templates/import/realm.json.j2 b/roles/web-app-keycloak/templates/import/realm.json.j2 index d73acfa7..b962b5e1 100644 --- a/roles/web-app-keycloak/templates/import/realm.json.j2 +++ b/roles/web-app-keycloak/templates/import/realm.json.j2 @@ -833,20 +833,8 @@ "alwaysDisplayInConsole": false, "clientAuthenticatorType": "desktop-secret", "secret": "{{oidc.client.secret}}", - {%- set redirect_uris = [] %} - {%- for domain_application_id, domain in domains.items() %} - {%- if applications | get_app_conf(domain_application_id, 'features.oauth2', False) or applications | get_app_conf(domain_application_id, 'features.oidc', False) %} - {%- if domain is string %} - {%- set _ = redirect_uris.append(WEB_PROTOCOL ~ '://' ~ domain ~ '/*') %} - {%- else %} - {%- for d in domain %} - {%- set _ = redirect_uris.append(WEB_PROTOCOL ~ '://' ~ d ~ '/*') %} - {%- endfor %} - {%- endif %} - {%- endif %} - {%- endfor %} - - "redirectUris": {{ redirect_uris | tojson }}, + {# The following line should be covered by 02_update_client_redirects.yml #} + "redirectUris": {{ domains | redirect_uris(applications, WEB_PROTOCOL) | tojson }}, "webOrigins": [ "{{ WEB_PROTOCOL }}://*.{{primary_domain}}" ], diff --git a/roles/web-app-keycloak/vars/main.yml b/roles/web-app-keycloak/vars/main.yml index 36220777..aa8b02f2 100644 --- a/roles/web-app-keycloak/vars/main.yml +++ b/roles/web-app-keycloak/vars/main.yml @@ -6,9 +6,9 @@ database_type: "postgres" keycloak_container: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.name', True) }}" # Name of the keycloack docker container keycloak_docker_import_directory: "/opt/keycloak/data/import/" # Directory in which keycloack import files are placed in the running docker container keycloak_realm: "{{ primary_domain}}" # This is the name of the default realm which is used by the applications -keycloak_administrator: "{{ applications | get_app_conf(application_id, 'users.administrator', True) }}" # Master Administrator -keycloak_administrator_username: "{{ keycloak_administrator.username }}" # Master Administrator Username -keycloak_administrator_password: "{{ keycloak_administrator.password }}" # Master Administrator Password +keycloak_master_api_user: "{{ applications | get_app_conf(application_id, 'users.administrator', True) }}" # Master Administrator +keycloak_master_api_user_name: "{{ keycloak_master_api_user.username }}" # Master Administrator Username +keycloak_master_api_user_password: "{{ keycloak_master_api_user.password }}" # Master Administrator Password keycloak_kcadm_path: "docker exec -i {{ keycloak_container }} /opt/keycloak/bin/kcadm.sh" # Init script for keycloak keycloak_server_internal_url: "http://127.0.0.1:8080" keycloak_server_host: "127.0.0.1:{{ ports.localhost.http[application_id] }}" @@ -17,6 +17,8 @@ keycloak_image: "{{ applications | get_app_conf(application_id keycloak_version: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.version', True) }}" # Keyloak docker version keycloak_import_realm: "{{ applications | get_app_conf(application_id, 'import_realm', True, True) }}" # Activate realm import keycloak_debug_enabled: "{{ enable_debug }}" +keycloak_redirect_features: ["features.oauth2","features.oidc"] +keycloak_client_id: "{{ oidc.client.id }}" # Docker docker_compose_flush_handlers: true # Remember to copy realm import before flushg when set to true \ No newline at end of file diff --git a/tests/unit/roles/web-app-keycloak/__init__.py b/tests/unit/roles/web-app-keycloak/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/roles/web-app-keycloak/filter_plugins/__init__.py b/tests/unit/roles/web-app-keycloak/filter_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/roles/web-app-keycloak/filter_plugins/test_redirect_uris.py b/tests/unit/roles/web-app-keycloak/filter_plugins/test_redirect_uris.py new file mode 100644 index 00000000..5f59b451 --- /dev/null +++ b/tests/unit/roles/web-app-keycloak/filter_plugins/test_redirect_uris.py @@ -0,0 +1,159 @@ +# tests/unit/roles/web-app-keycloak/filter_plugins/test_redirect_uris.py +import os +import sys +import types +import unittest +import importlib.util + +PLUGIN_REL_PATH = os.path.join("roles", "web-app-keycloak", "filter_plugins", "redirect_uris.py") + + +def _find_repo_root_containing(rel_path, max_depth=8): + """Walk upwards from this test file to find the repo root that contains rel_path.""" + here = os.path.dirname(__file__) + cur = here + for _ in range(max_depth): + candidate = os.path.join(cur, rel_path) + if os.path.isfile(candidate): + return cur + parent = os.path.dirname(cur) + if parent == cur: + break + cur = parent + raise FileNotFoundError(f"Could not find {rel_path} upwards from {here}") + + +def _load_module_from_path(name, file_path): + spec = importlib.util.spec_from_file_location(name, file_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader, f"Cannot load spec for {file_path}" + spec.loader.exec_module(module) + return module + + +class RedirectUrisTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Create stub package: module_utils, with config_utils and get_url submodules. + mu = types.ModuleType("module_utils") + mu_config = types.ModuleType("module_utils.config_utils") + mu_geturl = types.ModuleType("module_utils.get_url") + + # Define stub exceptions + class AppConfigKeyError(Exception): + pass + + class ConfigEntryNotSetError(Exception): + pass + + # Define a practical get_app_conf that understands dotted keys + def get_app_conf(applications, app_id, dotted, default=None): + data = applications.get(app_id, {}) + cur = data + for part in dotted.split("."): + if isinstance(cur, dict) and part in cur: + cur = cur[part] + else: + return default + return cur + + # Define a simple get_url matching your module_utils/get_url contract + # get_url(domains, application_id, protocol) -> "://" + def get_url(domains, application_id, protocol): + domain = domains[application_id] + return f"{protocol}://{domain}" + + # Attach to stub modules + mu_config.get_app_conf = staticmethod(get_app_conf) + mu_config.AppConfigKeyError = AppConfigKeyError + mu_config.ConfigEntryNotSetError = ConfigEntryNotSetError + + mu_geturl.get_url = staticmethod(get_url) + + # Register in sys.modules so plugin imports succeed + sys.modules["module_utils"] = mu + sys.modules["module_utils.config_utils"] = mu_config + sys.modules["module_utils.get_url"] = mu_geturl + + # Load the plugin by path + repo_root = _find_repo_root_containing(PLUGIN_REL_PATH) + plugin_path = os.path.join(repo_root, PLUGIN_REL_PATH) + cls.plugin = _load_module_from_path("test_target.redirect_uris", plugin_path) + + # Keep originals for per-test monkeypatching + cls._orig_get_app_conf = cls.plugin.get_app_conf + cls._orig_get_url = cls.plugin.get_url + + def tearDown(self): + # Restore plugin functions if a test monkeypatched them + self.plugin.get_app_conf = self._orig_get_app_conf + self.plugin.get_url = self._orig_get_url + + def test_single_domain_oauth2_enabled(self): + domains = {"app1": "example.org"} + applications = {"app1": {"features": {"oauth2": True}}} + result = self.plugin.redirect_uris(domains, applications, web_protocol="https") + self.assertEqual(result, ["https://example.org/*"]) + + def test_multiple_domains_oidc_enabled(self): + domains = {"appX": ["a.example.org", "b.example.org"]} + applications = {"appX": {"features": {"oidc": True}}} + result = self.plugin.redirect_uris(domains, applications, web_protocol="https") + self.assertCountEqual(result, ["https://a.example.org/*", "https://b.example.org/*"]) + + def test_feature_missing_is_skipped(self): + domains = {"app1": "example.org"} + applications = {"app1": {"features": {"oauth2": False, "oidc": False}}} + result = self.plugin.redirect_uris(domains, applications) + self.assertEqual(result, []) + + def test_protocol_and_wildcard_customization(self): + domains = {"app1": "x.test"} + applications = {"app1": {"features": {"oauth2": True}}} + result = self.plugin.redirect_uris(domains, applications, web_protocol="http", wildcard="/cb") + self.assertEqual(result, ["http://x.test/cb"]) + + def test_dedup_default_true(self): + domains = {"app1": ["dup.test", "dup.test", "other.test"]} + applications = {"app1": {"features": {"oidc": True}}} + result = self.plugin.redirect_uris(domains, applications) + self.assertEqual(result, ["https://dup.test/*", "https://other.test/*"]) + + def test_dedup_false_keeps_duplicates(self): + domains = {"app1": ["dup.test", "dup.test"]} + applications = {"app1": {"features": {"oidc": True}}} + result = self.plugin.redirect_uris(domains, applications, dedup=False) + self.assertEqual(result, ["https://dup.test/*", "https://dup.test/*"]) + + def test_invalid_domains_type_raises(self): + with self.assertRaises(self.plugin.AnsibleFilterError): + self.plugin.redirect_uris(["not-a-dict"], {}) # type: ignore[arg-type] + + def test_get_url_failure_is_wrapped(self): + # Make get_url raise an arbitrary error; plugin should re-raise AnsibleFilterError + def boom(*args, **kwargs): + raise RuntimeError("boom") + self.plugin.get_url = boom + + domains = {"app1": "example.org"} + applications = {"app1": {"features": {"oauth2": True}}} + + with self.assertRaises(self.plugin.AnsibleFilterError) as ctx: + self.plugin.redirect_uris(domains, applications) + self.assertIn("get_url failed", str(ctx.exception)) + + def test_get_app_conf_exception_is_handled_as_no_feature(self): + # Make get_app_conf raise AppConfigKeyError; plugin should treat as not enabled and skip + def raising_get_app_conf(*args, **kwargs): + raise self.plugin.AppConfigKeyError("missing key") + self.plugin.get_app_conf = raising_get_app_conf + + domains = {"app1": "example.org"} + applications = {"app1": {"features": {"oauth2": True}}} # value won't be read due to exception + + result = self.plugin.redirect_uris(domains, applications) + self.assertEqual(result, []) + + +if __name__ == "__main__": + unittest.main()