From f0b7c6351ecc60ac4cd0441cd2fe13ccf2ac06cc Mon Sep 17 00:00:00 2001 From: Florian Dambrine Date: Thu, 8 Apr 2021 13:37:06 -0700 Subject: [PATCH] New module: Add Pritunl VPN organization module (net_tools/pritunl/) (#804) --- plugins/module_utils/net_tools/pritunl/api.py | 70 +++ plugins/modules/net_tools/pritunl/__init__.py | 0 .../modules/net_tools/pritunl/pritunl_org.py | 199 +++++++ .../net_tools/pritunl/pritunl_org_info.py | 129 ++++ plugins/modules/pritunl_org.py | 1 + plugins/modules/pritunl_org_info.py | 1 + .../net_tools/pritunl/test_api.py | 556 ++++++++++-------- .../net_tools/pritunl/test_pritunl_org.py | 204 +++++++ .../pritunl/test_pritunl_org_info.py | 137 +++++ 9 files changed, 1064 insertions(+), 233 deletions(-) create mode 100644 plugins/modules/net_tools/pritunl/__init__.py create mode 100644 plugins/modules/net_tools/pritunl/pritunl_org.py create mode 100644 plugins/modules/net_tools/pritunl/pritunl_org_info.py create mode 120000 plugins/modules/pritunl_org.py create mode 120000 plugins/modules/pritunl_org_info.py create mode 100644 tests/unit/plugins/modules/net_tools/pritunl/test_pritunl_org.py create mode 100644 tests/unit/plugins/modules/net_tools/pritunl/test_pritunl_org_info.py diff --git a/plugins/module_utils/net_tools/pritunl/api.py b/plugins/module_utils/net_tools/pritunl/api.py index e78f1848eb..4dffe2b626 100644 --- a/plugins/module_utils/net_tools/pritunl/api.py +++ b/plugins/module_utils/net_tools/pritunl/api.py @@ -57,6 +57,34 @@ def _get_pritunl_organizations(api_token, api_secret, base_url, validate_certs=T ) +def _delete_pritunl_organization( + api_token, api_secret, base_url, organization_id, validate_certs=True +): + return pritunl_auth_request( + base_url=base_url, + api_token=api_token, + api_secret=api_secret, + method="DELETE", + path="/organization/%s" % (organization_id), + validate_certs=validate_certs, + ) + + +def _post_pritunl_organization( + api_token, api_secret, base_url, organization_data, validate_certs=True +): + return pritunl_auth_request( + api_token=api_token, + api_secret=api_secret, + base_url=base_url, + method="POST", + path="/organization/%s", + headers={"Content-Type": "application/json"}, + data=json.dumps(organization_data), + validate_certs=validate_certs, + ) + + def _get_pritunl_users( api_token, api_secret, base_url, organization_id, validate_certs=True ): @@ -179,6 +207,29 @@ def list_pritunl_users( return users +def post_pritunl_organization( + api_token, + api_secret, + base_url, + organization_name, + validate_certs=True, +): + response = _post_pritunl_organization( + api_token=api_token, + api_secret=api_secret, + base_url=base_url, + organization_data={"name": organization_name}, + validate_certs=True, + ) + + if response.getcode() != 200: + raise PritunlException( + "Could not add organization %s to Pritunl" % (organization_name) + ) + # The user PUT request returns the updated user object + return json.loads(response.read()) + + def post_pritunl_user( api_token, api_secret, @@ -227,6 +278,25 @@ def post_pritunl_user( return json.loads(response.read()) +def delete_pritunl_organization( + api_token, api_secret, base_url, organization_id, validate_certs=True +): + response = _delete_pritunl_organization( + api_token=api_token, + api_secret=api_secret, + base_url=base_url, + organization_id=organization_id, + validate_certs=True, + ) + + if response.getcode() != 200: + raise PritunlException( + "Could not remove organization %s from Pritunl" % (organization_id) + ) + + return json.loads(response.read()) + + def delete_pritunl_user( api_token, api_secret, base_url, organization_id, user_id, validate_certs=True ): diff --git a/plugins/modules/net_tools/pritunl/__init__.py b/plugins/modules/net_tools/pritunl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/modules/net_tools/pritunl/pritunl_org.py b/plugins/modules/net_tools/pritunl/pritunl_org.py new file mode 100644 index 0000000000..7fa7cbc124 --- /dev/null +++ b/plugins/modules/net_tools/pritunl/pritunl_org.py @@ -0,0 +1,199 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2021, Florian Dambrine +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +--- +module: pritunl_org +author: Florian Dambrine (@Lowess) +version_added: 2.5.0 +short_description: Manages Pritunl Organizations using the Pritunl API +description: + - A module to manage Pritunl organizations using the Pritunl API. +extends_documentation_fragment: + - community.general.pritunl +options: + name: + type: str + required: true + aliases: + - org + description: + - The name of the organization to manage in Pritunl. + + force: + type: bool + default: false + description: + - If I(force) is C(true) and I(state) is C(absent), the module + will delete the organization, no matter if it contains users + or not. By default I(force) is C(false), which will cause the + module to fail the deletion of the organization when it contains + users. + + state: + type: str + default: 'present' + choices: + - present + - absent + description: + - If C(present), the module adds organization I(name) to + Pritunl. If C(absent), attempt to delete the organization + from Pritunl (please read about I(force) usage). +""" + +EXAMPLES = """ +- name: Ensure the organization named MyOrg exists + community.general.pritunl_org: + state: present + name: MyOrg + +- name: Ensure the organization named MyOrg does not exist + community.general.pritunl_org: + state: absent + name: MyOrg +""" + +RETURN = """ +response: + description: JSON representation of a Pritunl Organization. + returned: success + type: dict + sample: + { + "auth_api": False, + "name": "Foo", + "auth_token": None, + "user_count": 0, + "auth_secret": None, + "id": "csftwlu6uhralzi2dpmhekz3", + } +""" + + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible.module_utils.common.dict_transformations import dict_merge +from ansible_collections.community.general.plugins.module_utils.net_tools.pritunl.api import ( + PritunlException, + delete_pritunl_organization, + post_pritunl_organization, + list_pritunl_organizations, + get_pritunl_settings, + pritunl_argument_spec, +) + + +def add_pritunl_organization(module): + result = {} + + org_name = module.params.get("name") + + org_obj_list = list_pritunl_organizations( + **dict_merge( + get_pritunl_settings(module), + {"filters": {"name": org_name}}, + ) + ) + + # If the organization already exists + if len(org_obj_list) > 0: + result["changed"] = False + result["response"] = org_obj_list[0] + else: + # Otherwise create it + response = post_pritunl_organization( + **dict_merge( + get_pritunl_settings(module), + {"organization_name": org_name}, + ) + ) + result["changed"] = True + result["response"] = response + + module.exit_json(**result) + + +def remove_pritunl_organization(module): + result = {} + + org_name = module.params.get("name") + force = module.params.get("force") + + org_obj_list = [] + + org_obj_list = list_pritunl_organizations( + **dict_merge( + get_pritunl_settings(module), + { + "filters": {"name": org_name}, + }, + ) + ) + + # No organization found + if len(org_obj_list) == 0: + result["changed"] = False + result["response"] = {} + + else: + # Otherwise attempt to delete it + org = org_obj_list[0] + + # Only accept deletion under specific conditions + if force or org["user_count"] == 0: + response = delete_pritunl_organization( + **dict_merge( + get_pritunl_settings(module), + {"organization_id": org["id"]}, + ) + ) + result["changed"] = True + result["response"] = response + else: + module.fail_json( + msg=( + "Can not remove organization '%s' with %d attached users. " + "Either set 'force' option to true or remove active users " + "from the organization" + ) + % (org_name, org["user_count"]) + ) + + module.exit_json(**result) + + +def main(): + argument_spec = pritunl_argument_spec() + + argument_spec.update( + dict( + name=dict(required=True, type="str", aliases=["org"]), + force=dict(required=False, type="bool", default=False), + state=dict( + required=False, choices=["present", "absent"], default="present" + ), + ) + ), + + module = AnsibleModule(argument_spec=argument_spec) + + state = module.params.get("state") + + try: + if state == "present": + add_pritunl_organization(module) + elif state == "absent": + remove_pritunl_organization(module) + except PritunlException as e: + module.fail_json(msg=to_native(e)) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/net_tools/pritunl/pritunl_org_info.py b/plugins/modules/net_tools/pritunl/pritunl_org_info.py new file mode 100644 index 0000000000..e0c573fb19 --- /dev/null +++ b/plugins/modules/net_tools/pritunl/pritunl_org_info.py @@ -0,0 +1,129 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2021, Florian Dambrine +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +--- +module: pritunl_org_info +author: Florian Dambrine (@Lowess) +version_added: 2.5.0 +short_description: List Pritunl Organizations using the Pritunl API +description: + - A module to list Pritunl organizations using the Pritunl API. +extends_documentation_fragment: + - community.general.pritunl +options: + organization: + type: str + required: false + aliases: + - org + default: null + description: + - Name of the Pritunl organization to search for. + If none provided, the module will return all Pritunl + organizations. +""" + +EXAMPLES = """ +- name: List all existing Pritunl organizations + community.general.pritunl_org_info: + +- name: Search for an organization named MyOrg + community.general.pritunl_user_info: + organization: MyOrg +""" + +RETURN = """ +organizations: + description: List of Pritunl organizations. + returned: success + type: list + elements: dict + sample: + [ + { + "auth_api": False, + "name": "FooOrg", + "auth_token": None, + "user_count": 0, + "auth_secret": None, + "id": "csftwlu6uhralzi2dpmhekz3", + }, + { + "auth_api": False, + "name": "MyOrg", + "auth_token": None, + "user_count": 3, + "auth_secret": None, + "id": "58070daee63f3b2e6e472c36", + }, + { + "auth_api": False, + "name": "BarOrg", + "auth_token": None, + "user_count": 0, + "auth_secret": None, + "id": "v1sncsxxybnsylc8gpqg85pg", + } + ] +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible.module_utils.common.dict_transformations import dict_merge +from ansible_collections.community.general.plugins.module_utils.net_tools.pritunl.api import ( + PritunlException, + get_pritunl_settings, + list_pritunl_organizations, + pritunl_argument_spec, +) + + +def get_pritunl_organizations(module): + org_name = module.params.get("organization") + + organizations = [] + + organizations = list_pritunl_organizations( + **dict_merge( + get_pritunl_settings(module), + {"filters": {"name": org_name} if org_name else None}, + ) + ) + + if org_name and len(organizations) == 0: + # When an org_name is provided but no organization match return an error + module.fail_json(msg="Organization '%s' does not exist" % org_name) + + result = {} + result["changed"] = False + result["organizations"] = organizations + + module.exit_json(**result) + + +def main(): + argument_spec = pritunl_argument_spec() + + argument_spec.update( + dict( + organization=dict(required=False, type="str", default=None, aliases=["org"]) + ) + ), + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + try: + get_pritunl_organizations(module) + except PritunlException as e: + module.fail_json(msg=to_native(e)) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/pritunl_org.py b/plugins/modules/pritunl_org.py new file mode 120000 index 0000000000..3e45ac224d --- /dev/null +++ b/plugins/modules/pritunl_org.py @@ -0,0 +1 @@ +./net_tools/pritunl/pritunl_org.py \ No newline at end of file diff --git a/plugins/modules/pritunl_org_info.py b/plugins/modules/pritunl_org_info.py new file mode 120000 index 0000000000..45ca579db2 --- /dev/null +++ b/plugins/modules/pritunl_org_info.py @@ -0,0 +1 @@ +./net_tools/pritunl/pritunl_org_info.py \ No newline at end of file diff --git a/tests/unit/plugins/module_utils/net_tools/pritunl/test_api.py b/tests/unit/plugins/module_utils/net_tools/pritunl/test_api.py index 1d78a6b555..4039f7c57a 100644 --- a/tests/unit/plugins/module_utils/net_tools/pritunl/test_api.py +++ b/tests/unit/plugins/module_utils/net_tools/pritunl/test_api.py @@ -9,7 +9,9 @@ import json import pytest from ansible.module_utils.common.dict_transformations import dict_merge from ansible.module_utils.six import iteritems -from ansible_collections.community.general.plugins.module_utils.net_tools.pritunl import api +from ansible_collections.community.general.plugins.module_utils.net_tools.pritunl import ( + api, +) from mock import MagicMock __metaclass__ = type @@ -17,6 +19,237 @@ __metaclass__ = type # Pritunl Mocks +PRITUNL_ORGS = [ + { + "auth_api": False, + "name": "Foo", + "auth_token": None, + "user_count": 0, + "auth_secret": None, + "id": "csftwlu6uhralzi2dpmhekz3", + }, + { + "auth_api": False, + "name": "GumGum", + "auth_token": None, + "user_count": 3, + "auth_secret": None, + "id": "58070daee63f3b2e6e472c36", + }, + { + "auth_api": False, + "name": "Bar", + "auth_token": None, + "user_count": 0, + "auth_secret": None, + "id": "v1sncsxxybnsylc8gpqg85pg", + }, +] + +NEW_PRITUNL_ORG = { + "auth_api": False, + "name": "NewOrg", + "auth_token": None, + "user_count": 0, + "auth_secret": None, + "id": "604a140ae63f3b36bc34c7bd", +} + +PRITUNL_USERS = [ + { + "auth_type": "google", + "dns_servers": None, + "pin": True, + "dns_suffix": None, + "servers": [ + { + "status": False, + "platform": None, + "server_id": "580711322bb66c1d59b9568f", + "virt_address6": "fd00:c0a8: 9700: 0: 192: 168: 101: 27", + "virt_address": "192.168.101.27", + "name": "vpn-A", + "real_address": None, + "connected_since": None, + "id": "580711322bb66c1d59b9568f", + "device_name": None, + }, + { + "status": False, + "platform": None, + "server_id": "5dad2cc6e63f3b3f4a6dfea5", + "virt_address6": "fd00:c0a8:f200: 0: 192: 168: 201: 37", + "virt_address": "192.168.201.37", + "name": "vpn-B", + "real_address": None, + "connected_since": None, + "id": "5dad2cc6e63f3b3f4a6dfea5", + "device_name": None, + }, + ], + "disabled": False, + "network_links": [], + "port_forwarding": [], + "id": "58070dafe63f3b2e6e472c3b", + "organization_name": "GumGum", + "type": "server", + "email": "bot@company.com", + "status": True, + "dns_mapping": None, + "otp_secret": "123456789ABCDEFG", + "client_to_client": False, + "sso": "google", + "bypass_secondary": False, + "groups": ["admin", "multiregion"], + "audit": False, + "name": "bot", + "gravatar": True, + "otp_auth": True, + "organization": "58070daee63f3b2e6e472c36", + }, + { + "auth_type": "google", + "dns_servers": None, + "pin": True, + "dns_suffix": None, + "servers": [ + { + "status": False, + "platform": None, + "server_id": "580711322bb66c1d59b9568f", + "virt_address6": "fd00:c0a8: 9700: 0: 192: 168: 101: 27", + "virt_address": "192.168.101.27", + "name": "vpn-A", + "real_address": None, + "connected_since": None, + "id": "580711322bb66c1d59b9568f", + "device_name": None, + }, + { + "status": False, + "platform": None, + "server_id": "5dad2cc6e63f3b3f4a6dfea5", + "virt_address6": "fd00:c0a8:f200: 0: 192: 168: 201: 37", + "virt_address": "192.168.201.37", + "name": "vpn-B", + "real_address": None, + "connected_since": None, + "id": "5dad2cc6e63f3b3f4a6dfea5", + "device_name": None, + }, + ], + "disabled": False, + "network_links": [], + "port_forwarding": [], + "id": "58070dafe63f3b2e6e472c3b", + "organization_name": "GumGum", + "type": "client", + "email": "florian@company.com", + "status": True, + "dns_mapping": None, + "otp_secret": "123456789ABCDEFG", + "client_to_client": False, + "sso": "google", + "bypass_secondary": False, + "groups": ["web", "database"], + "audit": False, + "name": "florian", + "gravatar": True, + "otp_auth": True, + "organization": "58070daee63f3b2e6e472c36", + }, + { + "auth_type": "google", + "dns_servers": None, + "pin": True, + "dns_suffix": None, + "servers": [ + { + "status": False, + "platform": None, + "server_id": "580711322bb66c1d59b9568f", + "virt_address6": "fd00:c0a8: 9700: 0: 192: 168: 101: 27", + "virt_address": "192.168.101.27", + "name": "vpn-A", + "real_address": None, + "connected_since": None, + "id": "580711322bb66c1d59b9568f", + "device_name": None, + }, + { + "status": False, + "platform": None, + "server_id": "5dad2cc6e63f3b3f4a6dfea5", + "virt_address6": "fd00:c0a8:f200: 0: 192: 168: 201: 37", + "virt_address": "192.168.201.37", + "name": "vpn-B", + "real_address": None, + "connected_since": None, + "id": "5dad2cc6e63f3b3f4a6dfea5", + "device_name": None, + }, + ], + "disabled": False, + "network_links": [], + "port_forwarding": [], + "id": "58070dafe63f3b2e6e472c3b", + "organization_name": "GumGum", + "type": "server", + "email": "ops@company.com", + "status": True, + "dns_mapping": None, + "otp_secret": "123456789ABCDEFG", + "client_to_client": False, + "sso": "google", + "bypass_secondary": False, + "groups": ["web", "database"], + "audit": False, + "name": "ops", + "gravatar": True, + "otp_auth": True, + "organization": "58070daee63f3b2e6e472c36", + }, +] + +NEW_PRITUNL_USER = { + "auth_type": "local", + "disabled": False, + "dns_servers": None, + "otp_secret": "6M4UWP2BCJBSYZAT", + "name": "alice", + "pin": False, + "dns_suffix": None, + "client_to_client": False, + "email": "alice@company.com", + "organization_name": "GumGum", + "bypass_secondary": False, + "groups": ["a", "b"], + "organization": "58070daee63f3b2e6e472c36", + "port_forwarding": [], + "type": "client", + "id": "590add71e63f3b72d8bb951a", +} + +NEW_PRITUNL_USER_UPDATED = dict_merge( + NEW_PRITUNL_USER, + { + "disabled": True, + "name": "bob", + "email": "bob@company.com", + "groups": ["c", "d"], + }, +) + + +class PritunlEmptyOrganizationMock(MagicMock): + """Pritunl API Mock for organization GET API calls.""" + + def getcode(self): + return 200 + + def read(self): + return json.dumps([]) + class PritunlListOrganizationMock(MagicMock): """Pritunl API Mock for organization GET API calls.""" @@ -25,34 +258,7 @@ class PritunlListOrganizationMock(MagicMock): return 200 def read(self): - return json.dumps( - [ - { - "auth_api": False, - "name": "Foo", - "auth_token": None, - "user_count": 0, - "auth_secret": None, - "id": "csftwlu6uhralzi2dpmhekz3", - }, - { - "auth_api": False, - "name": "GumGum", - "auth_token": None, - "user_count": 3, - "auth_secret": None, - "id": "58070daee63f3b2e6e472c36", - }, - { - "auth_api": False, - "name": "Bar", - "auth_token": None, - "user_count": 0, - "auth_secret": None, - "id": "v1sncsxxybnsylc8gpqg85pg", - }, - ] - ) + return json.dumps(PRITUNL_ORGS) class PritunlListUserMock(MagicMock): @@ -62,163 +268,7 @@ class PritunlListUserMock(MagicMock): return 200 def read(self): - return json.dumps( - [ - { - "auth_type": "google", - "dns_servers": None, - "pin": True, - "dns_suffix": None, - "servers": [ - { - "status": False, - "platform": None, - "server_id": "580711322bb66c1d59b9568f", - "virt_address6": "fd00:c0a8: 9700: 0: 192: 168: 101: 27", - "virt_address": "192.168.101.27", - "name": "vpn-A", - "real_address": None, - "connected_since": None, - "id": "580711322bb66c1d59b9568f", - "device_name": None, - }, - { - "status": False, - "platform": None, - "server_id": "5dad2cc6e63f3b3f4a6dfea5", - "virt_address6": "fd00:c0a8:f200: 0: 192: 168: 201: 37", - "virt_address": "192.168.201.37", - "name": "vpn-B", - "real_address": None, - "connected_since": None, - "id": "5dad2cc6e63f3b3f4a6dfea5", - "device_name": None, - }, - ], - "disabled": False, - "network_links": [], - "port_forwarding": [], - "id": "58070dafe63f3b2e6e472c3b", - "organization_name": "GumGum", - "type": "server", - "email": "bot@company.com", - "status": True, - "dns_mapping": None, - "otp_secret": "123456789ABCDEFG", - "client_to_client": False, - "sso": "google", - "bypass_secondary": False, - "groups": ["admin", "multiregion"], - "audit": False, - "name": "bot", - "gravatar": True, - "otp_auth": True, - "organization": "58070daee63f3b2e6e472c36", - }, - { - "auth_type": "google", - "dns_servers": None, - "pin": True, - "dns_suffix": None, - "servers": [ - { - "status": False, - "platform": None, - "server_id": "580711322bb66c1d59b9568f", - "virt_address6": "fd00:c0a8: 9700: 0: 192: 168: 101: 27", - "virt_address": "192.168.101.27", - "name": "vpn-A", - "real_address": None, - "connected_since": None, - "id": "580711322bb66c1d59b9568f", - "device_name": None, - }, - { - "status": False, - "platform": None, - "server_id": "5dad2cc6e63f3b3f4a6dfea5", - "virt_address6": "fd00:c0a8:f200: 0: 192: 168: 201: 37", - "virt_address": "192.168.201.37", - "name": "vpn-B", - "real_address": None, - "connected_since": None, - "id": "5dad2cc6e63f3b3f4a6dfea5", - "device_name": None, - }, - ], - "disabled": False, - "network_links": [], - "port_forwarding": [], - "id": "58070dafe63f3b2e6e472c3b", - "organization_name": "GumGum", - "type": "client", - "email": "florian@company.com", - "status": True, - "dns_mapping": None, - "otp_secret": "123456789ABCDEFG", - "client_to_client": False, - "sso": "google", - "bypass_secondary": False, - "groups": ["web", "database"], - "audit": False, - "name": "florian", - "gravatar": True, - "otp_auth": True, - "organization": "58070daee63f3b2e6e472c36", - }, - { - "auth_type": "google", - "dns_servers": None, - "pin": True, - "dns_suffix": None, - "servers": [ - { - "status": False, - "platform": None, - "server_id": "580711322bb66c1d59b9568f", - "virt_address6": "fd00:c0a8: 9700: 0: 192: 168: 101: 27", - "virt_address": "192.168.101.27", - "name": "vpn-A", - "real_address": None, - "connected_since": None, - "id": "580711322bb66c1d59b9568f", - "device_name": None, - }, - { - "status": False, - "platform": None, - "server_id": "5dad2cc6e63f3b3f4a6dfea5", - "virt_address6": "fd00:c0a8:f200: 0: 192: 168: 201: 37", - "virt_address": "192.168.201.37", - "name": "vpn-B", - "real_address": None, - "connected_since": None, - "id": "5dad2cc6e63f3b3f4a6dfea5", - "device_name": None, - }, - ], - "disabled": False, - "network_links": [], - "port_forwarding": [], - "id": "58070dafe63f3b2e6e472c3b", - "organization_name": "GumGum", - "type": "server", - "email": "ops@company.com", - "status": True, - "dns_mapping": None, - "otp_secret": "123456789ABCDEFG", - "client_to_client": False, - "sso": "google", - "bypass_secondary": False, - "groups": ["web", "database"], - "audit": False, - "name": "ops", - "gravatar": True, - "otp_auth": True, - "organization": "58070daee63f3b2e6e472c36", - }, - ] - ) + return json.dumps(PRITUNL_USERS) class PritunlErrorMock(MagicMock): @@ -231,6 +281,22 @@ class PritunlErrorMock(MagicMock): return "{}" +class PritunlPostOrganizationMock(MagicMock): + def getcode(self): + return 200 + + def read(self): + return json.dumps(NEW_PRITUNL_ORG) + + +class PritunlListOrganizationAfterPostMock(MagicMock): + def getcode(self): + return 200 + + def read(self): + return json.dumps(PRITUNL_ORGS + [NEW_PRITUNL_ORG]) + + class PritunlPostUserMock(MagicMock): """Pritunl API Mock for POST API calls.""" @@ -238,28 +304,7 @@ class PritunlPostUserMock(MagicMock): return 200 def read(self): - return json.dumps( - [ - { - "auth_type": "local", - "disabled": False, - "dns_servers": None, - "otp_secret": "6M4UWP2BCJBSYZAT", - "name": "alice", - "pin": False, - "dns_suffix": None, - "client_to_client": False, - "email": "alice@company.com", - "organization_name": "GumGum", - "bypass_secondary": False, - "groups": ["a", "b"], - "organization": "58070daee63f3b2e6e472c36", - "port_forwarding": [], - "type": "client", - "id": "590add71e63f3b72d8bb951a", - } - ] - ) + return json.dumps([NEW_PRITUNL_USER]) class PritunlPutUserMock(MagicMock): @@ -269,26 +314,17 @@ class PritunlPutUserMock(MagicMock): return 200 def read(self): - return json.dumps( - { - "auth_type": "local", - "disabled": True, - "dns_servers": None, - "otp_secret": "WEJANJYMF3Q2QSLG", - "name": "bob", - "pin": False, - "dns_suffix": False, - "client_to_client": False, - "email": "bob@company.com", - "organization_name": "GumGum", - "bypass_secondary": False, - "groups": ["c", "d"], - "organization": "58070daee63f3b2e6e472c36", - "port_forwarding": [], - "type": "client", - "id": "590add71e63f3b72d8bb951a", - } - ) + return json.dumps(NEW_PRITUNL_USER_UPDATED) + + +class PritunlDeleteOrganizationMock(MagicMock): + """Pritunl API Mock for DELETE API calls.""" + + def getcode(self): + return 200 + + def read(self): + return "{}" class PritunlDeleteUserMock(MagicMock): @@ -321,14 +357,21 @@ def pritunl_settings(): } +@pytest.fixture +def pritunl_organization_data(): + return { + "name": NEW_PRITUNL_ORG["name"], + } + + @pytest.fixture def pritunl_user_data(): return { - "name": "alice", - "email": "alice@company.com", - "groups": ["a", "b"], - "disabled": False, - "type": "client", + "name": NEW_PRITUNL_USER["name"], + "email": NEW_PRITUNL_USER["email"], + "groups": NEW_PRITUNL_USER["groups"], + "disabled": NEW_PRITUNL_USER["disabled"], + "type": NEW_PRITUNL_USER["type"], } @@ -347,6 +390,11 @@ def get_pritunl_error_mock(): return PritunlErrorMock() +@pytest.fixture +def post_pritunl_organization_mock(): + return PritunlPostOrganizationMock() + + @pytest.fixture def post_pritunl_user_mock(): return PritunlPostUserMock() @@ -357,6 +405,11 @@ def put_pritunl_user_mock(): return PritunlPutUserMock() +@pytest.fixture +def delete_pritunl_organization_mock(): + return PritunlDeleteOrganizationMock() + + @pytest.fixture def delete_pritunl_user_mock(): return PritunlDeleteUserMock() @@ -460,6 +513,25 @@ class TestPritunlApi: assert user["name"] == user_expected # Test for POST operation on Pritunl API + def test_add_pritunl_organization( + self, + pritunl_settings, + pritunl_organization_data, + post_pritunl_organization_mock, + ): + api._post_pritunl_organization = post_pritunl_organization_mock() + + create_response = api.post_pritunl_organization( + **dict_merge( + pritunl_settings, + {"organization_name": pritunl_organization_data["name"]}, + ) + ) + + # Ensure provided settings match with the ones returned by Pritunl + for k, v in iteritems(pritunl_organization_data): + assert create_response[k] == v + @pytest.mark.parametrize("org_id", [("58070daee63f3b2e6e472c36")]) def test_add_and_update_pritunl_user( self, @@ -513,6 +585,24 @@ class TestPritunlApi: assert update_response[k] == create_response[k] # Test for DELETE operation on Pritunl API + + @pytest.mark.parametrize("org_id", [("58070daee63f3b2e6e472c36")]) + def test_delete_pritunl_organization( + self, pritunl_settings, org_id, delete_pritunl_organization_mock + ): + api._delete_pritunl_organization = delete_pritunl_organization_mock() + + response = api.delete_pritunl_organization( + **dict_merge( + pritunl_settings, + { + "organization_id": org_id, + }, + ) + ) + + assert response == {} + @pytest.mark.parametrize( "org_id,user_id", [("58070daee63f3b2e6e472c36", "590add71e63f3b72d8bb951a")] ) diff --git a/tests/unit/plugins/modules/net_tools/pritunl/test_pritunl_org.py b/tests/unit/plugins/modules/net_tools/pritunl/test_pritunl_org.py new file mode 100644 index 0000000000..39071974c8 --- /dev/null +++ b/tests/unit/plugins/modules/net_tools/pritunl/test_pritunl_org.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +# (c) 2021 Florian Dambrine +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +import sys + +from ansible.module_utils.common.dict_transformations import dict_merge +from ansible.module_utils.six import iteritems +from ansible_collections.community.general.plugins.modules.net_tools.pritunl import ( + pritunl_org, +) +from ansible_collections.community.general.tests.unit.compat.mock import patch +from ansible_collections.community.general.tests.unit.plugins.module_utils.net_tools.pritunl.test_api import ( + PritunlDeleteOrganizationMock, + PritunlListOrganizationMock, + PritunlListOrganizationAfterPostMock, + PritunlPostOrganizationMock, +) +from ansible_collections.community.general.tests.unit.plugins.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + ModuleTestCase, + set_module_args, +) + +__metaclass__ = type + + +class TestPritunlOrg(ModuleTestCase): + def setUp(self): + super(TestPritunlOrg, self).setUp() + self.module = pritunl_org + + # Add backward compatibility + if sys.version_info < (3, 2): + self.assertRegex = self.assertRegexpMatches + + def tearDown(self): + super(TestPritunlOrg, self).tearDown() + + def patch_add_pritunl_organization(self, **kwds): + return patch( + "ansible_collections.community.general.plugins.module_utils.net_tools.pritunl.api._post_pritunl_organization", + autospec=True, + **kwds + ) + + def patch_delete_pritunl_organization(self, **kwds): + return patch( + "ansible_collections.community.general.plugins.module_utils.net_tools.pritunl.api._delete_pritunl_organization", + autospec=True, + **kwds + ) + + def patch_get_pritunl_organizations(self, **kwds): + return patch( + "ansible_collections.community.general.plugins.module_utils.net_tools.pritunl.api._get_pritunl_organizations", + autospec=True, + **kwds + ) + + def test_without_parameters(self): + """Test without parameters""" + set_module_args({}) + with self.assertRaises(AnsibleFailJson): + self.module.main() + + def test_present(self): + """Test Pritunl organization creation.""" + org_params = {"name": "NewOrg"} + set_module_args( + dict_merge( + { + "pritunl_api_token": "token", + "pritunl_api_secret": "secret", + "pritunl_url": "https://pritunl.domain.com", + }, + org_params, + ) + ) + # Test creation + with self.patch_get_pritunl_organizations( + side_effect=PritunlListOrganizationMock + ) as mock_get: + with self.patch_add_pritunl_organization( + side_effect=PritunlPostOrganizationMock + ) as mock_add: + with self.assertRaises(AnsibleExitJson) as create_result: + self.module.main() + + create_exc = create_result.exception.args[0] + + self.assertTrue(create_exc["changed"]) + self.assertEqual(create_exc["response"]["name"], org_params["name"]) + self.assertEqual(create_exc["response"]["user_count"], 0) + + # Test module idempotency + with self.patch_get_pritunl_organizations( + side_effect=PritunlListOrganizationAfterPostMock + ) as mock_get: + with self.patch_add_pritunl_organization( + side_effect=PritunlPostOrganizationMock + ) as mock_add: + with self.assertRaises(AnsibleExitJson) as idempotent_result: + self.module.main() + + idempotent_exc = idempotent_result.exception.args[0] + + # Ensure both calls resulted in the same returned value + # except for changed which sould be false the second time + for k, v in iteritems(idempotent_exc): + if k == "changed": + self.assertFalse(idempotent_exc[k]) + else: + self.assertEqual(create_exc[k], idempotent_exc[k]) + + def test_absent(self): + """Test organization removal from Pritunl.""" + org_params = {"name": "NewOrg"} + set_module_args( + dict_merge( + { + "state": "absent", + "pritunl_api_token": "token", + "pritunl_api_secret": "secret", + "pritunl_url": "https://pritunl.domain.com", + }, + org_params, + ) + ) + # Test deletion + with self.patch_get_pritunl_organizations( + side_effect=PritunlListOrganizationAfterPostMock + ) as mock_get: + with self.patch_delete_pritunl_organization( + side_effect=PritunlDeleteOrganizationMock + ) as mock_delete: + with self.assertRaises(AnsibleExitJson) as delete_result: + self.module.main() + + delete_exc = delete_result.exception.args[0] + + self.assertTrue(delete_exc["changed"]) + self.assertEqual(delete_exc["response"], {}) + + # Test module idempotency + with self.patch_get_pritunl_organizations( + side_effect=PritunlListOrganizationMock + ) as mock_get: + with self.patch_delete_pritunl_organization( + side_effect=PritunlDeleteOrganizationMock + ) as mock_add: + with self.assertRaises(AnsibleExitJson) as idempotent_result: + self.module.main() + + idempotent_exc = idempotent_result.exception.args[0] + + # Ensure both calls resulted in the same returned value + # except for changed which sould be false the second time + self.assertFalse(idempotent_exc["changed"]) + self.assertEqual(idempotent_exc["response"], delete_exc["response"]) + + def test_absent_with_existing_users(self): + """Test organization removal with attached users should fail except if force is true.""" + module_args = { + "state": "absent", + "pritunl_api_token": "token", + "pritunl_api_secret": "secret", + "pritunl_url": "https://pritunl.domain.com", + "name": "GumGum", + } + set_module_args(module_args) + + # Test deletion + with self.patch_get_pritunl_organizations( + side_effect=PritunlListOrganizationMock + ) as mock_get: + with self.patch_delete_pritunl_organization( + side_effect=PritunlDeleteOrganizationMock + ) as mock_delete: + with self.assertRaises(AnsibleFailJson) as failure_result: + self.module.main() + + failure_exc = failure_result.exception.args[0] + + self.assertRegex(failure_exc["msg"], "Can not remove organization") + + # Switch force=True which should run successfully + set_module_args(dict_merge(module_args, {"force": True})) + + with self.patch_get_pritunl_organizations( + side_effect=PritunlListOrganizationMock + ) as mock_get: + with self.patch_delete_pritunl_organization( + side_effect=PritunlDeleteOrganizationMock + ) as mock_delete: + with self.assertRaises(AnsibleExitJson) as delete_result: + self.module.main() + + delete_exc = delete_result.exception.args[0] + + self.assertTrue(delete_exc["changed"]) diff --git a/tests/unit/plugins/modules/net_tools/pritunl/test_pritunl_org_info.py b/tests/unit/plugins/modules/net_tools/pritunl/test_pritunl_org_info.py new file mode 100644 index 0000000000..54922f4b75 --- /dev/null +++ b/tests/unit/plugins/modules/net_tools/pritunl/test_pritunl_org_info.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2021, Florian Dambrine +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +import sys + +from ansible_collections.community.general.plugins.modules.net_tools.pritunl import ( + pritunl_org_info, +) +from ansible_collections.community.general.tests.unit.compat.mock import patch +from ansible_collections.community.general.tests.unit.plugins.module_utils.net_tools.pritunl.test_api import ( + PritunlListOrganizationMock, + PritunlEmptyOrganizationMock, +) +from ansible_collections.community.general.tests.unit.plugins.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + ModuleTestCase, + set_module_args, +) + +__metaclass__ = type + + +class TestPritunlOrgInfo(ModuleTestCase): + def setUp(self): + super(TestPritunlOrgInfo, self).setUp() + self.module = pritunl_org_info + + # Add backward compatibility + if sys.version_info < (3, 2): + self.assertRegex = self.assertRegexpMatches + + def tearDown(self): + super(TestPritunlOrgInfo, self).tearDown() + + def patch_get_pritunl_organizations(self, **kwds): + return patch( + "ansible_collections.community.general.plugins.module_utils.net_tools.pritunl.api._get_pritunl_organizations", + autospec=True, + **kwds + ) + + def test_without_parameters(self): + """Test without parameters""" + with self.patch_get_pritunl_organizations( + side_effect=PritunlListOrganizationMock + ) as org_mock: + set_module_args({}) + with self.assertRaises(AnsibleFailJson): + self.module.main() + + self.assertEqual(org_mock.call_count, 0) + + def test_list_empty_organizations(self): + """Listing all organizations even when no org exists should be valid.""" + with self.patch_get_pritunl_organizations( + side_effect=PritunlEmptyOrganizationMock + ) as org_mock: + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "pritunl_api_token": "token", + "pritunl_api_secret": "secret", + "pritunl_url": "https://pritunl.domain.com", + } + ) + self.module.main() + + self.assertEqual(org_mock.call_count, 1) + + exc = result.exception.args[0] + self.assertEqual(len(exc["organizations"]), 0) + + def test_list_specific_organization(self): + """Listing a specific organization should be valid.""" + with self.patch_get_pritunl_organizations( + side_effect=PritunlListOrganizationMock + ) as org_mock: + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "pritunl_api_token": "token", + "pritunl_api_secret": "secret", + "pritunl_url": "https://pritunl.domain.com", + "org": "GumGum", + } + ) + self.module.main() + + self.assertEqual(org_mock.call_count, 1) + + exc = result.exception.args[0] + self.assertEqual(len(exc["organizations"]), 1) + + def test_list_unknown_organization(self): + """Listing an unknown organization should result in a failure.""" + with self.patch_get_pritunl_organizations( + side_effect=PritunlListOrganizationMock + ) as org_mock: + with self.assertRaises(AnsibleFailJson) as result: + set_module_args( + { + "pritunl_api_token": "token", + "pritunl_api_secret": "secret", + "pritunl_url": "https://pritunl.domain.com", + "org": "Unknown", + } + ) + self.module.main() + + self.assertEqual(org_mock.call_count, 1) + + exc = result.exception.args[0] + self.assertRegex(exc["msg"], "does not exist") + + def test_list_all_organizations(self): + """Listing all organizations should be valid.""" + with self.patch_get_pritunl_organizations( + side_effect=PritunlListOrganizationMock + ) as org_mock: + with self.assertRaises(AnsibleExitJson) as result: + set_module_args( + { + "pritunl_api_token": "token", + "pritunl_api_secret": "secret", + "pritunl_url": "https://pritunl.domain.com", + } + ) + self.module.main() + + self.assertEqual(org_mock.call_count, 1) + + exc = result.exception.args[0] + self.assertEqual(len(exc["organizations"]), 3)