Repository: ansible-collections/community.general

Issue:
keycloak_client detects changes on check_mode regarding `redirectUris`, `attributes`, and `protocol_mappers`
### Summary

When I have a task that creates a keycloak client:

```
  community.general.keycloak_client: 
    # .. auth_args
    realm: "{{ realm }}"
    client_id: "{{ client_id }}"
    state: present
    redirect_uris: '{{redirect_uris1}}'
    attributes: '{{client_attributes1}}'
    protocol_mappers: '{{protocol_mappers1}}'
```

When I run the step once, it creates the client.
When I run the step a second time, it determines there are not changes, but when I run it in `--check` mode it determine there are.



### Issue Type

Bug Report

### Component Name

keycloak_client

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.11.0] 
  config file = None
  configured module search path = ['/home/pmdumuid/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /home/pmdumuid/.local/lib/python3.9/site-packages/ansible
  ansible collection location = /home/pmdumuid/.ansible/collections:/usr/share/ansible/collections
  executable location = /home/pmdumuid/.local/bin/ansible
  python version = 3.9.7 (default, Oct 10 2021, 15:13:22) [GCC 11.1.0]
  jinja version = 3.0.2
  libyaml = True


```


### Community.general Version

```console (paste below)
$ ansible-galaxy collection list community.general

```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed

```


### OS / Environment

_No response_

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)

```


### Expected Results

As per in the description above.

### Actual Results

```console (paste below)

