diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 9066eaa4f5..16f61156d2 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -857,6 +857,8 @@ files: maintainers: fynncfchen $modules/keycloak_realm_key.py: maintainers: mattock + $modules/keycloak_realm_localization.py: + maintainers: danekja $modules/keycloak_role.py: maintainers: laurpaum $modules/keycloak_user.py: diff --git a/meta/runtime.yml b/meta/runtime.yml index 2efbf3ff89..bfb7dce831 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -40,6 +40,7 @@ action_groups: - keycloak_realm - keycloak_realm_key - keycloak_realm_keys_metadata_info + - keycloak_realm_localization - keycloak_realm_rolemapping - keycloak_role - keycloak_user diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 1482f9693b..194893dd52 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -25,6 +25,9 @@ URL_REALMS = "{url}/admin/realms" URL_REALM = "{url}/admin/realms/{realm}" URL_REALM_KEYS_METADATA = "{url}/admin/realms/{realm}/keys" +URL_LOCALIZATIONS = "{url}/admin/realms/{realm}/localization/{locale}" +URL_LOCALIZATION = "{url}/admin/realms/{realm}/localization/{locale}/{key}" + URL_TOKEN = "{url}/realms/{realm}/protocol/openid-connect/token" URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}" URL_CLIENTS = "{url}/admin/realms/{realm}/clients" @@ -386,7 +389,7 @@ class KeycloakAPI: self.restheaders = connection_header self.http_agent = self.module.params.get("http_agent") - def _request(self, url: str, method: str, data: str | bytes | None = None): + def _request(self, url: str, method: str, data: str | bytes | None = None, headers=None): """Makes a request to Keycloak and returns the raw response. If a 401 is returned, attempts to re-authenticate using first the module's refresh_token (if provided) @@ -397,17 +400,18 @@ class KeycloakAPI: :param url: request path :param method: request method (e.g., 'GET', 'POST', etc.) :param data: (optional) data for request + :param headers headers to be sent with request, defaults to self.restheaders :return: raw API response """ - def make_request_catching_401() -> object | HTTPError: + def make_request_catching_401(headers) -> object | HTTPError: try: return open_url( url, method=method, data=data, http_agent=self.http_agent, - headers=self.restheaders, + headers=headers, timeout=self.connection_timeout, validate_certs=self.validate_certs, ) @@ -416,7 +420,10 @@ class KeycloakAPI: raise e return e - r = make_request_catching_401() + if headers is None: + headers = self.restheaders + + r = make_request_catching_401(headers) if isinstance(r, Exception): # Try to refresh token and retry, if available @@ -590,6 +597,80 @@ class KeycloakAPI: except Exception as e: self.fail_request(e, msg=f"Could not delete realm {realm}: {e}", exception=traceback.format_exc()) + def get_localization_values(self, locale: str, realm: str = "master") -> dict[str, str]: + """ + Get all localization overrides for a given realm and locale. + + :param locale: Locale code (for example, 'en', 'fi', 'de'). + :param realm: Realm name. Defaults to 'master'. + + :return: Mapping of localization keys to override values. + + :raise KeycloakError: Wrapped HTTP/JSON error with context + """ + realm_url = URL_LOCALIZATIONS.format(url=self.baseurl, realm=realm, locale=locale) + + try: + return self._request_and_deserialize(realm_url, method="GET") + except Exception as e: + self.fail_request( + e, + msg="Could not read localization overrides for realm %s, locale %s: %s" % (realm, locale, str(e)), + exception=traceback.format_exc(), + ) + + def set_localization_value(self, locale: str, key: str, value: str, realm: str = "master"): + """ + Create or update a single localization override for the given key. + + :param locale: Locale code (for example, 'en'). + :param key: Localization message key to set. + :param value: Override value to set. + :param realm: Realm name. Defaults to 'master'. + + :return: HTTPResponse: Response object on success. + + :raise KeycloakError: Wrapped HTTP error with context + """ + realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) + + headers = self.restheaders.copy() + headers["Content-Type"] = "text/plain; charset=utf-8" + + try: + return self._request(realm_url, method="PUT", data=to_native(value), headers=headers) + except Exception as e: + self.fail_request( + e, + msg="Could not set localization value in realm %s, locale %s: %s=%s: %s" + % (realm, locale, key, value, str(e)), + exception=traceback.format_exc(), + ) + + def delete_localization_value(self, locale: str, key: str, realm: str = "master"): + """ + Delete a single localization override key for the given locale. + + :param locale: Locale code (for example, 'en'). + :param key: Localization message key to delete. + :param realm: Realm name. Defaults to 'master'. + + :return: HTTPResponse: Response object on success. + + :raise KeycloakError: Wrapped HTTP error with context + """ + realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) + + try: + return self._request(realm_url, method="DELETE") + except Exception as e: + self.fail_request( + e, + msg="Could not delete localization value in realm %s, locale %s, key %s: %s" + % (realm, locale, key, str(e)), + exception=traceback.format_exc(), + ) + def get_clients(self, realm: str = "master", filter=None): """Obtains client representations for clients in a realm diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py new file mode 100644 index 0000000000..09be06d2d5 --- /dev/null +++ b/plugins/modules/keycloak_realm_localization.py @@ -0,0 +1,399 @@ +# Python +# !/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright Jakub Danek +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +module: keycloak_realm_localization + +short_description: Manage Keycloak realm localization overrides via the Keycloak API + +version_added: 11.4.0 + +description: + - Manage per-locale message overrides for a Keycloak realm using the Keycloak Admin REST API. + - Requires access via OpenID Connect; the connecting user/client must have sufficient privileges. + - The names of module options are snake_cased versions of the names found in the Keycloak API. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + append: + description: + - If V(true), only the keys listed in the O(overrides) will be modified by this module. Any other pre-existing + keys will be ignored. + - If V(false), all locale overrides will be made to match configuration of this module. I.e. any keys + missing from the O(overrides) will be removed regardless of O(state) value. + type: bool + default: true + locale: + description: + - Locale code for which the overrides apply (for example, V(en), V(fi), V(de)). + type: str + required: true + parent_id: + description: + - Name of the realm that owns the locale overrides. + type: str + required: true + state: + description: + - Desired state of localization overrides for the given locale. + - On V(present), the set of overrides for the locale will be made to match O(overrides). + If O(append) is V(false) keys not listed in O(overrides) will be removed, + and the listed keys will be created or updated. + If O(append) is V(true) keys not listed in O(overrides) will be ignored, + and the listed keys will be created or updated. + - On V(absent), overrides for the locale will be removed. If O(append) is V(false), all keys will be removed. + If O(append) is V(true), only the keys listed in O(overrides) will be removed. + type: str + choices: ['present', 'absent'] + default: present + overrides: + description: + - List of overrides to ensure for the locale when O(state=present). Each item is a mapping with + the record's O(overrides[].key) and its O(overrides[].value). + - Ignored when O(state=absent). + type: list + elements: dict + default: [] + suboptions: + key: + description: + - The message key to override. + type: str + required: true + value: + description: + - The override value for the message key. If omitted, value will be set to an empty string. + type: str + default: "" + required: false + +seealso: + - module: community.general.keycloak_realm + description: You can specify list of supported locales using O(community.general.keycloak_realm#module:supported_locales). + +extends_documentation_fragment: + - community.general.keycloak + - community.general.keycloak.actiongroup_keycloak + - community.general.attributes + +author: Jakub Danek (@danekja) +""" + +EXAMPLES = r""" +- name: Replace all overrides for locale "en" (credentials auth) + community.general.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: en + state: present + append: false + overrides: + - key: greeting + value: "Hello" + - key: farewell + value: "Bye" + delegate_to: localhost + +- name: Ensure only one override exists for locale "fi" (token auth) + community.general.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + parent_id: my-realm + locale: fi + state: present + append: false + overrides: + - key: app.title + value: "Sovellukseni" + delegate_to: localhost + +- name: Remove all overrides for locale "de" + community.general.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: de + state: absent + append: false + delegate_to: localhost + +- name: Dry run - see what would change for locale "en" + community.general.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: en + state: present + overrides: + - key: greeting + value: "Hello again" + check_mode: true + delegate_to: localhost +""" + +RETURN = r""" +end_state: + description: + - Final state of localization overrides for the locale after module execution. + - Contains the O(locale) and the list of O(overrides) as key/value items. + returned: on success + type: dict + contains: + locale: + description: The locale code affected. + type: str + sample: en + overrides: + description: The list of overrides that exist after execution. + type: list + elements: dict + sample: + - key: greeting + value: Hello + - key: farewell + value: Bye +""" + +from copy import deepcopy + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def _normalize_overrides_from_api(current): + """ + Accepts: + - dict: {'k1': 'v1', ...} + Return a sorted list of {'key', 'value'}. + + This helper provides a consistent shape for downstream comparison/diff logic. + """ + if not current: + return [] + + # Convert mapping to list of key/value dicts + items = [{"key": k, "value": v} for k, v in sorted(current.items())] + + # Sort for stable comparisons and diff output + return items + + +def main(): + """ + Module execution + + :return: + """ + # Base Keycloak auth/spec fragment common across Keycloak modules + argument_spec = keycloak_argument_spec() + + # Describe a single override record + overrides_spec = dict( + key=dict(type="str", no_log=False, required=True), + value=dict(type="str", default=""), + ) + + # Module-specific arguments + meta_args = dict( + locale=dict(type="str", required=True), + parent_id=dict(type="str", required=True), + state=dict(type="str", default="present", choices=["present", "absent"]), + overrides=dict(type="list", elements="dict", options=overrides_spec, default=[]), + append=dict(type="bool", default=True), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + # Require token OR full credential set. This mirrors other Keycloak modules. + required_one_of=([["token", "auth_realm", "auth_username", "auth_password"]]), + required_together=([["auth_realm", "auth_username", "auth_password"]]), + ) + + # Initialize the result object used by Ansible + result = dict(changed=False, msg="", end_state={}, diff=dict(before={}, after={})) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + # Convenience locals for frequently used parameters + locale = module.params.get("locale") + state = module.params.get("state") + parent_id = module.params.get("parent_id") + append = module.params.get("append") + + desired_raw = module.params.get("overrides") or [] + desired_map = {r["key"]: r.get("value") for r in desired_raw} + desired_overrides = [{"key": k, "value": v} for k, v in sorted(desired_map.items())] + + # Fetch current overrides and normalize to comparable structure + old_overrides = _normalize_overrides_from_api(kc.get_localization_values(locale, parent_id) or {}) + before = { + "locale": locale, + "overrides": deepcopy(old_overrides), + } + + # Proposed state used for diff reporting + changeset = { + "locale": locale, + "overrides": [], + } + + # Default to no change; flip to True when updates/deletes are needed + result["changed"] = False + + if state == "present": + changeset["overrides"] = deepcopy(desired_overrides) + + # Compute two sets: + # - to_update: keys missing or with different values + # - to_remove: keys existing in current state but not in desired + to_update = [] + to_remove = deepcopy(old_overrides) + + # Mark updates and remove matched ones from to_remove + for record in desired_overrides: + override_found = False + + for override in to_remove: + if override["key"] == record["key"]: + override_found = True + + # Value differs -> update needed + if override["value"] != record["value"]: + result["changed"] = True + to_update.append(record) + + # Remove processed item so what's left in to_remove are deletions + to_remove.remove(override) + break + + if not override_found: + # New key, must be created + to_update.append(record) + result["changed"] = True + + # ignore any left-overs in to_remove, append is true + if append: + changeset["overrides"].extend(to_remove) + to_remove = [] + + # Any leftovers in to_remove must be deleted + if to_remove: + result["changed"] = True + + if result["changed"]: + if module._diff: + result["diff"] = dict(before=before, after=changeset) + + if module.check_mode: + # Dry-run: report intent without side effects + result["msg"] = "Locale %s overrides would be updated." % (locale) + + else: + for override in to_remove: + kc.delete_localization_value(locale, override["key"], parent_id) + + for override in to_update: + kc.set_localization_value(locale, override["key"], override["value"], parent_id) + + result["msg"] = "Locale %s overrides have been updated." % (locale) + + else: + result["msg"] = "Locale %s overrides are in sync." % (locale) + + # For accurate end_state, read back from API unless we are in check_mode + if not module.check_mode: + final_overrides = _normalize_overrides_from_api(kc.get_localization_values(locale, parent_id) or {}) + + else: + final_overrides = ["overrides"] + + result["end_state"] = {"locale": locale, "overrides": final_overrides} + + elif state == "absent": + # touch only overrides listed in parameters, leave the rest be + if append: + to_remove = deepcopy(desired_overrides) + to_keep = deepcopy(old_overrides) + + for override in to_remove: + found = False + for keep in to_keep: + if override["key"] == keep["key"]: + to_keep.remove(keep) + found = True + break + + # not present + if not found: + to_remove.remove(override) + + changeset["overrides"] = to_keep + + else: + to_remove = old_overrides + + if to_remove: + result["changed"] = True + + if module._diff: + result["diff"] = dict(before=before, after=changeset) + + if module.check_mode: + if result["changed"]: + result["msg"] = "Overrides for locale %s would be deleted." % (locale) + else: + result["msg"] = "No overrides for locale %s to be deleted." % (locale) + + else: + for override in to_remove: + kc.delete_localization_value(locale, override["key"], parent_id) + + result["msg"] = "Locale %s has no overrides." % (locale) + + result["end_state"] = changeset + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/plugins/modules/test_keycloak_realm_localization.py b/tests/unit/plugins/modules/test_keycloak_realm_localization.py new file mode 100644 index 0000000000..249a1a56e2 --- /dev/null +++ b/tests/unit/plugins/modules/test_keycloak_realm_localization.py @@ -0,0 +1,354 @@ +# Python +# -*- coding: utf-8 -*- + +# Copyright Jakub Danek +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from contextlib import contextmanager +from io import StringIO +from itertools import count + +from ansible_collections.community.internal_test_tools.tests.unit.compat import unittest +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch +from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + ModuleTestCase, + set_module_args, +) + +from ansible_collections.community.general.plugins.modules import keycloak_realm_localization + + +@contextmanager +def patch_keycloak_api(get_localization_values=None, set_localization_value=None, delete_localization_value=None): + """ + Patch KeycloakAPI methods used by the module under test. + """ + obj = keycloak_realm_localization.KeycloakAPI + with patch.object(obj, "get_localization_values", side_effect=get_localization_values) as mock_get_values: + with patch.object(obj, "set_localization_value", side_effect=set_localization_value) as mock_set_value: + with patch.object( + obj, "delete_localization_value", side_effect=delete_localization_value + ) as mock_del_value: + yield mock_get_values, mock_set_value, mock_del_value + + +def get_response(object_with_future_response, method, get_id_call_count): + if callable(object_with_future_response): + return object_with_future_response() + if isinstance(object_with_future_response, dict): + return get_response(object_with_future_response[method], method, get_id_call_count) + if isinstance(object_with_future_response, list): + call_number = next(get_id_call_count) + return get_response(object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +def build_mocked_request(get_id_user_count, response_dict): + def _mocked_requests(*args, **kwargs): + url = args[0] + method = kwargs["method"] + future_response = response_dict.get(url, None) + return get_response(future_response, method, get_id_user_count) + + return _mocked_requests + + +def create_wrapper(text_as_string): + """Allow to mock many times a call to one address. + Without this function, the StringIO is empty for the second call. + """ + + def _create_wrapper(): + return StringIO(text_as_string) + + return _create_wrapper + + +def mock_good_connection(): + token_response = { + "http://keycloak.url/auth/realms/master/protocol/openid-connect/token": create_wrapper( + '{"access_token": "alongtoken"}' + ), + } + return patch( + "ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak.open_url", + side_effect=build_mocked_request(count(), token_response), + autospec=True, + ) + + +class TestKeycloakRealmLocalization(ModuleTestCase): + def setUp(self): + super(TestKeycloakRealmLocalization, self).setUp() + self.module = keycloak_realm_localization + + def test_present_no_change_in_sync(self): + """Desired overrides already match, no change.""" + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "present", + "overrides": [ + {"key": "greeting", "value": "Hello"}, + {"key": "farewell", "value": "Bye"}, + ], + } + # get_localization_values is called twice: before and after + return_value_get_localization_values = [ + {"greeting": "Hello", "farewell": "Bye"}, + {"greeting": "Hello", "farewell": "Bye"}, + ] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api(get_localization_values=return_value_get_localization_values) as ( + mock_get_values, + mock_set_value, + mock_del_value, + ): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 2) + self.assertEqual(mock_set_value.call_count, 0) + self.assertEqual(mock_del_value.call_count, 0) + self.assertIs(exec_info.exception.args[0]["changed"], False) + + def test_present_check_mode_only_reports(self): + """Check mode: report changes, do not call API mutators.""" + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "present", + "overrides": [ + {"key": "x", "value": "1"}, # change + {"key": "y", "value": "2"}, # create + ], + "_ansible_check_mode": True, # signal for readers; set_module_args is what matters + } + return_value_get_localization_values = [ + {"x": "0"}, + ] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api(get_localization_values=return_value_get_localization_values) as ( + mock_get_values, + mock_set_value, + mock_del_value, + ): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + # Only read current values + self.assertEqual(mock_get_values.call_count, 1) + self.assertEqual(mock_set_value.call_count, 0) + self.assertEqual(mock_del_value.call_count, 0) + self.assertIs(exec_info.exception.args[0]["changed"], True) + self.assertIn("would be updated", exec_info.exception.args[0]["msg"]) + + def test_absent_idempotent_when_nothing_to_delete(self): + """No change when locale has no overrides.""" + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "absent", + } + return_value_get_localization_values = [ + {}, + ] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api(get_localization_values=return_value_get_localization_values) as ( + mock_get_values, + mock_set_value, + mock_del_value, + ): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 1) + self.assertEqual(mock_del_value.call_count, 0) + self.assertEqual(mock_set_value.call_count, 0) + self.assertIs(exec_info.exception.args[0]["changed"], False) + + def test_present_missing_value_validation(self): + """Validation error when state=present and value is missing.""" + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "present", + "overrides": [ + {"key": "greeting"}, + ], + } + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api() as (_mock_get_values, _mock_set_value, _mock_del_value): + with self.assertRaises(AnsibleFailJson) as exec_info: + self.module.main() + + self.assertIn("missing required arguments: value", exec_info.exception.args[0]["msg"]) + + def test_present_append_true_preserves_unspecified_keys(self): + """With append=True, only modify specified keys, preserve others.""" + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "present", + "append": True, + "overrides": [ + {"key": "a", "value": "1-updated"}, # update existing + {"key": "c", "value": "3"}, # create new + ], + } + # Before: a=1, b=2; After: a=1-updated, b=2, c=3 (b is preserved) + return_value_get_localization_values = [ + {"a": "1", "b": "2"}, + {"a": "1-updated", "b": "2", "c": "3"}, + ] + return_value_set = [None, None] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api( + get_localization_values=return_value_get_localization_values, + set_localization_value=return_value_set, + ) as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 2) + # No deletes - key 'b' should be preserved + self.assertEqual(mock_del_value.call_count, 0) + # Two set calls: update 'a', create 'c' + self.assertEqual(mock_set_value.call_count, 2) + self.assertIs(exec_info.exception.args[0]["changed"], True) + + def test_present_append_false_removes_unspecified_keys(self): + """With append=False, create new, update existing, and delete unspecified keys.""" + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "present", + "append": False, + "overrides": [ + {"key": "a", "value": "1-updated"}, # update + {"key": "c", "value": "3"}, # create + ], + } + # Before: a=1, b=2; After: a=1-updated, c=3 (b is removed) + return_value_get_localization_values = [ + {"a": "1", "b": "2"}, + {"a": "1-updated", "c": "3"}, + ] + return_value_set = [None, None] + return_value_delete = [None] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api( + get_localization_values=return_value_get_localization_values, + set_localization_value=return_value_set, + delete_localization_value=return_value_delete, + ) as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 2) + # One delete for 'b' + self.assertEqual(mock_del_value.call_count, 1) + # Two set calls: update 'a', create 'c' + self.assertEqual(mock_set_value.call_count, 2) + self.assertIs(exec_info.exception.args[0]["changed"], True) + + def test_absent_append_true_removes_only_specified_keys(self): + """With state=absent and append=True, remove only specified keys.""" + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "absent", + "append": True, + "overrides": [ + {"key": "a"}, + ], + } + # Before: a=1, b=2; Remove only 'a', keep 'b' + return_value_get_localization_values = [ + {"a": "1", "b": "2"}, + ] + return_value_delete = [None] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api( + get_localization_values=return_value_get_localization_values, + delete_localization_value=return_value_delete, + ) as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 1) + # One delete for 'a' only + self.assertEqual(mock_del_value.call_count, 1) + self.assertEqual(mock_set_value.call_count, 0) + self.assertIs(exec_info.exception.args[0]["changed"], True) + + def test_absent_append_false_removes_all_keys(self): + """With state=absent and append=False, remove all keys.""" + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "absent", + "append": False, + } + # Before: a=1, b=2; Remove all + return_value_get_localization_values = [ + {"a": "1", "b": "2"}, + ] + return_value_delete = [None, None] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api( + get_localization_values=return_value_get_localization_values, + delete_localization_value=return_value_delete, + ) as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 1) + # Two deletes for 'a' and 'b' + self.assertEqual(mock_del_value.call_count, 2) + self.assertEqual(mock_set_value.call_count, 0) + self.assertIs(exec_info.exception.args[0]["changed"], True) + + +if __name__ == "__main__": + unittest.main()