1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2026-02-04 07:51:50 +00:00
This commit is contained in:
Jakub Danek 2026-02-03 16:31:05 +00:00 committed by GitHub
commit fe7ea9174c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 841 additions and 4 deletions

2
.github/BOTMETA.yml vendored
View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,399 @@
# Python
# !/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright Jakub Danek <danek.ja@gmail.com>
# 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()

View file

@ -0,0 +1,354 @@
# Python
# -*- coding: utf-8 -*-
# Copyright Jakub Danek <danek.ja@gmail.com>
# 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()