```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:

Resolving patch:
diff --git a/plugins/modules/identity/keycloak/keycloak_client.py b/plugins/modules/identity/keycloak/keycloak_client.py
--- a/plugins/modules/identity/keycloak/keycloak_client.py
+++ b/plugins/modules/identity/keycloak/keycloak_client.py
@@ -685,6 +685,36 @@
 from ansible.module_utils.basic import AnsibleModule
 
 
+def normalise_cr(clientrep, remove_ids=False):
+    """ Re-sorts any properties where the order so that diff's is minimised, and adds default values where appropriate so that the
+    the change detection is more effective.
+
+    :param clientrep: the clientrep dict to be sanitized
+    :param remove_ids: If set to true, then the unique ID's of objects is removed to make the diff and checks for changed
+                       not alert when the ID's of objects are not usually known, (e.g. for protocol_mappers)
+    :return: normalised clientrep dict
+    """
+    # Avoid the dict passed in to be modified
+    clientrep = clientrep.copy()
+
+    if 'attributes' in clientrep:
+        clientrep['attributes'] = list(sorted(clientrep['attributes']))
+
+    if 'redirectUris' in clientrep:
+        clientrep['redirectUris'] = list(sorted(clientrep['redirectUris']))
+
+    if 'protocolMappers' in clientrep:
+        clientrep['protocolMappers'] = sorted(clientrep['protocolMappers'], key=lambda x: (x.get('name'), x.get('protocol'), x.get('protocolMapper')))
+        for mapper in clientrep['protocolMappers']:
+            if remove_ids:
+                mapper.pop('id', None)
+
+            # Set to a default value.
+            mapper['consentRequired'] = mapper.get('consentRequired', False)
+
+    return clientrep
+
+
 def sanitize_cr(clientrep):
     """ Removes probably sensitive details from a client representation.
 
@@ -697,7 +727,7 @@ def sanitize_cr(clientrep):
     if 'attributes' in result:
         if 'saml.signing.private.key' in result['attributes']:
             result['attributes']['saml.signing.private.key'] = 'no_log'
-    return result
+    return normalise_cr(result)
 
 
 def main():
@@ -865,10 +895,12 @@ def main():
 
             if module.check_mode:
                 # We can only compare the current client with the proposed updates we have
+                before_norm = normalise_cr(before_client, remove_ids=True)
+                desired_norm = normalise_cr(desired_client, remove_ids=True)
                 if module._diff:
-                    result['diff'] = dict(before=sanitize_cr(before_client),
-                                          after=sanitize_cr(desired_client))
-                result['changed'] = (before_client != desired_client)
+                    result['diff'] = dict(before=sanitize_cr(before_norm),
+                                          after=sanitize_cr(desired_norm))
+                result['changed'] = (before_norm != desired_norm)
 
                 module.exit_json(**result)
 


Test patch:
diff --git a/tests/integration/targets/keycloak_client/README.md b/tests/integration/targets/keycloak_client/README.md
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/keycloak_client/README.md
@@ -0,0 +1,11 @@
+The integration test can be performed as follows:
+
+```
+# 1. Start docker-compose:
+docker-compose -f tests/integration/targets/keycloak_client/docker-compose.yml stop
+docker-compose -f tests/integration/targets/keycloak_client/docker-compose.yml rm -f -v
+docker-compose -f tests/integration/targets/keycloak_client/docker-compose.yml up -d
+
+# 2. Run the integration tests:
+ansible-test integration keycloak_client --allow-unsupported -v
+```
diff --git a/tests/integration/targets/keycloak_client/docker-compose.yml b/tests/integration/targets/keycloak_client/docker-compose.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/keycloak_client/docker-compose.yml
@@ -0,0 +1,26 @@
+version: '3.4'
+
+services:
+  postgres:
+    image: postgres:9.6
+    restart: always
+    environment:
+      POSTGRES_USER: postgres
+      POSTGRES_DB: postgres
+      POSTGRES_PASSWORD: postgres
+
+  keycloak:
+    image: jboss/keycloak:12.0.4
+    ports:
+      - 8080:8080
+
+    environment:
+      DB_VENDOR: postgres
+      DB_ADDR: postgres
+      DB_DATABASE: postgres
+      DB_USER: postgres
+      DB_SCHEMA: public
+      DB_PASSWORD: postgres
+
+      KEYCLOAK_USER: admin
+      KEYCLOAK_PASSWORD: password
diff --git a/tests/integration/targets/keycloak_client/tasks/main.yml b/tests/integration/targets/keycloak_client/tasks/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/keycloak_client/tasks/main.yml
@@ -0,0 +1,59 @@
+---
+- name: Delete realm
+  community.general.keycloak_realm: "{{ auth_args | combine(call_args) }}"
+  vars:
+    call_args:
+      id: "{{ realm }}"
+      realm: "{{ realm }}"
+      state: absent
+
+- name: Create realm
+  community.general.keycloak_realm: "{{ auth_args | combine(call_args) }}"
+  vars:
+    call_args:
+      id: "{{ realm }}"
+      realm: "{{ realm }}"
+      state: present
+
+- name: Desire client
+  community.general.keycloak_client: "{{ auth_args | combine(call_args) }}"
+  vars:
+    call_args:
+      realm: "{{ realm }}"
+      client_id: "{{ client_id }}"
+      state: present
+      redirect_uris: '{{redirect_uris1}}'
+      attributes: '{{client_attributes1}}'
+      protocol_mappers: '{{protocol_mappers1}}'
+  register: desire_client_not_present
+
+- name: Desire client again with same props
+  community.general.keycloak_client: "{{ auth_args | combine(call_args) }}"
+  vars:
+    call_args:
+      realm: "{{ realm }}"
+      client_id: "{{ client_id }}"
+      state: present
+      redirect_uris: '{{redirect_uris1}}'
+      attributes: '{{client_attributes1}}'
+      protocol_mappers: '{{protocol_mappers1}}'
+  register: desire_client_when_present_and_same
+
+- name: Check client again with same props
+  community.general.keycloak_client: "{{ auth_args | combine(call_args) }}"
+  check_mode: yes
+  vars:
+    call_args:
+      realm: "{{ realm }}"
+      client_id: "{{ client_id }}"
+      state: present
+      redirect_uris: '{{redirect_uris1}}'
+      attributes: '{{client_attributes1}}'
+      protocol_mappers: '{{protocol_mappers1}}'
+  register: check_client_when_present_and_same
+
+- name: Assert changes not detected in last two tasks (desire when same, and check)
+  assert:
+    that:
+      - desire_client_when_present_and_same is not changed
+      - check_client_when_present_and_same is not changed
diff --git a/tests/integration/targets/keycloak_client/vars/main.yml b/tests/integration/targets/keycloak_client/vars/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/keycloak_client/vars/main.yml
@@ -0,0 +1,57 @@
+---
+url: http://localhost:8080/auth
+admin_realm: master
+admin_user: admin
+admin_password: password
+realm: myrealm
+client_id: myclient
+role: myrole
+description_1: desc 1
+description_2: desc 2
+
+auth_args:
+  auth_keycloak_url: "{{ url }}"
+  auth_realm: "{{ admin_realm }}"
+  auth_username: "{{ admin_user }}"
+  auth_password: "{{ admin_password }}"
+
+redirect_uris1:
+  - "http://example.c.com/"
+  - "http://example.b.com/"
+  - "http://example.a.com/"
+
+client_attributes1: {"backchannel.logout.session.required": true, "backchannel.logout.revoke.offline.tokens": false}
+
+protocol_mappers1:
+  - name: 'email'
+    protocol: 'openid-connect'
+    protocolMapper: 'oidc-usermodel-property-mapper'
+    config:
+      "claim.name": "email"
+      "user.attribute": "email"
+      "jsonType.label": "String"
+      "id.token.claim": "true"
+      "access.token.claim": "true"
+      "userinfo.token.claim": "true"
+
+  - name: 'email_verified'
+    protocol: 'openid-connect'
+    protocolMapper: 'oidc-usermodel-property-mapper'
+    config:
+      "claim.name": "email_verified"
+      "user.attribute": "emailVerified"
+      "jsonType.label": "boolean"
+      "id.token.claim": "true"
+      "access.token.claim": "true"
+      "userinfo.token.claim": "true"
+
+  - name: 'family_name'
+    protocol: 'openid-connect'
+    protocolMapper: 'oidc-usermodel-property-mapper'
+    config:
+      "claim.name": "family_name"
+      "user.attribute": "lastName"
+      "jsonType.label": "String"
+      "id.token.claim": "true"
+      "access.token.claim": "true"
+      "userinfo.token.claim": "true"