From 495677df38e5c913d17100eaed9f38ebd35f7d5c Mon Sep 17 00:00:00 2001
From: Denis Karpelevich <dkarpele@redhat.com>
Date: Wed, 10 Aug 2022 23:18:47 +0200
Subject: [PATCH] New netgroup management module

There is a new netgroup management module placed in the plugins folder:

    plugins/modules/ipanetgroup.py

The netgroup module allows to ensure presence or absence of netgroup
and manage netgroup members.

Here is the documentation for the module:

    README-netgroup.md

New example playbooks have been added:

    playbooks/netgroup/netgroup-absent.yml
    playbooks/netgroup/netgroup-member-absent.yml
    playbooks/netgroup/netgroup-member-present.yml
    playbooks/netgroup/netgroup-present.yml

New tests for the module:

    tests/netgroup/test_netgroup.yml
    tests/netgroup/test_netgroup_client_context.yml
    tests/netgroup/test_netgroup_member.yml
    tests/netgroup/test_netgroup_member_absent.yml
    tests/netgroup/test_netgroup_member_case_insensitive.yml

Signed-off-by: Denis Karpelevich <dkarpele@redhat.com>
---
 README-netgroup.md                            | 179 ++++++++
 README.md                                     |   2 +
 playbooks/netgroup/netgroup-absent.yml        |  12 +
 playbooks/netgroup/netgroup-member-absent.yml |  14 +
 .../netgroup/netgroup-member-present.yml      |  13 +
 playbooks/netgroup/netgroup-present.yml       |  12 +
 plugins/modules/ipanetgroup.py                | 423 ++++++++++++++++++
 tests/netgroup/test_netgroup.yml              | 149 ++++++
 .../netgroup/test_netgroup_client_context.yml |  51 +++
 tests/netgroup/test_netgroup_member.yml       | 159 +++++++
 .../netgroup/test_netgroup_member_absent.yml  | 206 +++++++++
 .../test_netgroup_member_case_insensitive.yml | 251 +++++++++++
 12 files changed, 1471 insertions(+)
 create mode 100644 README-netgroup.md
 create mode 100644 playbooks/netgroup/netgroup-absent.yml
 create mode 100644 playbooks/netgroup/netgroup-member-absent.yml
 create mode 100644 playbooks/netgroup/netgroup-member-present.yml
 create mode 100644 playbooks/netgroup/netgroup-present.yml
 create mode 100644 plugins/modules/ipanetgroup.py
 create mode 100644 tests/netgroup/test_netgroup.yml
 create mode 100644 tests/netgroup/test_netgroup_client_context.yml
 create mode 100644 tests/netgroup/test_netgroup_member.yml
 create mode 100644 tests/netgroup/test_netgroup_member_absent.yml
 create mode 100644 tests/netgroup/test_netgroup_member_case_insensitive.yml

diff --git a/README-netgroup.md b/README-netgroup.md
new file mode 100644
index 00000000..468e1057
--- /dev/null
+++ b/README-netgroup.md
@@ -0,0 +1,179 @@
+Netgroup module
+============
+
+Description
+-----------
+
+The netgroup module allows to ensure presence and absence of netgroups.
+
+Features
+--------
+
+* Netgroup management
+
+
+Supported FreeIPA Versions
+--------------------------
+
+FreeIPA versions 4.4.0 and up are supported by the ipanetgroup module.
+
+
+Requirements
+------------
+
+**Controller**
+* Ansible version: 2.8+
+
+**Node**
+* Supported FreeIPA version (see above)
+
+
+Usage
+=====
+
+Example inventory file
+
+```ini
+[ipaserver]
+ipaserver.test.local
+```
+
+
+Example playbook to make sure netgroup "my_netgroup1" is present:
+
+```yaml
+---
+- name: Playbook to manage IPA netgroup.
+  hosts: ipaserver
+  become: no
+
+  tasks:
+  - name: Ensure netgroup my_netgroup1 is present
+    ipanetgroup:
+      ipaadmin_password: SomeADMINpassword
+      name: my_netgroup1
+      description: My netgroup 1
+```
+
+
+Example playbook to make sure netgroup "my_netgroup1" is absent:
+
+```yaml
+---
+- name: Playbook to manage IPA netgroup.
+  hosts: ipaserver
+  become: no
+
+  tasks:
+  - name: Ensure netgroup my_netgroup1 is absent
+    ipanetgroup:
+      ipaadmin_password: SomeADMINpassword
+      name: my_netgroup1
+      state: absent
+```
+
+
+Example playbook to make sure netgroup is present with user "user1"
+
+```yaml
+---
+- name: Playbook to manage IPA netgroup.
+  hosts: ipaserver
+  become: no
+
+  tasks:
+  - name: Ensure netgroup is present with user "user1"
+    ipanetgroup:
+      ipaadmin_password: SomeADMINpassword
+      name: TestNetgroup1
+      user: user1
+      action: member
+```
+
+
+Example playbook to make sure netgroup user, "user1", is absent
+
+```yaml
+---
+- name: Playbook to manage IPA netgroup.
+  hosts: ipaserver
+  become: no
+
+  tasks:
+  - name: Ensure netgroup user, "user1", is absent
+    ipanetgroup:
+      ipaadmin_password: SomeADMINpassword
+      name: TestNetgroup1
+      user: "user1"
+      action: member
+      state: absent
+```
+
+
+Example playbook to make sure netgroup is present with members
+
+```yaml
+---
+- name: Playbook to manage IPA netgroup.
+  hosts: ipaserver
+  become: no
+
+  tasks:
+  - name: Ensure netgroup members are present
+    ipanetgroup:
+      ipaadmin_password: SomeADMINpassword
+      name: TestNetgroup1
+      user: user1,user2
+      group: group1
+      host: host1
+      hostgroup: ipaservers
+      netgroup: admins
+      action: member
+```
+
+
+Example playbook to make sure 2 netgroups TestNetgroup1, admins are absent
+
+```yaml
+---
+- name: Playbook to manage IPA netgroup.
+  hosts: ipaserver
+  become: no
+
+  tasks:
+  - name: Ensure netgroups are absent
+    ipanetgroup:
+      ipaadmin_password: SomeADMINpassword
+      name:
+      - TestNetgroup1
+      - admins
+      state: absent
+```
+
+
+Variables
+---------
+
+Variable | Description | Required
+-------- | ----------- | --------
+`ipaadmin_principal` | The admin principal is a string and defaults to `admin` | no
+`ipaadmin_password` | The admin password is a string and is required if there is no admin ticket available on the node | no
+`ipaapi_context` | The context in which the module will execute. Executing in a server context is preferred. If not provided context will be determined by the execution environment. Valid values are `server` and `client`. | no
+`ipaapi_ldap_cache` | Use LDAP cache for IPA connection. The bool setting defaults to yes. (bool) | no
+`name` \| `cn` | The list of netgroup name strings. | yes
+`description` | Netgroup description | no
+`nisdomain` | NIS domain name | no
+`nomembers` | Suppress processing of membership attributes. (bool) | no
+`user` | List of user name strings assigned to this netgroup. | no
+`group` | List of group name strings assigned to this netgroup. | no
+`host` | List of host name strings assigned to this netgroup. | no
+`hostgroup` | List of hostgroup name strings assigned to this netgroup. | no
+`netgroup` | List of netgroup name strings assigned to this netgroup. | no
+`action` | Work on group or member level. It can be on of `member` or `netgroup` and defaults to `netgroup`. | no
+`state` | The state to ensure. It can be one of `present`, `absent`, default: `present`. | no
+
+
+Authors
+=======
+
+Denis Karpelevich
diff --git a/README.md b/README.md
index a3ddc3aa..f3ff4aaa 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@ Features
 * Modules for hostgroup management
 * Modules for idrange management
 * Modules for location management
+* Modules for netgroup management
 * Modules for permission management
 * Modules for privilege management
 * Modules for pwpolicy management
@@ -450,6 +451,7 @@ Modules in plugin/modules
 * [ipahostgroup](README-hostgroup.md)
 * [idrange](README-idrange.md)
 * [ipalocation](README-location.md)
+* [ipanetgroup](README-netgroup.md)
 * [ipapermission](README-permission.md)
 * [ipaprivilege](README-privilege.md)
 * [ipapwpolicy](README-pwpolicy.md)
diff --git a/playbooks/netgroup/netgroup-absent.yml b/playbooks/netgroup/netgroup-absent.yml
new file mode 100644
index 00000000..c3d9d593
--- /dev/null
+++ b/playbooks/netgroup/netgroup-absent.yml
@@ -0,0 +1,12 @@
+---
+- name: Netgroup absent example
+  hosts: ipaserver
+  become: no
+  gather_facts: no
+
+  tasks:
+  - name: Ensure netgroup my_netgroup1 is absent
+    ipanetgroup:
+      ipaadmin_password: SomeADMINpassword
+      name: my_netgroup1
+      state: absent
diff --git a/playbooks/netgroup/netgroup-member-absent.yml b/playbooks/netgroup/netgroup-member-absent.yml
new file mode 100644
index 00000000..54eadd4b
--- /dev/null
+++ b/playbooks/netgroup/netgroup-member-absent.yml
@@ -0,0 +1,14 @@
+---
+- name: Netgroup absent example
+  hosts: ipaserver
+  become: no
+  gather_facts: no
+
+  tasks:
+  - name: Ensure netgroup user, "user1", is absent
+    ipanetgroup:
+      ipaadmin_password: SomeADMINpassword
+      name: TestNetgroup1
+      user: "user1"
+      action: member
+      state: absent
diff --git a/playbooks/netgroup/netgroup-member-present.yml b/playbooks/netgroup/netgroup-member-present.yml
new file mode 100644
index 00000000..b14d2386
--- /dev/null
+++ b/playbooks/netgroup/netgroup-member-present.yml
@@ -0,0 +1,13 @@
+---
+- name: Netgroup member present example
+  hosts: ipaserver
+  become: no
+  gather_facts: no
+
+  tasks:
+  - name: Ensure netgroup is present with user "user1"
+    ipanetgroup:
+      ipaadmin_password: SomeADMINpassword
+      name: TestNetgroup1
+      user: user1
+      action: member
diff --git a/playbooks/netgroup/netgroup-present.yml b/playbooks/netgroup/netgroup-present.yml
new file mode 100644
index 00000000..5fdf7c03
--- /dev/null
+++ b/playbooks/netgroup/netgroup-present.yml
@@ -0,0 +1,12 @@
+---
+- name: Netgroup present example
+  hosts: ipaserver
+  become: no
+  gather_facts: no
+
+  tasks:
+  - name: Ensure netgroup my_netgroup1 is present
+    ipanetgroup:
+      ipaadmin_password: SomeADMINpassword
+      name: my_netgroup1
+      description: My netgroup 1
diff --git a/plugins/modules/ipanetgroup.py b/plugins/modules/ipanetgroup.py
new file mode 100644
index 00000000..bb5b4c13
--- /dev/null
+++ b/plugins/modules/ipanetgroup.py
@@ -0,0 +1,423 @@
+# -*- coding: utf-8 -*-
+
+# Authors:
+#   Denis Karpelevich <dkarpele@redhat.com>
+#
+# Copyright (C) 2022 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from __future__ import (absolute_import, division, print_function)
+
+__metaclass__ = type
+
+ANSIBLE_METADATA = {
+    "metadata_version": "1.0",
+    "supported_by": "community",
+    "status": ["preview"],
+}
+
+DOCUMENTATION = """
+---
+module: ipanetgroup
+short_description: NIS entities can be stored in netgroups.
+description: |
+  A netgroup is a group used for permission checking.
+  It can contain both user and host values.
+extends_documentation_fragment:
+  - ipamodule_base_docs
+  - ipamodule_base_docs.delete_continue
+options:
+  name:
+    description: The list of netgroup name strings.
+    required: true
+    type: list
+    elements: str
+    aliases: ["cn"]
+  description:
+    description: Netgroup description
+    required: false
+    type: str
+    aliases: ["desc"]
+  nisdomain:
+    description: NIS domain name
+    required: false
+    type: str
+    aliases: ["nisdomainname"]
+  nomembers:
+    description: Suppress processing of membership attributes
+    required: false
+    type: bool
+  user:
+    description: List of user names assigned to this netgroup.
+    required: false
+    type: list
+    elements: str
+    aliases: ["users"]
+  group:
+    description: List of group names assigned to this netgroup.
+    required: false
+    type: list
+    elements: str
+    aliases: ["groups"]
+  host:
+    description: List of host names assigned to this netgroup.
+    required: false
+    type: list
+    elements: str
+    aliases: ["hosts"]
+  hostgroup:
+    description: List of host group names assigned to this netgroup.
+    required: false
+    type: list
+    elements: str
+    aliases: ["hostgroups"]
+  netgroup:
+    description: List of netgroup names assigned to this netgroup.
+    required: false
+    type: list
+    elements: str
+    aliases: ["netgroups"]
+  action:
+    description: Work on netgroup or member level
+    required: false
+    default: netgroup
+    choices: ["member", "netgroup"]
+  state:
+    description: The state to ensure.
+    choices: ["present", "absent"]
+    default: present
+author:
+    - Denis Karpelevich (@dkarpele)
+"""
+
+EXAMPLES = """
+- name: Ensure netgroup my_netgroup1 is present
+  ipanetgroup:
+    ipaadmin_password: SomeADMINpassword
+    name: my_netgroup1
+    description: My netgroup 1
+
+- name: Ensure netgroup my_netgroup1 is absent
+  ipanetgroup:
+    ipaadmin_password: SomeADMINpassword
+    name: my_netgroup1
+    state: absent
+
+- name: Ensure netgroup is present with user "user1"
+  ipanetgroup:
+    ipaadmin_password: SomeADMINpassword
+    name: TestNetgroup1
+    user: user1
+    action: member
+
+- name: Ensure netgroup user, "user1", is absent
+  ipanetgroup:
+    ipaadmin_password: SomeADMINpassword
+    name: TestNetgroup1
+    user: "user1"
+    action: member
+    state: absent
+
+- name: Ensure netgroup is present with members
+  ipanetgroup:
+    ipaadmin_password: SomeADMINpassword
+    name: TestNetgroup1
+    user: user1,user2
+    group: group1
+    host: host1
+    hostgroup: ipaservers
+    netgroup: admins
+    action: member
+
+- name: Ensure 2 netgroups TestNetgroup1, admins are absent
+  ipanetgroup:
+    ipaadmin_password: SomeADMINpassword
+    name:
+    - TestNetgroup1
+    - admins
+    state: absent
+"""
+
+RETURN = """
+"""
+
+
+from ansible.module_utils.ansible_freeipa_module import \
+    IPAAnsibleModule, compare_args_ipa, gen_add_del_lists, \
+    gen_add_list, gen_intersection_list, ipalib_errors, ensure_fqdn
+
+
+def find_netgroup(module, name):
+    """Find if a netgroup with the given name already exist."""
+    try:
+        _result = module.ipa_command("netgroup_show", name, {"all": True})
+    except ipalib_errors.NotFound:
+        # An exception is raised if netgroup name is not found.
+        return None
+    else:
+        return _result["result"]
+
+
+def gen_args(description, nisdomain, nomembers):
+    _args = {}
+    if description is not None:
+        _args["description"] = description
+    if nisdomain is not None:
+        _args["nisdomainname"] = nisdomain
+    if nomembers is not None:
+        _args["nomembers"] = nomembers
+
+    return _args
+
+
+def gen_member_args(user, group, host, hostgroup, netgroup):
+    _args = {}
+    if user is not None:
+        _args["memberuser_user"] = user
+    if group is not None:
+        _args["memberuser_group"] = group
+    if host is not None:
+        _args["memberhost_host"] = host
+    if hostgroup is not None:
+        _args["memberhost_hostgroup"] = hostgroup
+    if netgroup is not None:
+        _args["member_netgroup"] = netgroup
+
+    return _args
+
+
+def main():
+    ansible_module = IPAAnsibleModule(
+        argument_spec=dict(
+            # general
+            name=dict(type="list", elements="str", aliases=["cn"],
+                      required=True),
+            # present
+            description=dict(required=False, type='str',
+                             aliases=["desc"], default=None),
+            nisdomain=dict(required=False, type='str',
+                           aliases=["nisdomainname"], default=None),
+            nomembers=dict(required=False, type='bool', default=None),
+            user=dict(required=False, type='list', elements="str",
+                      aliases=["users"], default=None),
+            group=dict(required=False, type='list', elements="str",
+                       aliases=["groups"], default=None),
+            host=dict(required=False, type='list', elements="str",
+                      aliases=["hosts"], default=None),
+            hostgroup=dict(required=False, type='list', elements="str",
+                           aliases=["hostgroups"], default=None),
+            netgroup=dict(required=False, type='list', elements="str",
+                          aliases=["netgroups"], default=None),
+            action=dict(required=False, type="str", default="netgroup",
+                        choices=["member", "netgroup"]),
+            # state
+            state=dict(type="str", default="present",
+                       choices=["present", "absent"]),
+        ),
+        supports_check_mode=True,
+        ipa_module_options=["delete_continue"],
+    )
+
+    ansible_module._ansible_debug = True
+
+    # Get parameters
+
+    # general
+    names = ansible_module.params_get("name")
+
+    # present
+    description = ansible_module.params_get("description")
+    nisdomain = ansible_module.params_get("nisdomain")
+    nomembers = ansible_module.params_get("nomembers")
+    user = ansible_module.params_get_lowercase("user")
+    group = ansible_module.params_get_lowercase("group")
+    host = ansible_module.params_get_lowercase("host")
+    hostgroup = ansible_module.params_get_lowercase("hostgroup")
+    netgroup = ansible_module.params_get_lowercase("netgroup")
+    action = ansible_module.params_get("action")
+
+    # state
+    state = ansible_module.params_get("state")
+
+    # Check parameters
+
+    invalid = []
+
+    if state == "present":
+        if len(names) != 1:
+            ansible_module.fail_json(
+                msg="Only one netgroup can be added at a time.")
+        if action == "member":
+            invalid = ["description", "nisdomain", "nomembers"]
+
+    if state == "absent":
+        if len(names) < 1:
+            ansible_module.fail_json(msg="No name given.")
+        if len(names) != 1 and action == "member":
+            ansible_module.fail_json(msg="Members can be removed only from one"
+                                         " netgroup at a time.")
+        invalid = ["description", "nisdomain", "nomembers"]
+        if action == "netgroup":
+            invalid.extend(["user", "group", "host", "hostgroup", "netgroup"])
+
+    ansible_module.params_fail_used_invalid(invalid, state)
+
+    # Init
+
+    exit_args = {}
+
+    # Connect to IPA API
+    with ansible_module.ipa_connect():
+        # Ensure fqdn host names, use default domain for simple names
+        if host is not None:
+            default_domain = ansible_module.ipa_get_domain()
+            host = [ensure_fqdn(_host, default_domain).lower()
+                    for _host in host]
+
+        commands = []
+        for name in names:
+            # Make sure netgroup exists
+            res_find = find_netgroup(ansible_module, name)
+
+            user_add, user_del = [], []
+            group_add, group_del = [], []
+            host_add, host_del = [], []
+            hostgroup_add, hostgroup_del = [], []
+            netgroup_add, netgroup_del = [], []
+
+            # Create command
+            if state == "present":
+                # Generate args
+                args = gen_args(description, nisdomain, nomembers)
+
+                if action == "netgroup":
+                    # Found the netgroup
+                    if res_find is not None:
+                        # For all settings is args, check if there are
+                        # different settings in the find result.
+                        # If yes: modify
+                        if not compare_args_ipa(ansible_module, args,
+                                                res_find):
+                            commands.append([name, "netgroup_mod", args])
+                    else:
+                        commands.append([name, "netgroup_add", args])
+                        res_find = {}
+
+                    member_args = gen_member_args(
+                        user, group, host, hostgroup, netgroup
+                    )
+                    if not compare_args_ipa(ansible_module, member_args,
+                                            res_find):
+                        # Generate addition and removal lists
+                        user_add, user_del = gen_add_del_lists(
+                            user, res_find.get("memberuser_user"))
+
+                        group_add, group_del = gen_add_del_lists(
+                            group, res_find.get("memberuser_group"))
+
+                        host_add, host_del = gen_add_del_lists(
+                            host, res_find.get("memberhost_host"))
+
+                        hostgroup_add, hostgroup_del = gen_add_del_lists(
+                            hostgroup, res_find.get("memberhost_hostgroup"))
+
+                        netgroup_add, netgroup_del = gen_add_del_lists(
+                            netgroup, res_find.get("member_netgroup"))
+
+                elif action == "member":
+                    if res_find is None:
+                        ansible_module.fail_json(msg="No netgroup '%s'" % name)
+
+                    # Reduce add lists for memberuser_user, memberuser_group,
+                    # member_service and member_external to new entries
+                    # only that are not in res_find.
+                    user_add = gen_add_list(
+                        user, res_find.get("memberuser_user"))
+                    group_add = gen_add_list(
+                        group, res_find.get("memberuser_group"))
+                    host_add = gen_add_list(
+                        host, res_find.get("memberhost_host"))
+                    hostgroup_add = gen_add_list(
+                        hostgroup, res_find.get("memberhost_hostgroup"))
+                    netgroup_add = gen_add_list(
+                        netgroup, res_find.get("member_netgroup"))
+
+            elif state == "absent":
+                if action == "netgroup":
+                    if res_find is not None:
+                        commands.append([name, "netgroup_del", {}])
+
+                elif action == "member":
+                    if res_find is None:
+                        ansible_module.fail_json(msg="No netgroup '%s'" % name)
+                    user_del = gen_intersection_list(
+                        user, res_find.get("memberuser_user"))
+                    group_del = gen_intersection_list(
+                        group, res_find.get("memberuser_group"))
+                    host_del = gen_intersection_list(
+                        host, res_find.get("memberhost_host"))
+                    hostgroup_del = gen_intersection_list(
+                        hostgroup, res_find.get("memberhost_hostgroup"))
+                    netgroup_del = gen_intersection_list(
+                        netgroup, res_find.get("member_netgroup"))
+
+            else:
+                ansible_module.fail_json(msg="Unknown state '%s'" % state)
+
+            # manage members
+            # setup member args for add/remove members.
+            add_member_args = {
+                "user": user_add,
+                "group": group_add,
+                "host": host_add,
+                "hostgroup": hostgroup_add,
+                "netgroup": netgroup_add
+            }
+
+            del_member_args = {
+                "user": user_del,
+                "group": group_del,
+                "host": host_del,
+                "hostgroup": hostgroup_del,
+                "netgroup": netgroup_del
+            }
+
+            # Add members
+            add_members = any([user_add, group_add, host_add,
+                               hostgroup_add, netgroup_add])
+            if add_members:
+                commands.append(
+                    [name, "netgroup_add_member", add_member_args]
+                )
+            # Remove members
+            remove_members = any([user_del, group_del, host_del,
+                                  hostgroup_del, netgroup_del])
+            if remove_members:
+                commands.append(
+                    [name, "netgroup_remove_member", del_member_args]
+                )
+        # Execute commands
+
+        changed = ansible_module.execute_ipa_commands(
+            commands, fail_on_member_errors=True)
+
+    # Done
+
+    ansible_module.exit_json(changed=changed, **exit_args)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/netgroup/test_netgroup.yml b/tests/netgroup/test_netgroup.yml
new file mode 100644
index 00000000..d4ac69e9
--- /dev/null
+++ b/tests/netgroup/test_netgroup.yml
@@ -0,0 +1,149 @@
+---
+- name: Test netgroup
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  become: no
+  gather_facts: no
+
+  tasks:
+  - block:
+    # CLEANUP TEST ITEMS
+    - name: Ensure netgroups are absent
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+          - my_netgroup1
+          - my_netgroup2
+          - my_netgroup3
+        state: absent
+
+    # CREATE TEST ITEMS
+    - name: Get Domain from server name
+      set_fact:
+        ipaserver_domain: "{{ ansible_facts['fqdn'].split('.')[1:] | join ('.') }}"
+      when: ipaserver_domain is not defined
+
+    - name: Ensure netgroup my_netgroup2 is present
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: my_netgroup2
+
+    - name: Ensure netgroup my_netgroup3 is present
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: my_netgroup3
+
+    # TESTS
+
+    - name: Ensure netgroup my_netgroup1 is present
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: my_netgroup1
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure netgroup my_netgroup1 is present again
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: my_netgroup1
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure netgroup my_netgroup1 is present with description and
+        nisdomain
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: my_netgroup1
+        description: My netgroup 1
+        nisdomain: domain.test
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure netgroup my_netgroup1 is present with new description
+        and new nisdomain
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: my_netgroup1
+        description: New description
+        nisdomain: new-domain.test
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure netgroup my_netgroup1 is present with description and
+        nisdomain again
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: my_netgroup1
+        description: New description
+        nisdomain: new-domain.test
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure 2 netgroups aren't present
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+          - my_netgroup1
+          - my_netgroup2
+      register: result
+      failed_when: result.changed or not result.failed or
+        "Only one netgroup can be added at a time." not in result.msg
+
+    - name: Ensure netgroup my_netgroup1 is absent
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: my_netgroup1
+        state: absent
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure netgroup my_netgroup1 is absent again
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: my_netgroup1
+        state: absent
+      register: result
+      failed_when: result.changed or result.failed
+
+    # netgroup and hostgroup with the same name are deprecated
+    - name: Ensure hostgroup my_netgroup2 isn't present
+      ipahostgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: my_netgroup2
+      register: result
+      failed_when: result.changed or not result.failed or
+        "Hostgroups and netgroups share a common namespace" not in result.msg
+
+    - name: Ensure netgroups my_netgroup2, my_netgroup3 are absent
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+          - my_netgroup2
+          - my_netgroup3
+        state: absent
+      register: result
+      failed_when: not result.changed
+
+    always:
+    # cleanup
+    - name: Ensure netgroups are absent
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+          - my_netgroup1
+          - my_netgroup2
+          - my_netgroup3
+        state: absent
diff --git a/tests/netgroup/test_netgroup_client_context.yml b/tests/netgroup/test_netgroup_client_context.yml
new file mode 100644
index 00000000..f5a4dd3a
--- /dev/null
+++ b/tests/netgroup/test_netgroup_client_context.yml
@@ -0,0 +1,51 @@
+---
+- name: Test netgroup
+  hosts: ipaclients, ipaserver
+  become: no
+  gather_facts: no
+
+  tasks:
+  - name: Include FreeIPA facts.
+    include_tasks: ../env_freeipa_facts.yml
+
+  # Test will only be executed if host is not a server.
+  - name: Execute with server context in the client.
+    ipanetgroup:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: server
+      name: ThisShouldNotWork
+    register: result
+    failed_when: not (result.failed and result.msg is regex("No module named '*ipaserver'*"))
+    when: ipa_host_is_client
+
+# Import basic module tests, and execute with ipa_context set to 'client'.
+# If ipaclients is set, it will be executed using the client, if not,
+# ipaserver will be used.
+#
+# With this setup, tests can be executed against an IPA client, against
+# an IPA server using "client" context, and ensure that tests are executed
+# in upstream CI.
+
+- name: Test netgroup using client context, in client host.
+  import_playbook: test_netgroup.yml
+  when: groups['ipaclients']
+  vars:
+    ipa_test_host: ipaclients
+
+- name: Test netgroup using client context, in server host.
+  import_playbook: test_netgroup.yml
+  when: groups['ipaclients'] is not defined or not groups['ipaclients']
+  vars:
+    ipa_context: client
+
+- name: Test netgroup with member using client context, in client host.
+  import_playbook: test_netgroup_member.yml
+  when: groups['ipaclients']
+  vars:
+    ipa_test_host: ipaclients
+
+- name: Test netgroup with member using client context, in server host.
+  import_playbook: test_netgroup_member.yml
+  when: groups['ipaclients'] is not defined or not groups['ipaclients']
+  vars:
+    ipa_context: client
diff --git a/tests/netgroup/test_netgroup_member.yml b/tests/netgroup/test_netgroup_member.yml
new file mode 100644
index 00000000..3fc00246
--- /dev/null
+++ b/tests/netgroup/test_netgroup_member.yml
@@ -0,0 +1,159 @@
+---
+- name: Netgroup member test
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  become: no
+  gather_facts: no
+
+  tasks:
+  - block:
+    - name: Get Domain from server name
+      set_fact:
+        ipaserver_domain: "{{ ansible_facts['fqdn'].split('.')[1:] | join ('.') }}"
+      when: ipaserver_domain is not defined
+
+    - name: Set host1_fqdn .. host2_fqdn
+      set_fact:
+        host1_fqdn: "{{ 'host1.' + ipaserver_domain }}"
+        host2_fqdn: "{{ 'host2.' + ipaserver_domain }}"
+
+    # CLEANUP TEST ITEMS
+    - name: Ensure users user1, user2 are absent
+      ipauser:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: user1,user2
+        state: absent
+
+    - name: Ensure group group1 is absent
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: group1
+        state: absent
+
+    - name: Ensure hosts are absent
+      ipahost:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+          - "{{ host1_fqdn }}"
+          - "{{ host2_fqdn }}"
+        state: absent
+
+    - name: Ensure netgroups TestNetgroup1, admins are absent
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+        - TestNetgroup1,admins
+        state: absent
+
+    # CREATE TEST ITEMS
+    - name: Ensure users user1, user2 are present
+      ipauser:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        users:
+        - name: user1
+          first: first1
+          last: last1
+        - name: user2
+          first: first2
+          last: last2
+
+    - name: Ensure groups group1 are present
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: group1
+
+    - name: Ensure hosts "{{ 'host[1..2].' + ipaserver_domain }}" are present
+      ipahost:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        hosts:
+        - name: "{{ host1_fqdn }}"
+          force: yes
+        - name: "{{ host2_fqdn }}"
+          force: yes
+
+    - name: Ensure netgroup admins is present
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: admins
+
+    # TEST
+    - name: Ensure netgroup TestNetgroup1 is present
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: TestNetgroup1
+        action: netgroup
+        description: Description for TestNetgroup1
+        nisdomain: "{{ ipaserver_domain }}"
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure netgroup is present with members
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: TestNetgroup1
+        user: user1,user2
+        group: group1
+        host: "{{ host1_fqdn }}"
+        hostgroup: ipaservers
+        netgroup: admins
+        action: member
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure netgroup is present with members again (idempotence check)
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: TestNetgroup1
+        user: user1,user2
+        group: group1
+        host:
+          - "{{ host1_fqdn }}"
+          - host1
+        hostgroup: ipaservers
+        netgroup: admins
+        action: member
+      register: result
+      failed_when: result.changed or result.failed
+
+    always:
+    # CLEANUP TEST ITEMS
+    - name: Ensure users user1, user2 are absent
+      ipauser:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: user1,user2
+        state: absent
+
+    - name: Ensure group group1 is absent
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: group1
+        state: absent
+
+    - name: Ensure hosts are absent
+      ipahost:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+          - "{{ host1_fqdn }}"
+          - "{{ host2_fqdn }}"
+        state: absent
+
+    - name: Ensure netgroups TestNetgroup1, admins are absent
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+        - TestNetgroup1,admins
+        state: absent
diff --git a/tests/netgroup/test_netgroup_member_absent.yml b/tests/netgroup/test_netgroup_member_absent.yml
new file mode 100644
index 00000000..3ccd36d5
--- /dev/null
+++ b/tests/netgroup/test_netgroup_member_absent.yml
@@ -0,0 +1,206 @@
+---
+- name: Netgroup member absent test
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  become: no
+  gather_facts: no
+
+  tasks:
+  - block:
+    - name: Get Domain from server name
+      set_fact:
+        ipaserver_domain: "{{ ansible_facts['fqdn'].split('.')[1:] | join ('.') }}"
+      when: ipaserver_domain is not defined
+
+    - name: Set host1_fqdn .. host2_fqdn
+      set_fact:
+        host1_fqdn: "{{ 'host1.' + ipaserver_domain }}"
+        host2_fqdn: "{{ 'host2.' + ipaserver_domain }}"
+
+    # CLEANUP TEST ITEMS
+    - name: Ensure users user1, user2 are absent
+      ipauser:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: user1,user2
+        state: absent
+
+    - name: Ensure group group1 is absent
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: group1
+        state: absent
+
+    - name: Ensure hosts are absent
+      ipahost:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+          - "{{ host1_fqdn }}"
+          - "{{ host2_fqdn }}"
+        state: absent
+
+    - name: Ensure netgroups TestNetgroup1, admins are absent
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+        - TestNetgroup1,admins
+        state: absent
+
+    # CREATE TEST ITEMS
+    - name: Ensure users user1, user2 are present
+      ipauser:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        users:
+        - name: user1
+          first: first1
+          last: last1
+        - name: user2
+          first: first2
+          last: last2
+
+    - name: Ensure group group1 is present
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: group1
+
+    - name: Ensure hosts "{{ 'host[1..2].' + ipaserver_domain }}" are present
+      ipahost:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        hosts:
+        - name: "{{ host1_fqdn }}"
+          force: yes
+        - name: "{{ host2_fqdn }}"
+          force: yes
+
+    - name: Ensure netgroup admins is present
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: admins
+
+    - name: Ensure netgroup TestNetgroup1 is present
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: TestNetgroup1
+        description: Description for TestNetgroup1
+        nisdomain: "{{ ipaserver_domain }}"
+
+    - name: Ensure netgroup is present with members
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: TestNetgroup1
+        user: user1,user2
+        group: group1
+        host:
+          - "{{ host1_fqdn }}"
+          - "{{ host2_fqdn }}"
+        hostgroup: ipaservers
+        netgroup: admins
+        action: member
+
+    # TEST
+    - name: Ensure members are absent in netgroup
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: TestNetgroup1
+        user: user1
+        group: group1
+        host:
+          - "{{ host1_fqdn }}"
+          - host1
+        hostgroup: ipaservers
+        netgroup: admins
+        action: member
+        state: absent
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure some members are still present in netgroup
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: TestNetgroup1
+        user: user2
+        host:
+          - "{{ host2_fqdn }}"
+        action: member
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure host was removed by hostname from netgroup
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: TestNetgroup1
+        host:
+          - host2
+        action: member
+        state: absent
+      register: result
+      failed_when: not result.changed or result.failed
+
+    - name: Ensure member user2 presents in netgroup
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: TestNetgroup1
+        user: user2
+        action: member
+      register: result
+      failed_when: result.changed or result.failed
+
+    - name: Ensure members from netgroups my_netgroup1,my_netgroup2 aren't
+        absent
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+          - my_netgroup1
+          - my_netgroup2
+        state: absent
+        action: member
+      register: result
+      failed_when: result.changed or not result.failed or
+        "Members can be removed only from one netgroup at a time." not in
+        result.msg
+
+    always:
+    # CLEANUP TEST ITEMS
+    - name: Ensure users user1, user2 are absent
+      ipauser:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: user1,user2
+        state: absent
+
+    - name: Ensure group group1 is absent
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name: group1
+        state: absent
+
+    - name: Ensure hosts are absent
+      ipahost:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+          - "{{ host1_fqdn }}"
+          - "{{ host2_fqdn }}"
+        state: absent
+
+    - name: Ensure netgroups TestNetgroup1, admins are absent
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        ipaapi_context: "{{ ipa_context | default(omit) }}"
+        name:
+        - TestNetgroup1,admins
+        state: absent
diff --git a/tests/netgroup/test_netgroup_member_case_insensitive.yml b/tests/netgroup/test_netgroup_member_case_insensitive.yml
new file mode 100644
index 00000000..abd12593
--- /dev/null
+++ b/tests/netgroup/test_netgroup_member_case_insensitive.yml
@@ -0,0 +1,251 @@
+---
+- name: Test netgroup members should be case insensitive.
+  hosts: "{{ ipa_test_host | default('ipaserver') }}"
+  become: no
+  gather_facts: no
+
+  vars:
+    groups_present:
+      - eleMENT1
+      - Element2
+      - eLeMenT3
+      - ElemENT4
+
+
+  tasks:
+  - block:
+    # SETUP
+    - name: Get Domain from server name
+      set_fact:
+        ipaserver_domain: "{{ ansible_facts['fqdn'].split('.')[1:] | join ('.') }}"
+      when: ipaserver_domain is not defined
+
+    - name: Ensure test groups exist.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+      loop: "{{ groups_present }}"
+
+    - name: Ensure test hostgroups exist.
+      ipahostgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "hostgroup{{ item }}"
+      loop: "{{ groups_present }}"
+
+    - name: Ensure test netgroups exist.
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "netgroup{{ item }}"
+      loop: "{{ groups_present }}"
+
+    - name: Ensure test hosts exist.
+      ipahost:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}.{{ ipaserver_domain }}"
+        force: yes
+      loop: "{{ groups_present }}"
+
+    - name: Ensure test users exist.
+      ipauser:
+        ipaadmin_password: SomeADMINpassword
+        name: "user{{ item }}"
+        first: "{{ item }}"
+        last: "{{ item }}"
+      loop: "{{ groups_present }}"
+
+    - name: Ensure netgroups don't exist
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+        state: absent
+      loop: "{{ groups_present }}"
+
+    # TESTS
+    - name: Start tests.
+      debug:
+        msg: "Tests are starting."
+
+    - name: Ensure netgroups exist
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+      loop: "{{ groups_present }}"
+      register: result
+      failed_when: result.failed or not result.changed
+
+    - name: Ensure netgroups exist with members
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+        hostgroup: "hostgroup{{ item }}"
+        host: "{{ item }}.{{ ipaserver_domain }}"
+        group: "{{ item }}"
+        user: "user{{ item }}"
+        netgroup: "netgroup{{ item }}"
+        action: member
+      loop: "{{ groups_present }}"
+      register: result
+      failed_when: result.failed or not result.changed
+
+    - name: Ensure netgroups exist with members, lowercase
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+        hostgroup: "hostgroup{{ item  | lower }}"
+        host: "{{ item  | lower }}.{{ ipaserver_domain }}"
+        group: "{{ item  | lower }}"
+        user: "user{{ item  | lower }}"
+        netgroup: "netgroup{{ item  | lower }}"
+        action: member
+      loop: "{{ groups_present }}"
+      register: result
+      failed_when: result.failed or result.changed
+
+    - name: Ensure netgroups exist with members, uppercase
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+        hostgroup: "hostgroup{{ item  | upper }}"
+        host: "{{ item  | upper }}.{{ ipaserver_domain }}"
+        group: "{{ item  | upper }}"
+        user: "user{{ item  | upper }}"
+        netgroup: "netgroup{{ item  | upper }}"
+        action: member
+      loop: "{{ groups_present }}"
+      register: result
+      failed_when: result.failed or result.changed
+
+    - name: Ensure netgroup member is absent
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+        hostgroup: "hostgroup{{ item }}"
+        host: "{{ item }}.{{ ipaserver_domain }}"
+        group: "{{ item }}"
+        user: "user{{ item }}"
+        netgroup: "netgroup{{ item }}"
+        action: member
+        state: absent
+      loop: "{{ groups_present }}"
+      register: result
+      failed_when: result.failed or not result.changed
+
+    - name: Ensure netgroup member is absent, lowercase
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+        hostgroup: "hostgroup{{ item  | lower }}"
+        host: "{{ item  | lower }}.{{ ipaserver_domain }}"
+        group: "{{ item  | lower }}"
+        user: "user{{ item  | lower }}"
+        netgroup: "netgroup{{ item  | lower }}"
+        action: member
+        state: absent
+      loop: "{{ groups_present }}"
+      register: result
+      failed_when: result.failed or result.changed
+
+    - name: Ensure netgroup member is absent, uppercase
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+        hostgroup: "hostgroup{{ item  | upper }}"
+        host: "{{ item  | upper }}.{{ ipaserver_domain }}"
+        group: "{{ item  | upper }}"
+        user: "user{{ item  | upper }}"
+        netgroup: "netgroup{{ item  | upper }}"
+        action: member
+        state: absent
+      loop: "{{ groups_present }}"
+      register: result
+      failed_when: result.failed or result.changed
+
+    - name: Ensure netgroup member is present, uppercase
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+        hostgroup: "hostgroup{{ item  | upper }}"
+        host: "{{ item  | upper }}.{{ ipaserver_domain }}"
+        group: "{{ item  | upper }}"
+        user: "user{{ item  | upper }}"
+        netgroup: "netgroup{{ item  | upper }}"
+        action: member
+      loop: "{{ groups_present }}"
+      register: result
+      failed_when: result.failed or not result.changed
+
+    - name: Ensure netgroup member is present, lowercase
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+        hostgroup: "hostgroup{{ item  | lower }}"
+        host: "{{ item  | lower }}.{{ ipaserver_domain }}"
+        group: "{{ item  | lower }}"
+        user: "user{{ item  | lower }}"
+        netgroup: "netgroup{{ item  | lower }}"
+        action: member
+      loop: "{{ groups_present }}"
+      register: result
+      failed_when: result.failed or result.changed
+
+    - name: Ensure netgroup member is present, mixed case
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+        hostgroup: "hostgroup{{ item }}"
+        host: "{{ item }}.{{ ipaserver_domain }}"
+        group: "{{ item }}"
+        user: "user{{ item }}"
+        netgroup: "netgroup{{ item }}"
+        action: member
+      loop: "{{ groups_present }}"
+      register: result
+      failed_when: result.failed or result.changed
+
+    - name: End tests.
+      debug:
+        msg: "All tests executed."
+
+    always:
+    # cleanup
+    - name: Ensure netgroups do not exist
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+        state: absent
+      loop: "{{ groups_present }}"
+
+    - name: Ensure test groups do not exist.
+      ipagroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}"
+        state: absent
+      loop: "{{ groups_present }}"
+
+    - name: Ensure test hostgroups do not exist.
+      ipahostgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "hostgroup{{ item }}"
+        state: absent
+      loop: "{{ groups_present }}"
+
+    - name: Ensure test netgroups do not exist.
+      ipanetgroup:
+        ipaadmin_password: SomeADMINpassword
+        name: "netgroup{{ item }}"
+        state: absent
+      loop: "{{ groups_present }}"
+
+    - name: Ensure test hosts do not exist.
+      ipahost:
+        ipaadmin_password: SomeADMINpassword
+        name: "{{ item }}.{{ ipaserver_domain }}"
+        state: absent
+      loop: "{{ groups_present }}"
+
+    - name: Ensure test users do not exist.
+      ipauser:
+        ipaadmin_password: SomeADMINpassword
+        name: "user{{ item }}"
+        state: absent
+      loop: "{{ groups_present }}"
-- 
GitLab