summaryrefslogtreecommitdiffstats
path: root/ansible_collections/microsoft/ad/tests
diff options
context:
space:
mode:
Diffstat (limited to 'ansible_collections/microsoft/ad/tests')
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/computer/aliases2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/computer/meta/main.yml2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/main.yml14
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/tests.yml425
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/aliases2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/tasks/main.yml17
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/domain/aliases2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/domain/meta/main.yml4
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/main.yml66
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/test.yml95
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/README.md34
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/Vagrantfile27
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/aliases2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/ansible.cfg3
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/inventory.yml21
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/setup.yml82
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/tasks/main.yml232
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/test.yml30
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/group/aliases2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/group/meta/main.yml2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/main.yml14
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/tests.yml516
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/aliases3
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/main.yml25
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/files/generate_cert.sh82
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/handlers/main.yml10
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/tasks/main.yml196
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/invoke.yml14
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/main.yml527
-rwxr-xr-xansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/runme.sh5
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/membership/README.md34
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/membership/Vagrantfile27
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/membership/aliases2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/membership/ansible.cfg3
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/membership/inventory.yml22
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/membership/setup.yml82
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/membership/tasks/main.yml631
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/membership/test.yml30
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/object/aliases2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/object/defaults/main.yml2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/object/meta/main.yml2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/main.yml164
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/tests.yml1446
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/object_info/aliases2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/object_info/handlers/main.yml4
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/object_info/meta/main.yml2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/object_info/tasks/main.yml186
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/offline_join/aliases2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/offline_join/meta/main.yml2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/main.yml14
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/tests.yml150
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/ou/aliases2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/ou/meta/main.yml2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/main.yml14
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/tests.yml254
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/defaults/main.yml2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/main.yml55
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/setup.yml5
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/user/aliases2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/user/meta/main.yml2
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/main.yml14
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/tests.yml1065
-rw-r--r--ansible_collections/microsoft/ad/tests/integration/windows-integration.controller.requirements.txt3
-rw-r--r--ansible_collections/microsoft/ad/tests/requirements.yml2
-rw-r--r--ansible_collections/microsoft/ad/tests/sanity/ignore-2.12.txt0
-rw-r--r--ansible_collections/microsoft/ad/tests/sanity/ignore-2.13.txt0
-rw-r--r--ansible_collections/microsoft/ad/tests/sanity/ignore-2.14.txt0
-rw-r--r--ansible_collections/microsoft/ad/tests/sanity/ignore-2.15.txt0
-rw-r--r--ansible_collections/microsoft/ad/tests/sanity/ignore-2.16.txt0
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/__init__.py0
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/compat/__init__.py0
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/compat/mock.py42
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/conftest.py43
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/mock/__init__.py0
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/mock/loader.py116
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/mock/path.py8
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/mock/procenv.py90
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/mock/vault_helper.py39
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/mock/yaml_helper.py124
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/modules/__init__.py0
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/modules/utils.py50
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/plugins/__init__.py0
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/plugins/filter/__init__.py0
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/plugins/filter/test_ldap_converters.py92
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/__init__.py0
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/__init__.py0
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_certificate.py638
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_laps.py213
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_schema.py156
-rw-r--r--ansible_collections/microsoft/ad/tests/unit/requirements.txt6
-rwxr-xr-xansible_collections/microsoft/ad/tests/utils/shippable/sanity.sh13
-rwxr-xr-xansible_collections/microsoft/ad/tests/utils/shippable/shippable.sh100
-rwxr-xr-xansible_collections/microsoft/ad/tests/utils/shippable/units.sh7
-rwxr-xr-xansible_collections/microsoft/ad/tests/utils/shippable/windows.sh21
94 files changed, 8445 insertions, 0 deletions
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/computer/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/computer/aliases
new file mode 100644
index 000000000..ccd8a25e8
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/computer/aliases
@@ -0,0 +1,2 @@
+windows
+shippable/windows/group1
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/computer/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/computer/meta/main.yml
new file mode 100644
index 000000000..4ce45dcfb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/computer/meta/main.yml
@@ -0,0 +1,2 @@
+dependencies:
+- setup_domain
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/main.yml
new file mode 100644
index 000000000..1e015c027
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/main.yml
@@ -0,0 +1,14 @@
+- name: remove temp computer
+ computer:
+ name: MyComputer
+ state: absent
+
+- block:
+ - import_tasks: tests.yml
+
+ always:
+ - name: remove temp computer
+ computer:
+ name: MyComputer
+ identity: '{{ object_identity | default(omit) }}'
+ state: absent
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/tests.yml b/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/tests.yml
new file mode 100644
index 000000000..fb4eee366
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/computer/tasks/tests.yml
@@ -0,0 +1,425 @@
+- name: create computer - check
+ computer:
+ name: MyComputer
+ state: present
+ register: create_comp_check
+ check_mode: true
+
+- name: get result of create computer - check
+ object_info:
+ identity: '{{ create_comp_check.distinguished_name }}'
+ register: create_comp_check_actual
+
+- name: assert create computer - check
+ assert:
+ that:
+ - create_comp_check is changed
+ - create_comp_check.distinguished_name == 'CN=MyComputer,CN=Computers,' ~ setup_domain_info.output[0].defaultNamingContext
+ - create_comp_check.object_guid == '00000000-0000-0000-0000-000000000000'
+ - create_comp_check.sid == 'S-1-5-0000'
+ - create_comp_check_actual.objects == []
+
+- name: create computer
+ computer:
+ name: MyComputer
+ state: present
+ register: create_comp
+
+- set_fact:
+ object_identity: '{{ create_comp.object_guid }}'
+
+- name: get result of create computer
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - dnsHostName
+ - objectSid
+ - sAMAccountName
+ - userAccountControl
+ register: create_comp_actual
+
+- name: assert create computer
+ assert:
+ that:
+ - create_comp is changed
+ - create_comp_actual.objects | length == 1
+ - create_comp.distinguished_name == 'CN=MyComputer,CN=Computers,' ~ setup_domain_info.output[0].defaultNamingContext
+ - create_comp.object_guid == create_comp_actual.objects[0].ObjectGUID
+ - create_comp.sid == create_comp_actual.objects[0].objectSid.Sid
+ - create_comp_actual.objects[0].DistinguishedName == create_comp.distinguished_name
+ - create_comp_actual.objects[0].Name == 'MyComputer'
+ - create_comp_actual.objects[0].dnsHostName == None
+ - create_comp_actual.objects[0].sAMAccountName == 'MyComputer$'
+ - create_comp_actual.objects[0].ObjectClass == 'computer'
+ - '"ADS_UF_ACCOUNTDISABLE" not in create_comp_actual.objects[0].userAccountControl_AnsibleFlags'
+
+- name: remove computer - check
+ computer:
+ name: MyComputer
+ state: absent
+ register: remove_comp_check
+ check_mode: true
+
+- name: get result of remove computer - check
+ object_info:
+ identity: '{{ object_identity }}'
+ register: remove_comp_check_actual
+
+- name: assert remove computer - check
+ assert:
+ that:
+ - remove_comp_check is changed
+ - remove_comp_check_actual.objects | length == 1
+
+- name: remove computer
+ computer:
+ name: MyComputer
+ state: absent
+ register: remove_comp
+
+- name: get result of remove computer
+ object_info:
+ identity: '{{ object_identity }}'
+ register: remove_comp_actual
+
+- name: assert remove computer
+ assert:
+ that:
+ - remove_comp is changed
+ - remove_comp_actual.objects == []
+
+- name: remove computer - idempotent
+ computer:
+ name: MyComputer
+ state: absent
+ register: remove_comp_again
+
+- name: assert remove computer - idempotent
+ assert:
+ that:
+ - not remove_comp_again is changed
+
+- name: create computer with custom options
+ computer:
+ name: MyComputer
+ state: present
+ delegates:
+ set:
+ - CN=krbtgt,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ kerberos_encryption_types:
+ set:
+ - aes128
+ - aes256
+ location: Comp Location
+ dns_hostname: MyComputer.domain.com
+ enabled: false
+ managed_by: Domain Admins
+ sam_account_name: SamMyComputer
+ spn:
+ set:
+ - HTTP/MyComputer
+ trusted_for_delegation: true
+ upn: MyComputer@{{ domain_realm }}
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ register: custom_comp
+
+- set_fact:
+ object_identity: '{{ custom_comp.object_guid }}'
+
+- name: get result of create computer with custom options
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - dnsHostName
+ - location
+ - managedBy
+ - msDS-AllowedToActOnBehalfOfOtherIdentity
+ - msDS-SupportedEncryptionTypes
+ - objectSid
+ - sAMAccountName
+ - servicePrincipalName
+ - userAccountControl
+ - userPrincipalName
+ register: custom_comp_actual
+
+- name: convert delegate SDDL to human readable string
+ ansible.windows.win_powershell:
+ parameters:
+ SDDL: '{{ custom_comp_actual.objects[0]["msDS-AllowedToActOnBehalfOfOtherIdentity"] }}'
+ script: |
+ param($SDDL)
+
+ $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity
+ $sd.SetSecurityDescriptorSddlForm($SDDL, 'All')
+ $sd.GetAccessRules($true, $false, [Type][System.Security.Principal.NTAccount]
+ ).IdentityReference.Value | ForEach-Object {
+ ($_ -split '\\', 2)[-1].ToLowerInvariant()
+ } | Sort-Object
+ register: custom_comp_delegates
+
+- name: assert create computer with custom options
+ assert:
+ that:
+ - custom_comp is changed
+ - custom_comp.distinguished_name == 'CN=MyComputer,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - custom_comp.object_guid == custom_comp_actual.objects[0].ObjectGUID
+ - custom_comp.sid == custom_comp_actual.objects[0].objectSid.Sid
+ - custom_comp_actual.objects[0].DistinguishedName == custom_comp.distinguished_name
+ - custom_comp_actual.objects[0].Name == 'MyComputer'
+ - custom_comp_actual.objects[0].dnsHostName == 'MyComputer.domain.com'
+ - custom_comp_actual.objects[0].location == 'Comp Location'
+ - custom_comp_actual.objects[0].managedBy == 'CN=Domain Admins,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - custom_comp_actual.objects[0]['msDS-SupportedEncryptionTypes'] == 24
+ - custom_comp_actual.objects[0]['msDS-SupportedEncryptionTypes_AnsibleFlags'] == ["AES128_CTS_HMAC_SHA1_96", "AES256_CTS_HMAC_SHA1_96"]
+ - custom_comp_actual.objects[0].sAMAccountName == 'SamMyComputer$'
+ - custom_comp_actual.objects[0].ObjectClass == 'computer'
+ - custom_comp_actual.objects[0].servicePrincipalName == 'HTTP/MyComputer'
+ - custom_comp_actual.objects[0].userPrincipalName == 'MyComputer@' ~ domain_realm
+ - '"ADS_UF_ACCOUNTDISABLE" in custom_comp_actual.objects[0].userAccountControl_AnsibleFlags'
+ - '"ADS_UF_TRUSTED_FOR_DELEGATION" in custom_comp_actual.objects[0].userAccountControl_AnsibleFlags'
+ - custom_comp_delegates.output == ["administrator", "krbtgt"]
+
+- name: change computer with custom options
+ computer:
+ name: MyComputer
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ delegates:
+ set:
+ - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ dns_hostname: other.domain.com
+ kerberos_encryption_types:
+ set:
+ - aes256
+ - rc4
+ location: comp location
+ enabled: true
+ sam_account_name: MyComputer2$
+ trusted_for_delegation: false
+ upn: mycomputer@{{ domain_realm }}
+ register: change_comp
+
+- name: get result of change computer with custom options
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - dnsHostName
+ - location
+ - msDS-AllowedToActOnBehalfOfOtherIdentity
+ - msDS-SupportedEncryptionTypes
+ - sAMAccountName
+ - userAccountControl
+ - userPrincipalName
+ register: change_comp_actual
+
+- name: convert delegate SDDL to human readable string
+ ansible.windows.win_powershell:
+ parameters:
+ SDDL: '{{ change_comp_actual.objects[0]["msDS-AllowedToActOnBehalfOfOtherIdentity"] }}'
+ script: |
+ param($SDDL)
+
+ $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity
+ $sd.SetSecurityDescriptorSddlForm($SDDL, 'All')
+ $sd.GetAccessRules($true, $false, [Type][System.Security.Principal.NTAccount]
+ ).IdentityReference.Value | ForEach-Object {
+ ($_ -split '\\', 2)[-1].ToLowerInvariant()
+ } | Sort-Object
+ register: change_comp_delegates
+
+- name: assert change computer with custom options
+ assert:
+ that:
+ - change_comp is changed
+ - change_comp_actual.objects[0].dnsHostName == 'other.domain.com'
+ - change_comp_actual.objects[0].location == 'comp location'
+ - change_comp_actual.objects[0]['msDS-SupportedEncryptionTypes'] == 20
+ - change_comp_actual.objects[0]['msDS-SupportedEncryptionTypes_AnsibleFlags'] == ["RC4_HMAC", "AES256_CTS_HMAC_SHA1_96"]
+ - change_comp_actual.objects[0].sAMAccountName == 'MyComputer2$'
+ - change_comp_actual.objects[0].userPrincipalName == 'mycomputer@' ~ domain_realm
+ - '"ADS_UF_ACCOUNTDISABLE" not in change_comp_actual.objects[0].userAccountControl_AnsibleFlags'
+ - '"ADS_UF_TRUSTED_FOR_DELEGATION" not in change_comp_actual.objects[0].userAccountControl_AnsibleFlags'
+ - change_comp_delegates.output == ["krbtgt"]
+
+- name: add and remove list options
+ computer:
+ name: MyComputer
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ delegates:
+ add:
+ - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ remove:
+ - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ - CN=Missing,{{ setup_domain_info.output[0].defaultNamingContext }}
+ kerberos_encryption_types:
+ add:
+ - aes128
+ - aes256
+ remove:
+ - rc4
+ register: add_remove_list
+
+- name: get result of add and remove list options
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - msDS-AllowedToActOnBehalfOfOtherIdentity
+ - msDS-SupportedEncryptionTypes
+ register: add_remove_list_actual
+
+- name: convert delegate SDDL to human readable string
+ ansible.windows.win_powershell:
+ parameters:
+ SDDL: '{{ add_remove_list_actual.objects[0]["msDS-AllowedToActOnBehalfOfOtherIdentity"] }}'
+ script: |
+ param($SDDL)
+
+ $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity
+ $sd.SetSecurityDescriptorSddlForm($SDDL, 'All')
+ $sd.GetAccessRules($true, $false, [Type][System.Security.Principal.NTAccount]
+ ).IdentityReference.Value | ForEach-Object {
+ ($_ -split '\\', 2)[-1].ToLowerInvariant()
+ } | Sort-Object
+ register: add_remove_list_delegation
+
+- name: assert add and remove list options
+ assert:
+ that:
+ - add_remove_list is changed
+ - add_remove_list_actual.objects[0]['msDS-SupportedEncryptionTypes'] == 24
+ - add_remove_list_actual.objects[0]['msDS-SupportedEncryptionTypes_AnsibleFlags'] == ["AES128_CTS_HMAC_SHA1_96", "AES256_CTS_HMAC_SHA1_96"]
+ - add_remove_list_delegation.output == ["administrator"]
+
+- name: add and remove list options - idempotent
+ computer:
+ name: MyComputer
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ delegates:
+ add:
+ - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ remove:
+ - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ - CN=Missing,{{ setup_domain_info.output[0].defaultNamingContext }}
+ kerberos_encryption_types:
+ add:
+ - aes128
+ - aes256
+ remove:
+ - rc4
+ register: add_remove_list_again
+
+- name: assert add and remove list options - idempotent
+ assert:
+ that:
+ - not add_remove_list_again is changed
+
+- name: unset list options
+ computer:
+ name: MyComputer
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ delegates:
+ set: []
+ kerberos_encryption_types:
+ set: []
+ register: unset_list_options
+
+- name: get result of unset list options
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - msDS-AllowedToActOnBehalfOfOtherIdentity
+ - msDS-SupportedEncryptionTypes
+ register: unset_list_options_actual
+
+- name: assert unset list options
+ assert:
+ that:
+ - unset_list_options is changed
+ - unset_list_options_actual.objects[0]['msDS-AllowedToActOnBehalfOfOtherIdentity'] == None
+ - unset_list_options_actual.objects[0]['msDS-SupportedEncryptionTypes'] == 0
+ - unset_list_options_actual.objects[0]['msDS-SupportedEncryptionTypes_AnsibleFlags'] == ["None"]
+
+- name: unset list options - idempotent
+ computer:
+ name: MyComputer
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ delegates:
+ set: []
+ kerberos_encryption_types:
+ set: []
+ register: unset_list_options_again
+
+- name: assert unset list options - idempotent
+ assert:
+ that:
+ - not unset_list_options_again is changed
+
+- name: set spns
+ computer:
+ name: MyComputer
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ spn:
+ set:
+ - HTTP/host
+ - HTTP/host.domain
+ - HTTP/host.domain:8080
+ register: spn_set
+
+- name: get result of set spns
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - servicePrincipalName
+ register: spn_set_actual
+
+- name: assert set spns
+ assert:
+ that:
+ - spn_set is changed
+ - spn_set_actual.objects[0].servicePrincipalName == ['HTTP/host.domain:8080', 'HTTP/host.domain', 'HTTP/host']
+
+- name: remove spns
+ computer:
+ name: MyComputer
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ spn:
+ remove:
+ - HTTP/fake
+ - HTTP/Host.domain
+ register: spn_remove
+
+- name: get result of remove spns
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - servicePrincipalName
+ register: spn_remove_actual
+
+- name: assert remove spns
+ assert:
+ that:
+ - spn_remove is changed
+ - spn_remove_actual.objects[0].servicePrincipalName == ['HTTP/host.domain:8080', 'HTTP/host']
+
+- name: add spns
+ computer:
+ name: MyComputer
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ spns:
+ add:
+ - HTTP/Host.domain:8080
+ - HTTP/fake
+ register: spn_add
+
+- name: get result of add spns
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - servicePrincipalName
+ register: spn_add_actual
+
+- name: assert add spns
+ assert:
+ that:
+ - spn_add is changed
+ - spn_add_actual.objects[0].servicePrincipalName == ['HTTP/fake', 'HTTP/host.domain:8080', 'HTTP/host']
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/aliases
new file mode 100644
index 000000000..ccd8a25e8
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/aliases
@@ -0,0 +1,2 @@
+windows
+shippable/windows/group1
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/tasks/main.yml
new file mode 100644
index 000000000..59a6d6d90
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/debug_ldap_client/tasks/main.yml
@@ -0,0 +1,17 @@
+# It's hard to ensure there's a common test environment with the same module
+# versions so just test that the plugin runs and has the base level return
+# values.
+
+- name: make sure module can run
+ debug_ldap_client:
+ register: res
+
+- name: assert return values
+ assert:
+ that:
+ - res.dns is defined
+ - res.kerberos is defined
+ - res.packages.dnspython is defined
+ - res.packages.krb5 is defined
+ - res.packages.pyspnego is defined
+ - res.packages.sansldap is defined
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/domain/aliases
new file mode 100644
index 000000000..ccd8a25e8
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain/aliases
@@ -0,0 +1,2 @@
+windows
+shippable/windows/group1
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain/meta/main.yml
new file mode 100644
index 000000000..025c08e43
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain/meta/main.yml
@@ -0,0 +1,4 @@
+dependencies:
+- name: setup_domain
+ vars:
+ run_domain_test: true
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/main.yml
new file mode 100644
index 000000000..c74df4f8d
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/main.yml
@@ -0,0 +1,66 @@
+- name: create domain - idempotent
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ register: domain_again
+
+- name: assert create domain - idempotent
+ assert:
+ that:
+ - not domain_again is changed
+ - domain_again.reboot_required == False
+
+- name: fail when reboot and async is used
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ reboot: true
+ async: 60
+ register: fail_reboot_async
+ failed_when:
+ - fail_reboot_async.msg != "async is not supported for this task when reboot=true"
+
+- name: fail when domain_netbios_name is greater than 15 character
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ domain_netbios_name: AReallyLongName1
+ register: fail_long_netbios_name
+ failed_when:
+ - fail_long_netbios_name.msg != "The parameter 'domain_netbios_name' should not exceed 15 characters in length"
+
+- name: get OS version
+ ansible.windows.win_powershell:
+ script: |
+ $Ansible.Changed = $false
+
+ $osVersion = [System.Environment]::OSVersion.Version
+ '{0}.{1}' -f $osVersion.Major, $osVersion.Minor
+ register: os_version
+
+- set_fact:
+ known_modes:
+ '6.2': Win2003, Win2008, Win2008R2, Win2012, Default
+ '6.3': Win2008, Win2008R2, Win2012, Win2012R2, Default
+ default: Win2008, Win2008R2, Win2012, Win2012R2, WinThreshold, Default
+
+- set_fact:
+ expected_modes: '{{ known_modes[os_version.output[0]] | default(known_modes["default"]) }}'
+
+- name: fail when domain_mode is invalid
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ domain_mode: fail
+ register: fail_domain_mode
+ failed_when:
+ - 'fail_domain_mode.msg != "The parameter ''domain_mode'' does not accept ''fail'', please use one of: " ~ expected_modes'
+
+- name: fail when forest_mode is invalid
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ forest_mode: fail
+ register: fail_forest_mode
+ failed_when:
+ - 'fail_forest_mode.msg != "The parameter ''forest_mode'' does not accept ''fail'', please use one of: " ~ expected_modes'
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/test.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/test.yml
new file mode 100644
index 000000000..d443d2556
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain/tasks/test.yml
@@ -0,0 +1,95 @@
+- name: setup domain with no prereqs - check mode
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ reboot: true
+ check_mode: true
+ register: domain_no_prereqs_check
+
+- name: assert setup domain with no prereqs - check mode
+ assert:
+ that:
+ - domain_no_prereqs_check is changed
+ - domain_no_prereqs_check.reboot_required == False
+
+- name: install feature pre-requisites
+ ansible.windows.win_feature:
+ name:
+ - AD-Domain-Services
+ - RSAT-ADDS
+ state: present
+
+- name: setup domain without reboot - check mode
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ check_mode: true
+ register: domain_check_no_reboot
+
+- name: assert setup domain without reboot - check mode
+ assert:
+ that:
+ - domain_check_no_reboot is changed
+ - domain_check_no_reboot.reboot_required == True
+
+# While not needed it puts the host in a state where it needs to reboot
+# before it can create the domain. This is testing an edge case.
+- name: rename host
+ ansible.windows.win_hostname:
+ name: ansible-ad-test
+
+- name: setup domain without reboot after reboot pending - check mode
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ check_mode: true
+ register: domain_check_no_reboot_pending
+
+- name: assert setup domain without reboot after reboot pending - check mode
+ assert:
+ that:
+ - domain_check_no_reboot_pending is changed
+ - domain_check_no_reboot_pending.reboot_required == True
+
+- name: setup domain with reboot - check mode
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ reboot: true
+ check_mode: true
+ register: domain_check_reboot
+
+- name: assert setup domain with reboot - check mode
+ assert:
+ that:
+ - domain_check_reboot is changed
+ - domain_check_reboot.reboot_required == False
+
+- name: setup domain without reboot
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ register: domain_no_reboot
+ ignore_errors: true
+
+- name: assert setup domain without reboot
+ assert:
+ that:
+ - not domain_no_reboot is changed
+ - domain_no_reboot is failed
+ - '"Failed to install ADDSForest" in domain_no_reboot.msg'
+ - '"A reboot is required" in domain_no_reboot.msg'
+ - domain_no_reboot.reboot_required == True
+
+- name: setup domain with reboot
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ reboot: true
+ register: domain_reboot
+
+- name: assert setup domain with reboot
+ assert:
+ that:
+ - domain_reboot is changed
+ - domain_reboot.reboot_required == False
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/README.md b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/README.md
new file mode 100644
index 000000000..0628d5335
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/README.md
@@ -0,0 +1,34 @@
+# microsoft.ad.domain_controller tests
+
+As this cannot be run in CI this is a brief guide on how to run these tests locally.
+Run the following:
+
+```bash
+vagrant up
+
+ansible-playbook setup.yml
+```
+
+It is a good idea to create a snapshot of both hosts before running the tests.
+This allows you to reset the host back to a blank starting state if the tests need to be rerun.
+To create a snaphost do the following:
+
+```bash
+virsh snapshot-create-as --domain "domain_controller_DC" --name "pretest"
+virsh snapshot-create-as --domain "domain_controller_TEST" --name "pretest"
+```
+
+To restore these snapshots run the following:
+
+```bash
+virsh snapshot-revert --domain "domain_controller_DC" --snapshotname "pretest" --running
+virsh snapshot-revert --domain "domain_controller_TEST" --snapshotname "pretest" --running
+```
+
+Once you are ready to run the tests run the following:
+
+```bash
+ansible-playbook test.yml
+```
+
+Run `vagrant destroy` to remove the test VMs.
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/Vagrantfile b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/Vagrantfile
new file mode 100644
index 000000000..5341185c3
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/Vagrantfile
@@ -0,0 +1,27 @@
+# -*- mode: ruby -*-
+# vi: set ft=ruby :
+
+require 'yaml'
+
+inventory = YAML.load_file('inventory.yml')
+
+Vagrant.configure("2") do |config|
+ inventory['all']['children'].each do |group,details|
+ details['hosts'].each do |server,host_details|
+ config.vm.define server do |srv|
+ srv.vm.box = host_details['vagrant_box']
+ srv.vm.hostname = server
+ srv.vm.network :private_network,
+ :ip => host_details['ansible_host'],
+ :libvirt__network_name => 'microsoft.ad',
+ :libvirt__domain_name => inventory['all']['vars']['domain_realm']
+
+ srv.vm.provider :libvirt do |l|
+ l.memory = 4096
+ l.cpus = 2
+ end
+ end
+ end
+ end
+end
+
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/aliases
new file mode 100644
index 000000000..435ff207d
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/aliases
@@ -0,0 +1,2 @@
+windows
+unsupported # can never run in CI, see README.md
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/ansible.cfg b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/ansible.cfg
new file mode 100644
index 000000000..3a986973e
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/ansible.cfg
@@ -0,0 +1,3 @@
+[defaults]
+inventory = inventory.yml
+retry_files_enabled = False
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/inventory.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/inventory.yml
new file mode 100644
index 000000000..3daa807df
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/inventory.yml
@@ -0,0 +1,21 @@
+all:
+ children:
+ windows:
+ hosts:
+ DC:
+ ansible_host: 192.168.11.10
+ vagrant_box: jborean93/WindowsServer2022
+ TEST:
+ ansible_host: 192.168.11.11
+ vagrant_box: jborean93/WindowsServer2022
+ vars:
+ ansible_port: 5985
+ ansible_connection: psrp
+
+ vars:
+ ansible_user: vagrant
+ ansible_password: vagrant
+ domain_username: vagrant-domain
+ domain_user_upn: '{{ domain_username }}@{{ domain_realm | upper }}'
+ domain_password: VagrantPass1
+ domain_realm: ad.test
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/setup.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/setup.yml
new file mode 100644
index 000000000..5fcc09dfd
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/setup.yml
@@ -0,0 +1,82 @@
+- name: create domain controller
+ hosts: DC
+ gather_facts: no
+
+ tasks:
+ - name: get network connection names
+ ansible.windows.win_powershell:
+ parameters:
+ IPAddress: '{{ ansible_host }}'
+ script: |
+ param ($IPAddress)
+
+ $Ansible.Changed = $false
+
+ Get-CimInstance -ClassName Win32_NetworkAdapter -Filter "Netenabled='True'" |
+ ForEach-Object -Process {
+ $config = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter "Index='$($_.Index)'"
+ if ($config.IPAddress -contains $IPAddress) {
+ $_.NetConnectionID
+ }
+ }
+ register: connection_name
+
+ - name: set the DNS for the internal adapters to localhost
+ ansible.windows.win_dns_client:
+ adapter_names:
+ - '{{ connection_name.output[0] }}'
+ dns_servers:
+ - 127.0.0.1
+
+ - name: ensure domain exists and DC is promoted as a domain controller
+ microsoft.ad.domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ reboot: true
+
+ - ansible.windows.win_feature:
+ name: RSAT-AD-PowerShell
+ state: present
+
+ - name: create domain username
+ microsoft.ad.user:
+ name: '{{ domain_username }}'
+ upn: '{{ domain_user_upn }}'
+ description: '{{ domain_username }} Domain Account'
+ password: '{{ domain_password }}'
+ password_never_expires: yes
+ update_password: when_changed
+ groups:
+ add:
+ - Domain Admins
+ state: present
+
+- name: setup test host
+ hosts: TEST
+ gather_facts: no
+
+ tasks:
+ - name: get network connection names
+ ansible.windows.win_powershell:
+ parameters:
+ IPAddress: '{{ ansible_host }}'
+ script: |
+ param ($IPAddress)
+
+ $Ansible.Changed = $false
+
+ Get-CimInstance -ClassName Win32_NetworkAdapter -Filter "Netenabled='True'" |
+ ForEach-Object -Process {
+ $config = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter "Index='$($_.Index)'"
+ if ($config.IPAddress -contains $IPAddress) {
+ $_.NetConnectionID
+ }
+ }
+ register: connection_name
+
+ - name: set DNS for the private adapter to point to the DC
+ ansible.windows.win_dns_client:
+ adapter_names:
+ - '{{ connection_name.output[0] }}'
+ dns_servers:
+ - '{{ hostvars["DC"]["ansible_host"] }}'
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/tasks/main.yml
new file mode 100644
index 000000000..b1288d093
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/tasks/main.yml
@@ -0,0 +1,232 @@
+- set_fact:
+ get_role_script: |
+ $Ansible.Changed = $false
+ Get-CimInstance -ClassName Win32_ComputerSystem -Property Domain, DomainRole, PartOfDomain |
+ Select-Object -Property @{
+ N = 'Domain'
+ E = {
+ if ($_.PartOfDomain) {
+ $_.Domain
+ }
+ else {
+ $null
+ }
+ }
+ }, @{
+ N = 'DomainRole'
+ E = {
+ switch ($_.DomainRole) {
+ 0 { "StandaloneWorkstation" }
+ 1 { "MemberWorkstation" }
+ 2 { "StandaloneServer" }
+ 3 { "MemberServer" }
+ 4 { "BackupDC" }
+ 5 { "PrimaryDC" }
+ }
+ }
+ }, @{
+ N = 'HostName'
+ E = { $env:COMPUTERNAME }
+ }
+
+- name: test no change when not a DC
+ domain_controller:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ local_admin_password: '{{ domain_password }}'
+ state: member_server
+ reboot: true
+ register: not_dc_no_change
+
+- name: assert test no change when not a DC
+ assert:
+ that:
+ - not not_dc_no_change is changed
+ - not_dc_no_change.reboot_required == False
+
+- name: promote to DC - check mode
+ domain_controller:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ safe_mode_password: '{{ domain_password }}'
+ state: domain_controller
+ reboot: true
+ register: to_dc_check
+ check_mode: true
+
+- name: get result of promote to DC - check mode
+ ansible.windows.win_powershell:
+ script: '{{ get_role_script }}'
+ register: to_dc_check_actual
+
+- name: assert promote to DC - check mode
+ assert:
+ that:
+ - to_dc_check is changed
+ - to_dc_check_actual.output[0]["Domain"] == None
+ - to_dc_check_actual.output[0]["DomainRole"] == "StandaloneServer"
+
+- name: change hostname to have a pending change before promotion
+ ansible.windows.win_hostname:
+ name: FOO
+
+- name: promote to DC with pending reboot
+ domain_controller:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ safe_mode_password: '{{ domain_password }}'
+ state: domain_controller
+ reboot: true
+ register: to_dc
+
+- name: get result of promote to DC with pending reboot
+ ansible.windows.win_powershell:
+ script: '{{ get_role_script }}'
+ register: to_dc_actual
+
+- name: assert promote to DC with pending reboot
+ assert:
+ that:
+ - to_dc is changed
+ - to_dc.reboot_required == False
+ - to_dc_actual.output[0]["Domain"] == domain_realm
+ - to_dc_actual.output[0]["DomainRole"] == "BackupDC"
+ - to_dc_actual.output[0]["HostName"] == "FOO"
+
+- name: promote to DC - idempotent
+ domain_controller:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ safe_mode_password: '{{ domain_password }}'
+ state: domain_controller
+ reboot: true
+ register: to_dc_again
+
+- name: assert promote to DC - idempotent
+ assert:
+ that:
+ - not to_dc_again is changed
+ - to_dc_again.reboot_required == False
+
+# The following operations will run with the domain admin account now that the
+# host is joined to the domain
+- name: change connection user and password to domain account
+ set_fact:
+ ansible_user: '{{ domain_user_upn }}'
+ ansible_password: '{{ domain_password }}'
+
+- name: fail to change domain of DC
+ domain_controller:
+ dns_domain_name: bogus.local
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ safe_mode_password: '{{ domain_password }}'
+ state: domain_controller
+ reboot: true
+ register: change_domain_fail
+ failed_when:
+ - change_domain_fail.msg != "The host FOO is a domain controller for the domain " ~ domain_realm ~ "; changing DC domains is not implemented"
+
+- name: fail with invalid username format
+ domain_controller:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_username }}' # Must be a UPN or Netbios Name
+ domain_admin_password: '{{ domain_password }}'
+ safe_mode_password: '{{ domain_password }}'
+ state: domain_controller
+ reboot: true
+ register: invalid_user_fail
+ failed_when:
+ - invalid_user_fail.msg != "domain_admin_user must be in domain\\user or user@domain.com format"
+
+- name: set DC as member server - check mode
+ domain_controller:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ local_admin_password: '{{ domain_password }}'
+ state: member_server
+ reboot: true
+ register: member_server_check
+ check_mode: true
+
+- name: get result of set DC as member server - check mode
+ ansible.windows.win_powershell:
+ script: '{{ get_role_script }}'
+ register: member_server_check_actual
+
+- name: assert set DC as member server - check mode
+ assert:
+ that:
+ - member_server_check is changed
+ - member_server_check.reboot_required == False
+ - member_server_check_actual.output[0]["Domain"] == domain_realm
+ - member_server_check_actual.output[0]["DomainRole"] == "BackupDC"
+
+- name: set DC as member server
+ domain_controller:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ local_admin_password: '{{ domain_password }}'
+ state: member_server
+ reboot: true
+ register: member_server
+
+- name: get result of set DC as member server
+ ansible.windows.win_powershell:
+ script: '{{ get_role_script }}'
+ register: member_server_actual
+
+- name: assert set DC as member server
+ assert:
+ that:
+ - member_server is changed
+ - member_server.reboot_required == False
+ - member_server_actual.output[0]["Domain"] == domain_realm
+ - member_server_actual.output[0]["DomainRole"] == "MemberServer"
+
+- name: set DC as member server - idempotent
+ domain_controller:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ local_admin_password: '{{ domain_password }}'
+ state: member_server
+ reboot: true
+ register: member_server_again
+
+- name: assert set DC as member server - idempotent
+ assert:
+ that:
+ - not member_server_again is changed
+ - member_server_again.reboot_required == False
+
+# Promote it once more to a DC to test an edge case where Ansible is unable to
+# connect back until a reboot has occurred.
+- name: promote to DC again
+ domain_controller:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ safe_mode_password: '{{ domain_password }}'
+ state: domain_controller
+ reboot: true
+ register: to_dc_manual_reboot
+
+- name: get result of promote to DC with manual reboot
+ ansible.windows.win_powershell:
+ script: '{{ get_role_script }}'
+ register: to_dc_manual_reboot_actual
+
+- name: assert promote to DC with manual reboot
+ assert:
+ that:
+ - to_dc_manual_reboot is changed
+ - to_dc_manual_reboot.reboot_required == False
+ - to_dc_manual_reboot_actual.output[0]["Domain"] == domain_realm
+ - to_dc_manual_reboot_actual.output[0]["DomainRole"] == "BackupDC"
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/test.yml b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/test.yml
new file mode 100644
index 000000000..69034d12f
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/domain_controller/test.yml
@@ -0,0 +1,30 @@
+- name: ensure time is in sync
+ hosts: windows
+ gather_facts: no
+ tasks:
+ - name: get current host datetime
+ command: date +%s
+ changed_when: False
+ delegate_to: localhost
+ run_once: True
+ register: local_time
+
+ - name: set datetime on Windows
+ ansible.windows.win_powershell:
+ parameters:
+ SecondsSinceEpoch: '{{ local_time.stdout | trim }}'
+ script: |
+ param($SecondsSinceEpoch)
+
+ $utc = [System.DateTimeKind]::Utc
+ $epoch = New-Object -TypeName System.DateTime -ArgumentList 1970, 1, 1, 0, 0, 0, 0, $utc
+ $date = $epoch.AddSeconds($SecondsSinceEpoch)
+
+ Set-Date -Date $date
+
+- name: run microsoft.ad.domain_controller tests
+ hosts: TEST
+ gather_facts: no
+
+ tasks:
+ - import_tasks: tasks/main.yml
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/group/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/group/aliases
new file mode 100644
index 000000000..ccd8a25e8
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/group/aliases
@@ -0,0 +1,2 @@
+windows
+shippable/windows/group1
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/group/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/group/meta/main.yml
new file mode 100644
index 000000000..4ce45dcfb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/group/meta/main.yml
@@ -0,0 +1,2 @@
+dependencies:
+- setup_domain
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/main.yml
new file mode 100644
index 000000000..b5291e36c
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/main.yml
@@ -0,0 +1,14 @@
+- name: remove temp group
+ group:
+ name: MyGroup
+ state: absent
+
+- block:
+ - import_tasks: tests.yml
+
+ always:
+ - name: remove temp group
+ group:
+ name: MyGroup
+ identity: '{{ object_identity | default(omit) }}'
+ state: absent
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/tests.yml b/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/tests.yml
new file mode 100644
index 000000000..bdb1b95b7
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/group/tasks/tests.yml
@@ -0,0 +1,516 @@
+- name: fail to create group without scope
+ group:
+ name: MyGroup
+ state: present
+ register: fail_no_scope
+ failed_when: fail_no_scope.msg != "scope must be set when state=present and the group does not exist"
+
+- name: create group - check
+ group:
+ name: MyGroup
+ state: present
+ scope: global
+ register: create_group_check
+ check_mode: true
+
+- name: get result of create group - check
+ object_info:
+ identity: '{{ create_group_check.distinguished_name }}'
+ register: create_group_check_actual
+
+- name: assert create group - check
+ assert:
+ that:
+ - create_group_check is changed
+ - create_group_check.distinguished_name == 'CN=MyGroup,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - create_group_check.object_guid == '00000000-0000-0000-0000-000000000000'
+ - create_group_check.sid == 'S-1-5-0000'
+ - create_group_check_actual.objects == []
+
+- name: create group
+ group:
+ name: MyGroup
+ state: present
+ scope: global
+ register: create_group
+
+- set_fact:
+ object_identity: '{{ create_group.object_guid }}'
+
+- name: get result of create group
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - groupType
+ - objectSid
+ register: create_group_actual
+
+- name: assert create group
+ assert:
+ that:
+ - create_group is changed
+ - create_group.distinguished_name == 'CN=MyGroup,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - create_group_actual.objects | length == 1
+ - create_group.object_guid == create_group_actual.objects[0].ObjectGUID
+ - create_group.sid == create_group_actual.objects[0].objectSid.Sid
+ - create_group_actual.objects[0].groupType_AnsibleFlags == ["GROUP_TYPE_ACCOUNT_GROUP", "GROUP_TYPE_SECURITY_ENABLED"]
+
+- name: create group - idempotent
+ group:
+ name: MyGroup
+ state: present
+ scope: global
+ register: create_group_again
+
+- name: assert create group - idempotent
+ assert:
+ that:
+ - not create_group_again is changed
+
+- name: create ou to store group members
+ ou:
+ name: MyOU
+ state: present
+ register: ou_info
+
+- block:
+ - name: create test users
+ user:
+ name: My User {{ item }}
+ sam_account_name: my_user_{{ item }}
+ upn: user_{{ item }}@{{ domain_realm }}
+ state: present
+ path: '{{ ou_info.distinguished_name }}'
+ register: test_users
+ loop:
+ - 1
+ - 2
+ - 3
+ - 4
+
+ - name: fail to find members to add to a group
+ group:
+ name: MyGroup
+ state: present
+ members:
+ add:
+ - my_user_1
+ - fake-user
+ - my_user_2
+ - another-user
+ register: fail_invalid_members
+ failed_when: 'fail_invalid_members.msg != "Failed to find the following ad objects for group members: ''fake-user'', ''another-user''"'
+
+ - name: add members to a group - check
+ group:
+ name: MyGroup
+ state: present
+ members:
+ add:
+ - my_user_1
+ - '{{ test_users.results[2].sid }}'
+ register: add_member_check
+ check_mode: true
+
+ - name: get result of add members to a group - check
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - member
+ register: add_member_check_actual
+
+ - name: assert add members to a group - check
+ assert:
+ that:
+ - add_member_check is changed
+ - add_member_check_actual.objects[0].member == None
+
+ - name: add members to a group
+ group:
+ name: MyGroup
+ state: present
+ members:
+ add:
+ - my_user_1
+ - '{{ test_users.results[2].sid }}'
+ register: add_member
+
+ - name: get result of add members to a group
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - member
+ register: add_member_actual
+
+ - name: assert add members to a group
+ assert:
+ that:
+ - add_member is changed
+ - add_member_actual.objects[0].member | length == 2
+ - test_users.results[0].distinguished_name in add_member_actual.objects[0].member
+ - test_users.results[2].distinguished_name in add_member_actual.objects[0].member
+
+ - name: add members to a group - idempotent
+ group:
+ name: MyGroup
+ state: present
+ members:
+ add:
+ - user_1@{{ domain_realm }}
+ - '{{ test_users.results[2].object_guid }}'
+ register: add_member_again
+
+ - name: assert add members to a group - idempotent
+ assert:
+ that:
+ - not add_member_again is changed
+
+ - name: remove member from a group
+ group:
+ name: MyGroup
+ state: present
+ members:
+ remove:
+ - '{{ test_users.results[0].distinguished_name | upper }}'
+ - my_user_2
+ register: remove_member
+
+ - name: get result of remove member from a group
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - member
+ register: remove_member_actual
+
+ - name: assert remove member from a group
+ assert:
+ that:
+ - remove_member is changed
+ - remove_member_actual.objects[0].member == test_users.results[2].distinguished_name
+
+ - name: remove member from a group - idempotent
+ group:
+ name: MyGroup
+ state: present
+ members:
+ remove:
+ - '{{ test_users.results[0].object_guid }}'
+ register: remove_member_again
+
+ - name: assert remove member from a group - idempotent
+ assert:
+ that:
+ - not remove_member_again is changed
+
+ - name: add and remove members from a group
+ group:
+ name: MyGroup
+ state: present
+ members:
+ add:
+ - my_user_1
+ - user_2@{{ domain_realm }}
+ remove:
+ - my_user_3
+ - my_user_4
+ register: add_remove_member
+
+ - name: get result of add and remove members from a group
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - member
+ register: add_remove_member_actual
+
+ - name: assert add and remove members from a group
+ assert:
+ that:
+ - add_remove_member is changed
+ - add_remove_member_actual.objects[0].member | length == 2
+ - test_users.results[0].distinguished_name in add_remove_member_actual.objects[0].member
+ - test_users.results[1].distinguished_name in add_remove_member_actual.objects[0].member
+
+ - name: set members
+ group:
+ name: MyGroup
+ state: present
+ members:
+ set:
+ - my_user_1
+ - my_user_3
+ register: set_member
+
+ - name: get result of set members
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - member
+ register: set_member_actual
+
+ - name: assert set members
+ assert:
+ that:
+ - set_member is changed
+ - set_member_actual.objects[0].member | length == 2
+ - test_users.results[0].distinguished_name in set_member_actual.objects[0].member
+ - test_users.results[2].distinguished_name in set_member_actual.objects[0].member
+
+ - name: set members - idempotent
+ group:
+ name: MyGroup
+ state: present
+ members:
+ set:
+ - My_user_1
+ - '{{ test_users.results[2].sid }}'
+ register: set_member_again
+
+ - name: assert set members - idempotent
+ assert:
+ that:
+ - not set_member_again is changed
+
+ - name: unset all members
+ group:
+ name: MyGroup
+ state: present
+ members:
+ set: []
+ register: unset_member
+
+ - name: get result of unset all members
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - member
+ register: unset_member_actual
+
+ - name: assert unset all members
+ assert:
+ that:
+ - unset_member is changed
+ - unset_member_actual.objects[0].member == None
+
+ - name: unset all members - idempotent
+ group:
+ name: MyGroup
+ state: present
+ members:
+ set: []
+ register: unset_member_again
+
+ - name: assert unset all members - idempotent
+ assert:
+ that:
+ - not unset_member_again is changed
+
+ - name: remove group - check
+ group:
+ name: MyGroup
+ state: absent
+ register: remove_group_check
+ check_mode: true
+
+ - name: get result of remove group - check
+ object_info:
+ identity: '{{ object_identity }}'
+ register: remove_group_check_actual
+
+ - name: assert remove group - check
+ assert:
+ that:
+ - remove_group_check is changed
+ - remove_group_check_actual.objects | length == 1
+
+ - name: remove group
+ group:
+ name: MyGroup
+ state: absent
+ register: remove_group
+
+ - name: get result of remove group
+ object_info:
+ identity: '{{ object_identity }}'
+ register: remove_group_actual
+
+ - name: assert remove group
+ assert:
+ that:
+ - remove_group is changed
+ - remove_group_actual.objects == []
+
+ - name: remove group - idempotent
+ group:
+ name: MyGroup
+ state: absent
+ register: remove_group_again
+
+ - name: assert remove group - idempotent
+ assert:
+ that:
+ - not remove_group_again is changed
+
+ - name: fail to create group with invalid members
+ group:
+ name: MyGroup
+ state: present
+ scope: domainlocal
+ members:
+ add:
+ - my_user_1
+ - fake-user
+ - my_user_2
+ - another-user
+ register: fail_invalid_members
+ failed_when: 'fail_invalid_members.msg != "Failed to find the following ad objects for group members: ''fake-user'', ''another-user''"'
+
+ - name: create group with custom options
+ group:
+ name: MyGroup
+ state: present
+ path: '{{ ou_info.distinguished_name }}'
+ display_name: My Display Name
+ description: My Description
+ scope: domainlocal
+ category: distribution
+ homepage: www.ansible.com
+ managed_by: Domain Admins
+ members:
+ add:
+ - my_user_1
+ - '{{ test_users.results[1].object_guid }}'
+ set:
+ - '{{ test_users.results[2].sid }}'
+ sam_account_name: GroupSAM
+ register: group_custom
+
+ - set_fact:
+ object_identity: '{{ group_custom.object_guid }}'
+
+ - name: get result of create group with custom options
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - Description
+ - DisplayName
+ - groupType
+ - managedBy
+ - member
+ - objectSid
+ - wWWHomePage
+ - sAMAccountName
+ register: group_custom_actual
+
+ - name: assert create group with custom options
+ assert:
+ that:
+ - group_custom is changed
+ - group_custom.distinguished_name == "CN=MyGroup," ~ ou_info.distinguished_name
+ - group_custom_actual.objects[0].DistinguishedName == group_custom.distinguished_name
+ - group_custom_actual.objects[0].ObjectGUID == group_custom.object_guid
+ - group_custom_actual.objects[0].objectSid.Sid == group_custom.sid
+ - group_custom_actual.objects[0].Description == 'My Description'
+ - group_custom_actual.objects[0].DisplayName == 'My Display Name'
+ - group_custom_actual.objects[0].Name == 'MyGroup'
+ - group_custom_actual.objects[0].groupType_AnsibleFlags == ["GROUP_TYPE_RESOURCE_GROUP"]
+ - group_custom_actual.objects[0].managedBy == "CN=Domain Admins,CN=Users," ~ setup_domain_info.output[0].defaultNamingContext
+ - group_custom_actual.objects[0].member | length == 3
+ - test_users.results[0].distinguished_name in group_custom_actual.objects[0].member
+ - test_users.results[1].distinguished_name in group_custom_actual.objects[0].member
+ - test_users.results[2].distinguished_name in group_custom_actual.objects[0].member
+ - group_custom_actual.objects[0].sAMAccountName == "GroupSAM"
+ - group_custom_actual.objects[0].wWWHomePage == "www.ansible.com"
+
+ - name: create group with custom options - idempotent
+ group:
+ name: MyGroup
+ state: present
+ path: '{{ ou_info.distinguished_name }}'
+ display_name: My Display Name
+ description: My Description
+ scope: domainlocal
+ category: distribution
+ homepage: www.ansible.com
+ managed_by: CN=Domain Admins,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ members:
+ add:
+ - my_user_1
+ - '{{ test_users.results[1].object_guid }}'
+ - '{{ test_users.results[2].sid }}'
+ sam_account_name: GroupSAM
+ register: group_custom_again
+
+ - name: assert create group with custom options - idempotent
+ assert:
+ that:
+ - group_custom_again is not changed
+
+ - name: edit group
+ group:
+ name: MyGroup
+ state: present
+ path: '{{ ou_info.distinguished_name }}'
+ display_name: my display name
+ description: ''
+ homepage: www.Ansible.com
+ members:
+ set: []
+ sam_account_name: MyGroup
+ register: group_edit
+
+ - name: get result of edit group
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - Description
+ - DisplayName
+ - groupType
+ - member
+ - objectSid
+ - wWWHomePage
+ - sAMAccountName
+ register: group_edit_actual
+
+ - name: assert edit group
+ assert:
+ that:
+ - group_edit is changed
+ - group_edit_actual.objects[0].DistinguishedName == group_edit.distinguished_name
+ - group_edit_actual.objects[0].ObjectGUID == group_edit.object_guid
+ - group_edit_actual.objects[0].objectSid.Sid == group_edit.sid
+ - group_edit_actual.objects[0].Description == None
+ - group_edit_actual.objects[0].DisplayName == 'my display name'
+ - group_edit_actual.objects[0].Name == 'MyGroup'
+ - group_edit_actual.objects[0].groupType_AnsibleFlags == ["GROUP_TYPE_RESOURCE_GROUP"]
+ - group_edit_actual.objects[0].member == None
+ - group_edit_actual.objects[0].sAMAccountName == "MyGroup"
+ - group_edit_actual.objects[0].wWWHomePage == "www.Ansible.com"
+
+ - name: edit group scope and category
+ group:
+ name: MyGroup
+ state: present
+ path: '{{ ou_info.distinguished_name }}'
+ scope: universal
+ category: security
+ register: edit_scope
+
+ - name: get result of edit group scope and category
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - groupType
+ register: edit_scope_actual
+
+ - name: assert edit group scope and category
+ assert:
+ that:
+ - edit_scope is changed
+ - edit_scope_actual.objects[0].groupType_AnsibleFlags == ["GROUP_TYPE_UNIVERSAL_GROUP", "GROUP_TYPE_SECURITY_ENABLED"]
+
+ always:
+ - name: remove test ou
+ ou:
+ name: MyOU
+ state: absent
+ identity: '{{ ou_info.object_guid }}'
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/aliases
new file mode 100644
index 000000000..b92c22d5a
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/aliases
@@ -0,0 +1,3 @@
+windows
+shippable/windows/group1
+needs/target/setup_domain
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/main.yml
new file mode 100644
index 000000000..14b4ae33a
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/main.yml
@@ -0,0 +1,25 @@
+- name: run microsoft.ad.ldap tests
+ hosts: windows
+ gather_facts: false
+
+ tasks:
+ - name: setup domain controller
+ import_role:
+ name: ../../setup_domain
+
+ - name: setup domain certificates
+ import_role:
+ name: setup_certificate
+ vars:
+ dc_name: '{{ setup_domain_info.output[0].dnsHostName }}'
+ cert_path: /tmp/microsoft.ad-{{ inventory_hostname }}
+
+ - name: run tests
+ import_role:
+ name: test
+ vars:
+ ldap_server: '{{ ansible_host | default(inventory_hostname) }}'
+ ldap_user: ldap-test@{{ domain_realm }}
+ ldap_pass: '{{ domain_password }}'
+ ldap_user_cert: /tmp/microsoft.ad-{{ inventory_hostname }}/user.pfx
+ ldap_ca_cert: /tmp/microsoft.ad-{{ inventory_hostname }}/ca.pem
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/files/generate_cert.sh b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/files/generate_cert.sh
new file mode 100644
index 000000000..365757307
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/files/generate_cert.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+
+set -o pipefail -eux
+
+TARGET="${1}"
+PASSWORD="${2}"
+
+generate () {
+ NAME="${1}"
+ SUBJECT="${2}"
+ KEY="${3}"
+ CA_NAME="${4}"
+ CA_OPTIONS=("-CA" "${CA_NAME}.pem" "-CAkey" "${CA_NAME}.key" "-CAcreateserial")
+
+ cat > openssl.conf << EOL
+distinguished_name = req_distinguished_name
+
+[req_distinguished_name]
+
+[req]
+basicConstraints = CA:FALSE
+keyUsage = digitalSignature,keyEncipherment
+extendedKeyUsage = serverAuth
+subjectAltName = DNS:${SUBJECT}
+EOL
+
+ echo "Generating ${NAME} signed cert"
+ openssl req \
+ -new \
+ "-${KEY}" \
+ -subj "/CN=${SUBJECT}" \
+ -newkey rsa:2048 \
+ -keyout "${NAME}.key" \
+ -out "${NAME}.csr" \
+ -config openssl.conf \
+ -reqexts req \
+ -passin pass:"${PASSWORD}" \
+ -passout pass:"${PASSWORD}"
+
+ openssl x509 \
+ -req \
+ -in "${NAME}.csr" \
+ "-${KEY}" \
+ -out "${NAME}.pem" \
+ -days 365 \
+ -extfile openssl.conf \
+ -extensions req \
+ -passin pass:"${PASSWORD}" \
+ "${CA_OPTIONS[@]}"
+
+ # PBE-SHA1-3DES/nomac is used for compatibility with Server 2016 and older
+ openssl pkcs12 \
+ -export \
+ -out "${NAME}.pfx" \
+ -inkey "${NAME}.key" \
+ -in "${NAME}.pem" \
+ -keypbe PBE-SHA1-3DES \
+ -certpbe PBE-SHA1-3DES \
+ -nomac \
+ -passin pass:"${PASSWORD}" \
+ -passout pass:"${PASSWORD}"
+
+ rm openssl.conf
+}
+
+echo "Generating CA certificate"
+openssl genrsa \
+ -aes256 \
+ -out ca.key \
+ -passout pass:"${PASSWORD}"
+
+openssl req \
+ -new \
+ -x509 \
+ -days 365 \
+ -key ca.key \
+ -out ca.pem \
+ -subj "/CN=microsoft.ad root" \
+ -passin pass:"${PASSWORD}"
+
+echo "Generating ${TARGET} LDAPS certificate"
+generate ldaps "${TARGET}" sha256 ca
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/handlers/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/handlers/main.yml
new file mode 100644
index 000000000..471b5f24b
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/handlers/main.yml
@@ -0,0 +1,10 @@
+- name: remove test user
+ microsoft.ad.user:
+ name: ldap-test
+ state: absent
+
+- name: remove test user cert
+ ansible.windows.win_file:
+ path: C:\Windows\TEMP\user.pfx
+ state: absent
+ \ No newline at end of file
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/tasks/main.yml
new file mode 100644
index 000000000..8858e20cc
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/setup_certificate/tasks/main.yml
@@ -0,0 +1,196 @@
+- name: install Active Directory Certificate Services
+ ansible.windows.win_feature:
+ name: AD-Certificate
+ state: present
+ register: adcs_setup_res
+
+- name: reboot after ADCS install
+ ansible.windows.win_reboot:
+ when: adcs_setup_res.reboot_required
+
+- name: configure ADCS certification authority
+ ansible.windows.win_powershell:
+ script: |
+ $ErrorActionPreference = 'Stop'
+ $Ansible.Changed = $false
+
+ $caParams = @{
+ CAType = 'EnterpriseRootCa'
+ CryptoProviderName = 'RSA#Microsoft Software Key Storage Provider'
+ KeyLength = 2048
+ HashAlgorithmName = 'SHA256'
+ Force = $true
+ }
+ try {
+ Install-AdcsCertificationAuthority @caParams
+ $Ansible.Changed = $true
+ }
+ catch [Microsoft.CertificateServices.Deployment.Common.CertificateServicesBaseSetupException] {
+ if ($_.Exception.Message -like 'The Certification Authority is already installed.*') {
+ return
+ }
+ throw
+ }
+ become: true
+ become_method: runas
+ become_user: SYSTEM
+
+- name: name ensure local cert dir exists
+ ansible.builtin.file:
+ path: '{{ cert_path }}'
+ state: directory
+ delegate_to: localhost
+
+- name: check if certificates have been generated
+ ansible.windows.win_stat:
+ path: C:\Windows\TEMP\ca.pem
+ register: cert_info
+
+- name: fetch CA cert from remote
+ ansible.builtin.fetch:
+ src: C:\Windows\TEMP\ca.pem
+ dest: '{{ cert_path }}/ca.pem'
+ flat: true
+ when: cert_info.stat.exists
+
+- name: generate CA and LDAPS certs
+ when: not cert_info.stat.exists
+ block:
+ - name: generate TLS certificates
+ ansible.builtin.script:
+ cmd: generate_cert.sh {{ dc_name | quote }} password
+ creates: '{{ cert_path }}/ca.pem'
+ chdir: '{{ cert_path }}'
+ delegate_to: localhost
+
+ - name: copy across CA and LDAPS cert to remote target
+ ansible.windows.win_copy:
+ src: '{{ cert_path }}/{{ item }}'
+ dest: C:\Windows\TEMP\{{ item }}
+ loop:
+ - ca.pem
+ - ldaps.pfx
+
+- name: import CA certificate to trusted root CA
+ ansible.windows.win_certificate_store:
+ path: C:\Windows\TEMP\ca.pem
+ state: present
+ store_location: LocalMachine
+ store_name: Root
+
+- name: add custom CA to Forest NTAuthStore
+ ansible.windows.win_powershell:
+ script: |
+ $ErrorActionPreference = 'Stop'
+ $Ansible.Changed = $false
+
+ $caCert = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList 'C:\Windows\TEMP\ca.pem'
+ $configRoot = (Get-ADRootDSE).configurationNamingContext
+
+ $dn = "CN=NTAuthCertificates,CN=Public Key Services,CN=Services,$configRoot"
+ $obj = Get-ADObject -Identity $dn -Properties cACertificate
+
+ $found = $false
+ foreach ($certBytes in $obj.cACertificate) {
+ $cert = New-Object -TypeName System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @(,$certBytes)
+ if ($cert.Thumbprint -eq $caCert.Thumbprint) {
+ $found = $true
+ break
+ }
+ }
+
+ if (-not $found) {
+ certutil.exe -dspublish C:\Windows\TEMP\ca.pem NTAuthCA
+ $Ansible.Changed = $true
+ }
+ become: true
+ become_method: runas
+ become_user: SYSTEM
+
+- name: create domain user
+ microsoft.ad.user:
+ name: ldap-test
+ upn: ldap-test@{{ domain_realm }}
+ description: ldap-test Domain Account
+ password: '{{ domain_password }}'
+ password_never_expires: true
+ update_password: when_changed
+ groups:
+ set:
+ - Domain Admins
+ - Domain Users
+ - Enterprise Admins
+ state: present
+ notify: remove test user
+
+- name: request User certificate
+ ansible.windows.win_powershell:
+ parameters:
+ Path: C:\Windows\TEMP\user.pfx
+ CertPass: '{{ domain_password }}'
+ script: |
+ [CmdletBinding()]
+ param (
+ [string]
+ $Path,
+
+ [string]
+ $CertPass
+ )
+ $ErrorActionPreference = 'Stop'
+ $Ansible.Changed = $false
+
+ if (Test-Path -LiteralPath $Path) {
+ return
+ }
+
+ Push-Location Cert:\CurrentUser\My
+ $result = Get-Certificate -Template User -Url ldap:
+ Pop-Location
+
+ if ($result.Status -ne "Issued") {
+ throw "Failed to request User certificate: $($result.Status)"
+ }
+ $Ansible.Changed = $true
+
+ $cert = $result.Certificate
+ $certBytes = $result.Certificate.Export("Pfx", $CertPass)
+ [System.IO.File]::WriteAllBytes($Path, $certBytes)
+ notify: remove test user cert
+ vars:
+ ansible_become: true
+ ansible_become_method: runas
+ ansible_become_user: ldap-test@{{ domain_realm }}
+ ansible_become_pass: '{{ domain_password }}'
+
+- name: fetch certificate for user cert authentication
+ ansible.builtin.fetch:
+ src: C:\Windows\TEMP\user.pfx
+ dest: '{{ cert_path }}/user.pfx'
+ flat: true
+
+- name: import LDAPS certificate
+ ansible.windows.win_certificate_store:
+ path: C:\Windows\TEMP\ldaps.pfx
+ password: password
+ key_exportable: false
+ key_storage: machine
+ state: present
+ store_type: service
+ store_location: NTDS
+ store_name: My
+ register: ldaps_cert_info
+
+- name: register LDAPS certificate
+ ansible.windows.win_powershell:
+ script: |
+ $ErrorActionPreference = 'Stop'
+ $dse = [adsi]'LDAP://localhost/rootDSE'
+ [void]$dse.Properties['renewServerCertificate'].Add(1)
+ $dse.CommitChanges()
+ when: ldaps_cert_info is changed
+ vars:
+ ansible_become: true
+ ansible_become_method: runas
+ ansible_become_user: ldap-test@{{ domain_realm }}
+ ansible_become_pass: '{{ domain_password }}'
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/invoke.yml b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/invoke.yml
new file mode 100644
index 000000000..722effb22
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/invoke.yml
@@ -0,0 +1,14 @@
+- name: '{{ scenario }} - create temp inventory file'
+ ansible.builtin.copy:
+ content: '{{ inventory | to_nice_yaml(sort_keys=False) }}'
+ dest: /tmp/tmp-microsoft.ad.ldap.yml
+ delegate_to: localhost
+
+- name: '{{ scenario }} - run ansible-inventory'
+ ansible.builtin.command: ansible-inventory -i /tmp/tmp-microsoft.ad.ldap.yml --list
+ register: inventory_out_raw
+ delegate_to: localhost
+
+- name: '{{ scenario }} - get ansible-inventory output'
+ set_fact:
+ inventory_out: '{{ inventory_out_raw.stdout | from_json }}'
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/main.yml
new file mode 100644
index 000000000..86b6d75e9
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/roles/test/tasks/main.yml
@@ -0,0 +1,527 @@
+- name: get domain controller info
+ microsoft.ad.object_info:
+ ldap_filter: '(objectClass=computer)'
+ properties:
+ - dNSHostName
+ register: dc_info_raw
+
+- name: make sure only 1 computer is present for start of tests
+ assert:
+ that:
+ - dc_info_raw.objects | length == 1
+
+- set_fact:
+ dc_info: '{{ dc_info_raw.objects[0] }}'
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: Failure connection invalid hostname
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: failed
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+
+- name: assert failure connection invalid hostname
+ assert:
+ that:
+ - inventory_out._meta.hostvars == {}
+ - '"Failed to connect to failed:389" in inventory_out_raw.stderr'
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: Failure connection blocked port
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ port: 1234
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+
+- name: assert failure connection blocked port
+ assert:
+ that:
+ - inventory_out._meta.hostvars == {}
+ - '"Failed to connect to " ~ ldap_server ~ ":1234" in inventory_out_raw.stderr'
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: Failure connection invalid port
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ port: 5985
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+
+- name: assert failure connection invalid port
+ assert:
+ that:
+ - inventory_out._meta.hostvars == {}
+ - '"Received invalid data from the peer" in inventory_out_raw.stderr'
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: LDAP
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+
+- name: assert LDAP inventory
+ assert:
+ that: &default-assertion
+ - inventory_out._meta.hostvars | length == 1
+ - (inventory_out._meta.hostvars.keys() | list) == [dc_info.Name]
+ - (inventory_out._meta.hostvars[dc_info.Name].keys() | list) == ['ansible_host', 'microsoft_ad_distinguished_name']
+ - inventory_out._meta.hostvars[dc_info.Name]['ansible_host'] == dc_info.dNSHostName
+ - inventory_out._meta.hostvars[dc_info.Name]['microsoft_ad_distinguished_name'] == dc_info.DistinguishedName
+ - inventory_out.ungrouped.hosts == [dc_info.Name]
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: LDAP through environment variables
+ inventory:
+ plugin: microsoft.ad.ldap
+ environment:
+ MICROSOFT_AD_LDAP_SERVER: '{{ ldap_server }}'
+ MICROSOFT_AD_LDAP_USERNAME: '{{ ldap_user }}'
+ MICROSOFT_AD_LDAP_PASSWORD: '{{ ldap_pass }}'
+
+- name: assert LDAP inventory through environment variables
+ assert:
+ that: *default-assertion
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: LDAPS
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ tls_mode: ldaps
+ ca_cert: '{{ ldap_ca_cert }}'
+ cert_validation: ignore_hostname
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+
+- name: assert LDAPS inventory
+ assert:
+ that: *default-assertion
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: LDAPS through environment variables
+ inventory:
+ plugin: microsoft.ad.ldap
+ environment:
+ MICROSOFT_AD_LDAP_SERVER: '{{ ldap_server }}'
+ MICROSOFT_AD_LDAP_TLS_MODE: ldaps
+ MICROSOFT_AD_LDAP_CA_CERT: '{{ ldap_ca_cert }}'
+ MICROSOFT_AD_LDAP_CERT_VALIDATION: ignore_hostname
+ MICROSOFT_AD_LDAP_USERNAME: '{{ ldap_user }}'
+ MICROSOFT_AD_LDAP_PASSWORD: '{{ ldap_pass }}'
+
+- name: assert LDAPS inventory through environment variables
+ assert:
+ that: *default-assertion
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: StartTLS
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ tls_mode: start_tls
+ ca_cert: '{{ ldap_ca_cert }}'
+ cert_validation: ignore_hostname
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+
+- name: assert StartTLS inventory
+ assert:
+ that: *default-assertion
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: Simple auth
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ tls_mode: ldaps
+ ca_cert: '{{ lookup("file", ldap_ca_cert) }}'
+ cert_validation: ignore_hostname
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+ auth_protocol: simple
+
+- name: assert Simple auth inventory
+ assert:
+ that: *default-assertion
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: Simple auth fails over LDAP
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+ auth_protocol: simple
+
+- name: assert simple auth failure over LDAP
+ assert:
+ that:
+ - inventory_out._meta.hostvars == {}
+ - '"Cannot use simple auth with encryption" in inventory_out_raw.stderr'
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: Simple auth over LDAP with no encryption
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+ auth_protocol: simple
+ encrypt: false
+
+- name: assert Simple auth over LDAP with no encryption
+ assert:
+ that: *default-assertion
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: Certificate auth with LDAPS
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ tls_mode: ldaps
+ ca_cert: '{{ ldap_ca_cert }}'
+ cert_validation: ignore_hostname
+ certificate: '{{ ldap_user_cert }}'
+ certificate_password: '{{ ldap_pass }}'
+
+- name: assert Certificate auth inventory with LDAPS
+ assert:
+ that: *default-assertion
+
+# Recent Windows Update seems to have broken this. Fails with:
+# Received LDAPResult error bind failed - INVALID_CREDENTIALS - 80090317: LdapErr: DSID-0C090635, comment: The server did not receive any credentials via TLS, data 0, v4563
+# I cannot figure out why so just disabling the test for now.
+
+# - import_tasks: invoke.yml
+# vars:
+# scenario: Certificate auth with StartTLS
+# inventory:
+# plugin: microsoft.ad.ldap
+# server: '{{ ldap_server }}'
+# tls_mode: start_tls
+# ca_cert: '{{ ldap_ca_cert }}'
+# cert_validation: ignore_hostname
+# certificate: '{{ ldap_user_cert }}'
+# certificate_password: '{{ ldap_pass }}'
+
+# - name: assert Certificate auth inventory with StartTLS
+# assert:
+# that: *default-assertion
+
+- import_tasks: invoke.yml
+ vars:
+ scenario: TLS ignoring cert validation
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ tls_mode: ldaps
+ cert_validation: ignore
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+
+- name: assert TLS ignoring cert validation
+ assert:
+ that: *default-assertion
+
+- block:
+ - name: setup custom server data
+ ansible.windows.win_powershell:
+ depth: 3
+ script: |
+ $ErrorActionPreference = 'Stop'
+
+ $ou = New-ADOrganizationalUnit -Name '<My OU, !test''>' -PassThru
+ $adParams = @{
+ Path = $ou.DistinguishedName
+ PassThru = $true
+ }
+ $subOU = New-ADOrganizationalUnit -Name SubOU @adParams
+
+ $group1 = New-ADGroup -Name Group1 -GroupCategory Security -GroupScope Global @adParams
+ $group2 = New-ADGroup -Name Group2 -GroupCategory Security -GroupScope Global @adParams
+
+ $comp1 = New-ADComputer -Name Comp1 -DNSHostName CustomName -OtherAttributes @{
+ comment = 'comment 1'
+ 'msDS-AllowedToDelegateTo' = 'dns 1'
+ location = 'my_location'
+ } @adParams
+ $comp2 = New-ADComputer -Name Comp2 -SamAccountName Comp2Sam -Path $subOU.DistinguishedName -PassThru -OtherAttributes @{
+ comment = 'comment 1'
+ 'msDS-AllowedToDelegateTo' = 'dns 2'
+ }
+
+ Add-ADGroupMember -Identity $group1 -Members $comp1, $comp2
+ Add-ADGroupMember -Identity $group2 -Members $comp1
+
+ $compMembers = @{
+ Property = @(
+ 'DistinguishedName'
+ 'MemberOf'
+ @{N='RawMemberOf'; E={
+ ,@($_.memberOf | ForEach-Object {
+ $b = (New-Object -TypeName System.Text.UTF8Encoding).GetBytes($_)
+ [System.Convert]::ToBase64String($b)
+ })
+ }}
+ 'PwdLastSet'
+ @{N='SID'; E={$_.SID.Value}}
+ @{N='RawSID'; E={
+ $b = New-Object -TypeName byte[] -ArgumentList $_.SID.BinaryLength
+ $_.SID.GetBinaryForm($b, 0)
+ [System.Convert]::ToBase64String($b)
+ }}
+ )
+ }
+
+ [PSCustomObject]@{
+ OUId = $ou.ObjectGuid
+ OUPath = $ou.DistinguishedName
+ Comp1 = $comp1 | Get-ADComputer -Properties * | Select-Object @compMembers
+ Comp2 = $comp2 | Get-ADComputer -Properties * | Select-Object @compMembers
+ }
+ register: test_data
+
+ - import_tasks: invoke.yml
+ vars:
+ scenario: Search with search_base and scope
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+ search_base: '{{ test_data.output[0]["OUPath"] }}'
+ search_scope: one_level
+
+ - name: assert search with seach base and scope
+ assert:
+ that:
+ - inventory_out._meta.hostvars | length == 1
+ - (inventory_out._meta.hostvars.keys() | list) == ["Comp1"]
+ - (inventory_out._meta.hostvars.Comp1.keys() | list) == ['ansible_host', 'microsoft_ad_distinguished_name']
+ - inventory_out._meta.hostvars.Comp1.ansible_host == "CustomName"
+ - inventory_out._meta.hostvars.Comp1.microsoft_ad_distinguished_name == test_data.output[0].Comp1.DistinguishedName
+ - inventory_out.ungrouped.hosts == ["Comp1"]
+
+ - import_tasks: invoke.yml
+ vars:
+ scenario: Search with filter
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+ filter: (sAMAccountName=Comp2Sam$)
+
+ - name: assert search with seach base and scope
+ assert:
+ that:
+ - inventory_out._meta.hostvars | length == 1
+ - (inventory_out._meta.hostvars.keys() | list) == ["Comp2"]
+ - (inventory_out._meta.hostvars.Comp2.keys() | list) == ['microsoft_ad_distinguished_name']
+ - inventory_out._meta.hostvars.Comp2.microsoft_ad_distinguished_name == test_data.output[0].Comp2.DistinguishedName
+ - inventory_out.ungrouped.hosts == ["Comp2"]
+
+ - import_tasks: invoke.yml
+ vars:
+ scenario: Set inventory_hostname from attributes
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+ search_base: '{{ test_data.output[0]["OUPath"] }}'
+ attributes:
+ sAMAccountName:
+ inventory_hostname: sAMAccountName[:-1]
+ ansible_host: inventory_hostname
+
+ - name: assert set inventory_hostname from attributes
+ assert:
+ that:
+ - inventory_out._meta.hostvars | length == 2
+ - (inventory_out._meta.hostvars.keys() | list | sort) == ["Comp1", "Comp2Sam"]
+
+ - (inventory_out._meta.hostvars.Comp1.keys() | list | sort) == ['ansible_host', 'microsoft_ad_distinguished_name', 'sAMAccountName']
+ - "inventory_out._meta.hostvars.Comp1.ansible_host == {'__ansible_unsafe': 'Comp1'}"
+ - inventory_out._meta.hostvars.Comp1.microsoft_ad_distinguished_name == test_data.output[0].Comp1.DistinguishedName
+ - "inventory_out._meta.hostvars.Comp1.sAMAccountName == {'__ansible_unsafe': 'Comp1$'}"
+
+ - (inventory_out._meta.hostvars.Comp2Sam.keys() | list | sort) == ['ansible_host', 'microsoft_ad_distinguished_name', 'sAMAccountName']
+ - "inventory_out._meta.hostvars.Comp2Sam.ansible_host == {'__ansible_unsafe': 'Comp2Sam'}"
+ - inventory_out._meta.hostvars.Comp2Sam.microsoft_ad_distinguished_name == test_data.output[0].Comp2.DistinguishedName
+ - "inventory_out._meta.hostvars.Comp2Sam.sAMAccountName == {'__ansible_unsafe': 'Comp2Sam$'}"
+
+ - inventory_out.ungrouped.hosts | length == 2
+ - inventory_out.ungrouped.hosts[0]['__ansible_unsafe'] in ['Comp1', 'Comp2Sam']
+ - inventory_out.ungrouped.hosts[1]['__ansible_unsafe'] in ['Comp1', 'Comp2Sam']
+
+ - import_tasks: invoke.yml
+ vars:
+ scenario: Set inventory_hostname from compose
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+ search_base: '{{ test_data.output[0]["OUPath"] }}'
+ attributes:
+ sAMAccountName:
+ compose:
+ inventory_hostname: sAMAccountName[:-1]
+ ansible_host: inventory_hostname
+
+ - name: assert set inventory_hostname from compose
+ assert:
+ that:
+ - inventory_out._meta.hostvars | length == 2
+ - (inventory_out._meta.hostvars.keys() | list | sort) == ["Comp1", "Comp2Sam"]
+
+ - (inventory_out._meta.hostvars.Comp1.keys() | list | sort) == ['ansible_host', 'microsoft_ad_distinguished_name', 'sAMAccountName']
+ - "inventory_out._meta.hostvars.Comp1.ansible_host == {'__ansible_unsafe': 'Comp1'}"
+ - inventory_out._meta.hostvars.Comp1.microsoft_ad_distinguished_name == test_data.output[0].Comp1.DistinguishedName
+ - "inventory_out._meta.hostvars.Comp1.sAMAccountName == {'__ansible_unsafe': 'Comp1$'}"
+
+ - (inventory_out._meta.hostvars.Comp2Sam.keys() | list | sort) == ['ansible_host', 'microsoft_ad_distinguished_name', 'sAMAccountName']
+ - "inventory_out._meta.hostvars.Comp2Sam.ansible_host == {'__ansible_unsafe': 'Comp2Sam'}"
+ - inventory_out._meta.hostvars.Comp2Sam.microsoft_ad_distinguished_name == test_data.output[0].Comp2.DistinguishedName
+ - "inventory_out._meta.hostvars.Comp2Sam.sAMAccountName == {'__ansible_unsafe': 'Comp2Sam$'}"
+
+ - inventory_out.ungrouped.hosts | length == 2
+ - inventory_out.ungrouped.hosts[0]['__ansible_unsafe'] in ['Comp1', 'Comp2Sam']
+ - inventory_out.ungrouped.hosts[1]['__ansible_unsafe'] in ['Comp1', 'Comp2Sam']
+
+ - import_tasks: invoke.yml
+ vars:
+ scenario: Search with composable options
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+ search_base: '{{ test_data.output[0]["OUPath"] }}'
+ attributes:
+ sAMAccountName:
+ objectSid:
+ nothing_sid:
+ this_sid: this
+ raw_sid: raw
+ raw_sid_filter: raw | microsoft.ad.as_sid
+ PwdLastSet:
+ location:
+ msDS-SupportedEncryptionTypes:
+ msDS-AllowedToDelegateTo:
+ msDS-AllowedToDelegateTo:
+ memberOf:
+ previous_reference: PwdLastSet | microsoft.ad.as_datetime
+ nothing_member:
+ this_member: this
+ raw_member: raw
+ computer_membership: this | map("regex_search", '^CN=(?P<name>.+?)((?<!\\),)', '\g<name>') | flatten
+ compose:
+ host_var: computer_sid
+ groups:
+ testing: true
+ production: '"Group2" in computer_membership'
+ keyed_groups:
+ - key: location | default(omit)
+ prefix: site
+ default_value: unknown
+
+ - name: assert search with composable options
+ assert:
+ that:
+ - inventory_out._meta.hostvars | length == 2
+ - (inventory_out._meta.hostvars.keys() | list | sort) == ["Comp1", "Comp2"]
+
+ - (inventory_out._meta.hostvars.Comp1.keys() | list | sort) == ['ansible_host', 'computer_membership', 'location', 'microsoft_ad_distinguished_name', 'msDS-AllowedToDelegateTo', 'msDS_SupportedEncryptionTypes', 'nothing_member', 'nothing_sid', 'previous_reference', 'PwdLastSet', 'raw_member', 'raw_sid', 'raw_sid_filter', 'sAMAccountName', 'this_member', 'this_sid']
+ - inventory_out._meta.hostvars.Comp1['ansible_host'] == 'CustomName'
+ - "inventory_out._meta.hostvars.Comp1['computer_membership'] == [{'__ansible_unsafe': 'Group2'}, {'__ansible_unsafe': 'Group1'}]"
+ - "inventory_out._meta.hostvars.Comp1['location'] == {'__ansible_unsafe': 'my_location'}"
+ - inventory_out._meta.hostvars.Comp1['microsoft_ad_distinguished_name'] == test_data.output[0].Comp1.DistinguishedName
+ - "inventory_out._meta.hostvars.Comp1['msDS-AllowedToDelegateTo'] == [{'__ansible_unsafe': 'dns 1'}]"
+ - inventory_out._meta.hostvars.Comp1['msDS_SupportedEncryptionTypes'] == None
+ - "inventory_out._meta.hostvars.Comp1['nothing_member'] == [{'__ansible_unsafe': test_data.output[0].Comp1.MemberOf[0]}, {'__ansible_unsafe': test_data.output[0].Comp1.MemberOf[1]}]"
+ - "inventory_out._meta.hostvars.Comp1['nothing_sid'] == {'__ansible_unsafe': test_data.output[0].Comp1.SID}"
+ - inventory_out._meta.hostvars.Comp1['previous_reference'] == test_data.output[0].Comp1.PwdLastSet | microsoft.ad.as_datetime
+ - inventory_out._meta.hostvars.Comp1['PwdLastSet'] == test_data.output[0].Comp1.PwdLastSet
+ - "inventory_out._meta.hostvars.Comp1['raw_member'] == [{'__ansible_unsafe': test_data.output[0].Comp1.RawMemberOf[0]}, {'__ansible_unsafe': test_data.output[0].Comp1.RawMemberOf[1]}]"
+ - "inventory_out._meta.hostvars.Comp1['raw_sid'] == [{'__ansible_unsafe': test_data.output[0].Comp1.RawSID}]"
+ - "inventory_out._meta.hostvars.Comp1['raw_sid_filter'] == [{'__ansible_unsafe': test_data.output[0].Comp1.SID}]"
+ - "inventory_out._meta.hostvars.Comp1['sAMAccountName'] == {'__ansible_unsafe': 'Comp1$'}"
+ - "inventory_out._meta.hostvars.Comp1['this_member'] == [{'__ansible_unsafe': test_data.output[0].Comp1.MemberOf[0]}, {'__ansible_unsafe': test_data.output[0].Comp1.MemberOf[1]}]"
+ - "inventory_out._meta.hostvars.Comp1['this_sid'] == {'__ansible_unsafe': test_data.output[0].Comp1.SID}"
+
+ - (inventory_out._meta.hostvars.Comp2.keys() | list | sort) == ['computer_membership', 'location', 'microsoft_ad_distinguished_name', 'msDS-AllowedToDelegateTo', 'msDS_SupportedEncryptionTypes', 'nothing_member', 'nothing_sid', 'previous_reference', 'PwdLastSet', 'raw_member', 'raw_sid', 'raw_sid_filter', 'sAMAccountName', 'this_member', 'this_sid']
+ - "inventory_out._meta.hostvars.Comp2['computer_membership'] == [{'__ansible_unsafe': 'Group1'}]"
+ - inventory_out._meta.hostvars.Comp2['location'] == None
+ - inventory_out._meta.hostvars.Comp2['microsoft_ad_distinguished_name'] == test_data.output[0].Comp2.DistinguishedName
+ - "inventory_out._meta.hostvars.Comp2['msDS-AllowedToDelegateTo'] == [{'__ansible_unsafe': 'dns 2'}]"
+ - inventory_out._meta.hostvars.Comp2['msDS_SupportedEncryptionTypes'] == None
+ - "inventory_out._meta.hostvars.Comp2['nothing_member'] == [{'__ansible_unsafe': test_data.output[0].Comp2.MemberOf[0]}]"
+ - "inventory_out._meta.hostvars.Comp2['nothing_sid'] == {'__ansible_unsafe': test_data.output[0].Comp2.SID}"
+ - inventory_out._meta.hostvars.Comp2['previous_reference'] == test_data.output[0].Comp2.PwdLastSet | microsoft.ad.as_datetime
+ - inventory_out._meta.hostvars.Comp2['PwdLastSet'] == test_data.output[0].Comp2.PwdLastSet
+ - "inventory_out._meta.hostvars.Comp2['raw_member'] == [{'__ansible_unsafe': test_data.output[0].Comp2.RawMemberOf[0]}]"
+ - "inventory_out._meta.hostvars.Comp2['raw_sid'] == [{'__ansible_unsafe': test_data.output[0].Comp2.RawSID}]"
+ - "inventory_out._meta.hostvars.Comp2['raw_sid_filter'] == [{'__ansible_unsafe': test_data.output[0].Comp2.SID}]"
+ - "inventory_out._meta.hostvars.Comp2['sAMAccountName'] == {'__ansible_unsafe': 'Comp2Sam$'}"
+ - "inventory_out._meta.hostvars.Comp2['this_member'] == [{'__ansible_unsafe': test_data.output[0].Comp2.MemberOf[0]}]"
+ - "inventory_out._meta.hostvars.Comp2['this_sid'] == {'__ansible_unsafe': test_data.output[0].Comp2.SID}"
+
+ - inventory_out.production.hosts == ["Comp1"]
+ - inventory_out.site_my_location.hosts == ["Comp1"]
+ - inventory_out.site_unknown.hosts == ["Comp2"]
+ - inventory_out.testing.hosts | sort == ["Comp1", "Comp2"]
+
+ - name: create multiple computer objects
+ ansible.windows.win_powershell:
+ parameters:
+ Path: '{{ test_data.output[0].OUPath }}'
+ script: |
+ param($Path)
+
+ $ErrorActionPreference = 'Stop'
+
+ 1..2010 | ForEach-Object {
+ New-ADComputer -Name "MultiComp$_" -Path $Path
+ }
+
+ - import_tasks: invoke.yml
+ vars:
+ scenario: Search with large number of computer accounts
+ inventory:
+ plugin: microsoft.ad.ldap
+ server: '{{ ldap_server }}'
+ username: '{{ ldap_user }}'
+ password: '{{ ldap_pass }}'
+ filter: (name=MultiComp*)
+
+ - name: assert search with large number of computer accounts
+ assert:
+ that:
+ - inventory_out._meta.hostvars | length == 2010
+
+ always:
+ - name: remove test OU
+ microsoft.ad.ou:
+ name: <My OU, !test'>
+ identity: '{{ test_data.output[0].OUId | default(omit) }}'
+ state: absent
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/runme.sh b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/runme.sh
new file mode 100755
index 000000000..22564b656
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/inventory_ldap/runme.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+set -eux
+
+ansible-playbook main.yml -i ../../inventory.winrm "$@"
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/README.md b/ansible_collections/microsoft/ad/tests/integration/targets/membership/README.md
new file mode 100644
index 000000000..9387dd0a6
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/README.md
@@ -0,0 +1,34 @@
+# microsoft.ad.membership tests
+
+As this cannot be run in CI this is a brief guide on how to run these tests locally.
+Run the following:
+
+```bash
+vagrant up
+
+ansible-playbook setup.yml
+```
+
+It is a good idea to create a snapshot of both hosts before running the tests.
+This allows you to reset the host back to a blank starting state if the tests need to be rerun.
+To create a snaphost do the following:
+
+```bash
+virsh snapshot-create-as --domain "membership_DC" --name "pretest"
+virsh snapshot-create-as --domain "membership_TEST" --name "pretest"
+```
+
+To restore these snapshots run the following:
+
+```bash
+virsh snapshot-revert --domain "membership_DC" --snapshotname "pretest" --running
+virsh snapshot-revert --domain "membership_TEST" --snapshotname "pretest" --running
+```
+
+Once you are ready to run the tests run the following:
+
+```bash
+ansible-playbook test.yml
+```
+
+Run `vagrant destroy` to remove the test VMs.
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/Vagrantfile b/ansible_collections/microsoft/ad/tests/integration/targets/membership/Vagrantfile
new file mode 100644
index 000000000..5341185c3
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/Vagrantfile
@@ -0,0 +1,27 @@
+# -*- mode: ruby -*-
+# vi: set ft=ruby :
+
+require 'yaml'
+
+inventory = YAML.load_file('inventory.yml')
+
+Vagrant.configure("2") do |config|
+ inventory['all']['children'].each do |group,details|
+ details['hosts'].each do |server,host_details|
+ config.vm.define server do |srv|
+ srv.vm.box = host_details['vagrant_box']
+ srv.vm.hostname = server
+ srv.vm.network :private_network,
+ :ip => host_details['ansible_host'],
+ :libvirt__network_name => 'microsoft.ad',
+ :libvirt__domain_name => inventory['all']['vars']['domain_realm']
+
+ srv.vm.provider :libvirt do |l|
+ l.memory = 4096
+ l.cpus = 2
+ end
+ end
+ end
+ end
+end
+
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/membership/aliases
new file mode 100644
index 000000000..a0187456c
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/aliases
@@ -0,0 +1,2 @@
+windows
+unsupported # can never run in CI, see README.md \ No newline at end of file
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/ansible.cfg b/ansible_collections/microsoft/ad/tests/integration/targets/membership/ansible.cfg
new file mode 100644
index 000000000..3a986973e
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/ansible.cfg
@@ -0,0 +1,3 @@
+[defaults]
+inventory = inventory.yml
+retry_files_enabled = False
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/inventory.yml b/ansible_collections/microsoft/ad/tests/integration/targets/membership/inventory.yml
new file mode 100644
index 000000000..06c916072
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/inventory.yml
@@ -0,0 +1,22 @@
+all:
+ children:
+ windows:
+ hosts:
+ DC:
+ ansible_host: 192.168.11.10
+ vagrant_box: jborean93/WindowsServer2022
+ TEST:
+ ansible_host: 192.168.11.11
+ vagrant_box: jborean93/WindowsServer2022
+ vars:
+ ansible_port: 5985
+ ansible_connection: psrp
+
+ vars:
+ ansible_user: vagrant
+ ansible_password: vagrant
+ domain_username: vagrant-domain
+ domain_user_upn: '{{ domain_username }}@{{ domain_realm | upper }}'
+ domain_password: VagrantPass1
+ domain_realm: ad.test
+ domain_dn_base: DC=ad,DC=test
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/setup.yml b/ansible_collections/microsoft/ad/tests/integration/targets/membership/setup.yml
new file mode 100644
index 000000000..5fcc09dfd
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/setup.yml
@@ -0,0 +1,82 @@
+- name: create domain controller
+ hosts: DC
+ gather_facts: no
+
+ tasks:
+ - name: get network connection names
+ ansible.windows.win_powershell:
+ parameters:
+ IPAddress: '{{ ansible_host }}'
+ script: |
+ param ($IPAddress)
+
+ $Ansible.Changed = $false
+
+ Get-CimInstance -ClassName Win32_NetworkAdapter -Filter "Netenabled='True'" |
+ ForEach-Object -Process {
+ $config = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter "Index='$($_.Index)'"
+ if ($config.IPAddress -contains $IPAddress) {
+ $_.NetConnectionID
+ }
+ }
+ register: connection_name
+
+ - name: set the DNS for the internal adapters to localhost
+ ansible.windows.win_dns_client:
+ adapter_names:
+ - '{{ connection_name.output[0] }}'
+ dns_servers:
+ - 127.0.0.1
+
+ - name: ensure domain exists and DC is promoted as a domain controller
+ microsoft.ad.domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ reboot: true
+
+ - ansible.windows.win_feature:
+ name: RSAT-AD-PowerShell
+ state: present
+
+ - name: create domain username
+ microsoft.ad.user:
+ name: '{{ domain_username }}'
+ upn: '{{ domain_user_upn }}'
+ description: '{{ domain_username }} Domain Account'
+ password: '{{ domain_password }}'
+ password_never_expires: yes
+ update_password: when_changed
+ groups:
+ add:
+ - Domain Admins
+ state: present
+
+- name: setup test host
+ hosts: TEST
+ gather_facts: no
+
+ tasks:
+ - name: get network connection names
+ ansible.windows.win_powershell:
+ parameters:
+ IPAddress: '{{ ansible_host }}'
+ script: |
+ param ($IPAddress)
+
+ $Ansible.Changed = $false
+
+ Get-CimInstance -ClassName Win32_NetworkAdapter -Filter "Netenabled='True'" |
+ ForEach-Object -Process {
+ $config = Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter "Index='$($_.Index)'"
+ if ($config.IPAddress -contains $IPAddress) {
+ $_.NetConnectionID
+ }
+ }
+ register: connection_name
+
+ - name: set DNS for the private adapter to point to the DC
+ ansible.windows.win_dns_client:
+ adapter_names:
+ - '{{ connection_name.output[0] }}'
+ dns_servers:
+ - '{{ hostvars["DC"]["ansible_host"] }}'
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/membership/tasks/main.yml
new file mode 100644
index 000000000..e4fa96c8e
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/tasks/main.yml
@@ -0,0 +1,631 @@
+- set_fact:
+ get_result_script: |
+ $Ansible.Changed = $false
+ $cs = Get-CimInstance -ClassName Win32_ComputerSystem -Property Domain, PartOfDomain, Workgroup
+ $domainName = if ($cs.PartOfDomain) {
+ try {
+ [System.DirectoryServices.ActiveDirectory.Domain]::GetComputerDomain().Name
+ }
+ catch [System.Security.Authentication.AuthenticationException] {
+ $cs.Domain
+ }
+ }
+ else {
+ $null
+ }
+
+ [PSCustomObject]@{
+ HostName = $env:COMPUTERNAME
+ PartOfDomain = $cs.PartOfDomain
+ DnsDomainName = $domainName
+ WorkgroupName = $cs.Workgroup
+ }
+
+ get_ad_result_script: |
+ $Ansible.Changed = $false
+ Get-ADComputer -Filter { Name -ne 'DC' } -Properties DistinguishedName, Name, Enabled |
+ Select-Object -Property DistinguishedName, Name, Enabled
+
+- name: join domain - check mode
+ membership:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ state: domain
+ reboot: true
+ check_mode: true
+ register: join_domain_check
+
+- name: get result of join domain - check mode
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: join_domain_check_actual
+
+- name: assert join domain - check mode
+ assert:
+ that:
+ - join_domain_check is changed
+ - join_domain_check.reboot_required == False
+ - join_domain_check_actual.output[0]["DnsDomainName"] == None
+ - join_domain_check_actual.output[0]["HostName"] == "TEST"
+ - join_domain_check_actual.output[0]["PartOfDomain"] == False
+ - join_domain_check_actual.output[0]["WorkgroupName"] == "WORKGROUP"
+
+- name: join domain with reboot
+ membership:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ state: domain
+ reboot: true
+ register: join_domain
+
+- name: get result of join domain with reboot
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: join_domain_actual
+
+- name: get ad result of join domain with reboot
+ ansible.windows.win_powershell:
+ script: '{{ get_ad_result_script }}'
+ delegate_to: DC
+ register: join_domain_ad_actual
+
+- name: assert join domain with reboot
+ assert:
+ that:
+ - join_domain is changed
+ - join_domain.reboot_required == False
+ - join_domain_actual.output[0]["DnsDomainName"] == domain_realm
+ - join_domain_actual.output[0]["HostName"] == "TEST"
+ - join_domain_actual.output[0]["PartOfDomain"] == True
+ - join_domain_actual.output[0]["WorkgroupName"] == None
+ - join_domain_ad_actual.output | length == 1
+ - join_domain_ad_actual.output[0]["Name"] == "TEST"
+ - join_domain_ad_actual.output[0]["Enabled"] == True
+
+- name: join domain with reboot - idempotent
+ membership:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ state: domain
+ reboot: true
+ register: join_domain_again
+
+- name: assert join domain with reboot - idempotent
+ assert:
+ that:
+ - not join_domain_again is changed
+ - join_domain_again.reboot_required == False
+
+- name: fail to change domain of already joined host
+ membership:
+ dns_domain_name: fake.realm
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ state: domain
+ reboot: true
+ register: fail_cannot_rename_domain
+ failed_when:
+ - fail_cannot_rename_domain.msg != "Host is already joined to '" ~ domain_realm ~ "', switching domains is not implemented"
+
+- name: rename hostname of domain joined host - check mode
+ membership:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ hostname: OTHER
+ state: domain
+ reboot: true
+ register: rename_host_domain_check
+ check_mode: True
+
+- name: get result of rename hostname of domain joined host - check mode
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: rename_host_domain_check_actual
+
+- name: get ad result of rename hostname of domain joined host - check mode
+ ansible.windows.win_powershell:
+ script: '{{ get_ad_result_script }}'
+ delegate_to: DC
+ register: rename_host_domain_check_ad_actual
+
+- name: assert rename hostname of domain joined host - check mode
+ assert:
+ that:
+ - rename_host_domain_check is changed
+ - rename_host_domain_check.reboot_required == False
+ - rename_host_domain_check_actual.output[0]["DnsDomainName"] == domain_realm
+ - rename_host_domain_check_actual.output[0]["HostName"] == "TEST"
+ - rename_host_domain_check_actual.output[0]["PartOfDomain"] == True
+ - rename_host_domain_check_actual.output[0]["WorkgroupName"] == None
+ - rename_host_domain_check_ad_actual.output | length == 1
+ - rename_host_domain_check_ad_actual.output[0]["Name"] == "TEST"
+ - rename_host_domain_check_ad_actual.output[0]["Enabled"] == True
+
+- name: rename hostname of domain joined host
+ membership:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ hostname: OTHER
+ state: domain
+ reboot: true
+ register: rename_host_domain
+
+- name: get result of rename hostname of domain joined host
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: rename_host_domain_actual
+
+- name: get ad result of rename hostname of domain joined host
+ ansible.windows.win_powershell:
+ script: '{{ get_ad_result_script }}'
+ delegate_to: DC
+ register: rename_host_domain_ad_actual
+
+- name: assert join domain
+ assert:
+ that:
+ - rename_host_domain is changed
+ - rename_host_domain.reboot_required == False
+ - rename_host_domain_actual.output[0]["DnsDomainName"] == domain_realm
+ - rename_host_domain_actual.output[0]["HostName"] == "OTHER"
+ - rename_host_domain_actual.output[0]["PartOfDomain"] == True
+ - rename_host_domain_actual.output[0]["WorkgroupName"] == None
+ - rename_host_domain_ad_actual.output | length == 1
+ - rename_host_domain_ad_actual.output[0]["Name"] == "OTHER"
+ - rename_host_domain_ad_actual.output[0]["Enabled"] == True
+
+- name: change domain to workgroup - check mode
+ membership:
+ workgroup_name: TEST
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ state: workgroup
+ register: to_workgroup_check
+ check_mode: true
+
+- name: get result of change domain to workgroup - check mode
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: to_workgroup_check_actual
+
+- name: get ad result of change domain to workgroup - check mode
+ ansible.windows.win_powershell:
+ script: '{{ get_ad_result_script }}'
+ delegate_to: DC
+ register: to_workgroup_check_ad_actual
+
+- name: assert change domain to workgroup - check mode
+ assert:
+ that:
+ - to_workgroup_check is changed
+ - to_workgroup_check.reboot_required == True
+ - to_workgroup_check_actual.output[0]["DnsDomainName"] == domain_realm
+ - to_workgroup_check_actual.output[0]["HostName"] == "OTHER"
+ - to_workgroup_check_actual.output[0]["PartOfDomain"] == True
+ - to_workgroup_check_actual.output[0]["WorkgroupName"] == None
+ - to_workgroup_check_ad_actual.output | length == 1
+ - to_workgroup_check_ad_actual.output[0]["Name"] == "OTHER"
+ - to_workgroup_check_ad_actual.output[0]["Enabled"] == True
+
+- name: change domain to workgroup
+ membership:
+ workgroup_name: TEST
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ state: workgroup
+ register: to_workgroup
+
+- set_fact:
+ local_user: OTHER\{{ ansible_user }}
+
+- ansible.windows.win_reboot:
+ when: to_workgroup.reboot_required
+ vars:
+ # To avoid conflicts with the domain account with the same name we
+ # explicitly prefix the user to be the local account. Failing to do so
+ # will have the connection fail as it will try to talk to the DC which
+ # ends up failing.
+ ansible_user: '{{ local_user }}'
+
+- name: get result of change domain to workgroup
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: to_workgroup_actual
+
+- name: get ad result of change domain to workgroup
+ ansible.windows.win_powershell:
+ script: '{{ get_ad_result_script }}'
+ delegate_to: DC
+ register: to_workgroup_ad_actual
+
+- name: assert change domain to workgroup
+ assert:
+ that:
+ - to_workgroup is changed
+ - to_workgroup.reboot_required == True
+ - to_workgroup_actual.output[0]["DnsDomainName"] == None
+ - to_workgroup_actual.output[0]["HostName"] == "OTHER"
+ - to_workgroup_actual.output[0]["PartOfDomain"] == False
+ - to_workgroup_actual.output[0]["WorkgroupName"] == "TEST"
+ - to_workgroup_ad_actual.output | length == 1
+ - to_workgroup_ad_actual.output[0]["Name"] == "OTHER"
+ - to_workgroup_ad_actual.output[0]["Enabled"] == False
+
+- name: remove orphaned AD account for later tests
+ microsoft.ad.computer:
+ name: OTHER
+ state: absent
+ delegate_to: DC
+
+- name: change domain to workgroup - idempotent
+ membership:
+ workgroup_name: TEST
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ state: workgroup
+ reboot: true
+ register: to_workgroup_again
+
+- name: assert change domain to workgroup - idempotent
+ assert:
+ that:
+ - not to_workgroup_again is changed
+ - to_workgroup_again.reboot_required == False
+
+- name: change workgroup - check mode
+ membership:
+ workgroup_name: TEST2
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ state: workgroup
+ reboot: true
+ register: change_workgroup_check
+ check_mode: true
+
+- name: get result of change workgroup - check mode
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: change_workgroup_check_actual
+
+- name: assert change workgroup - check mode
+ assert:
+ that:
+ - change_workgroup_check is changed
+ - change_workgroup_check.reboot_required == False
+ - change_workgroup_check_actual.output[0]["DnsDomainName"] == None
+ - change_workgroup_check_actual.output[0]["HostName"] == "OTHER"
+ - change_workgroup_check_actual.output[0]["PartOfDomain"] == False
+ - change_workgroup_check_actual.output[0]["WorkgroupName"] == "TEST"
+
+- name: change workgroup
+ membership:
+ workgroup_name: TEST2
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ state: workgroup
+ reboot: true
+ register: change_workgroup
+
+- name: get result of change workgroup
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: change_workgroup_actual
+
+- name: assert change workgroup
+ assert:
+ that:
+ - change_workgroup is changed
+ - change_workgroup.reboot_required == False
+ - change_workgroup_actual.output[0]["DnsDomainName"] == None
+ - change_workgroup_actual.output[0]["HostName"] == "OTHER"
+ - change_workgroup_actual.output[0]["PartOfDomain"] == False
+ - change_workgroup_actual.output[0]["WorkgroupName"] == "TEST2"
+
+- name: change just the hostname - check mode
+ membership:
+ workgroup_name: TEST2
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ state: workgroup
+ reboot: true
+ hostname: FOO
+ register: change_hostname_check
+ check_mode: true
+
+- name: get result of change just the hostname - check mode
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: change_hostname_check_actual
+
+- name: assert change just the hostname - check mode
+ assert:
+ that:
+ - change_hostname_check is changed
+ - change_hostname_check.reboot_required == False
+ - change_hostname_check_actual.output[0]["DnsDomainName"] == None
+ - change_hostname_check_actual.output[0]["HostName"] == "OTHER"
+ - change_hostname_check_actual.output[0]["PartOfDomain"] == False
+ - change_hostname_check_actual.output[0]["WorkgroupName"] == "TEST2"
+
+- name: change just the hostname
+ membership:
+ workgroup_name: TEST2
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ state: workgroup
+ reboot: true
+ hostname: FOO
+ register: change_hostname
+
+- name: get result of change just the hostname
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: change_hostname_actual
+
+- name: assert change just the hostname - check mode
+ assert:
+ that:
+ - change_hostname is changed
+ - change_hostname.reboot_required == False
+ - change_hostname_actual.output[0]["DnsDomainName"] == None
+ - change_hostname_actual.output[0]["HostName"] == "FOO"
+ - change_hostname_actual.output[0]["PartOfDomain"] == False
+ - change_hostname_actual.output[0]["WorkgroupName"] == "TEST2"
+
+- name: create custom OU
+ ansible.windows.win_powershell:
+ script: |
+ $ou = New-ADOrganizationalUnit -Name MyHosts -PassThru
+ $ou.DistinguishedName
+ delegate_to: DC
+ register: custom_ou
+
+- name: join domain with hostname and OU - check mode
+ membership:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ hostname: BAR
+ domain_ou_path: '{{ custom_ou.output[0] }}'
+ state: domain
+ register: join_ou_check
+ check_mode: true
+
+- name: get result of join domain with hostname and OU - check mode
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: join_ou_check_actual
+
+- name: assert change just the hostname - check mode
+ assert:
+ that:
+ - join_ou_check is changed
+ - join_ou_check.reboot_required == True
+ - join_ou_check_actual.output[0]["DnsDomainName"] == None
+ - join_ou_check_actual.output[0]["HostName"] == "FOO"
+ - join_ou_check_actual.output[0]["PartOfDomain"] == False
+ - join_ou_check_actual.output[0]["WorkgroupName"] == "TEST2"
+
+- name: join domain with hostname and OU
+ membership:
+ dns_domain_name: '{{ domain_realm }}'
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ hostname: BAR
+ domain_ou_path: '{{ custom_ou.output[0] }}'
+ state: domain
+ register: join_ou
+
+- ansible.windows.win_reboot:
+ when: join_ou.reboot_required
+
+- name: get result of join domain with hostname and OU
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: join_ou_actual
+
+- name: get ad result of join domain with hostname and OU
+ ansible.windows.win_powershell:
+ script: '{{ get_ad_result_script }}'
+ register: join_ou_ad_actual
+ delegate_to: DC
+
+- name: assert change just the hostname
+ assert:
+ that:
+ - join_ou is changed
+ - join_ou.reboot_required == True
+ - join_ou_actual.output[0]["DnsDomainName"] == domain_realm
+ - join_ou_actual.output[0]["HostName"] == "BAR"
+ - join_ou_actual.output[0]["PartOfDomain"] == True
+ - join_ou_actual.output[0]["WorkgroupName"] == None
+ - join_ou_ad_actual.output | length == 1
+ - join_ou_ad_actual.output[0]["Name"] == "BAR"
+ - join_ou_ad_actual.output[0]["Enabled"] == True
+ - join_ou_ad_actual.output[0]["DistinguishedName"] == "CN=BAR," ~ custom_ou.output[0]
+
+- name: change domain to workgroup with hostname change - check mode
+ membership:
+ workgroup_name: WORKGROUP
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ hostname: FOO
+ state: workgroup
+ register: to_workgroup_hostname_check
+ check_mode: true
+
+- name: get result of change domain to workgroup with hostname change - check mode
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: to_workgroup_hostname_check_actual
+
+- name: get ad result of change domain to workgroup with hostname change - check mode
+ ansible.windows.win_powershell:
+ script: '{{ get_ad_result_script }}'
+ delegate_to: DC
+ register: to_workgroup_hostname_check_ad_actual
+
+- name: assert change domain to workgroup with hostname change - check mode
+ assert:
+ that:
+ - to_workgroup_hostname_check is changed
+ - to_workgroup_hostname_check.reboot_required == True
+ - to_workgroup_hostname_check_actual.output[0]["DnsDomainName"] == domain_realm
+ - to_workgroup_hostname_check_actual.output[0]["HostName"] == "BAR"
+ - to_workgroup_hostname_check_actual.output[0]["PartOfDomain"] == True
+ - to_workgroup_hostname_check_actual.output[0]["WorkgroupName"] == None
+ - to_workgroup_hostname_check_ad_actual.output | length == 1
+ - to_workgroup_hostname_check_ad_actual.output[0]["Name"] == "BAR"
+ - to_workgroup_hostname_check_ad_actual.output[0]["Enabled"] == True
+
+- name: change domain to workgroup with hostname change
+ membership:
+ workgroup_name: WORKGROUP
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ hostname: FOO
+ state: workgroup
+ reboot: true
+ register: to_workgroup_hostname
+
+- name: get result of change domain to workgroup with hostname change
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: to_workgroup_hostname_actual
+
+- name: get ad result of change domain to workgroup with hostname change
+ ansible.windows.win_powershell:
+ script: '{{ get_ad_result_script }}'
+ delegate_to: DC
+ register: to_workgroup_hostname_ad_actual
+
+- name: assert change domain to workgroup with hostname change
+ assert:
+ that:
+ - to_workgroup_hostname is changed
+ - to_workgroup_hostname.reboot_required == False
+ - to_workgroup_hostname_actual.output[0]["DnsDomainName"] == None
+ - to_workgroup_hostname_actual.output[0]["HostName"] == "FOO"
+ - to_workgroup_hostname_actual.output[0]["PartOfDomain"] == False
+ - to_workgroup_hostname_actual.output[0]["WorkgroupName"] == "WORKGROUP"
+ - to_workgroup_hostname_ad_actual.output | length == 1
+ - to_workgroup_hostname_ad_actual.output[0]["Name"] == "BAR"
+ - to_workgroup_hostname_ad_actual.output[0]["Enabled"] == False
+
+- name: remove orphaned AD account for later tests
+ microsoft.ad.computer:
+ name: BAR
+ state: absent
+ delegate_to: DC
+
+- name: create computer object
+ microsoft.ad.computer:
+ name: My, Computer
+ path: CN=Users,{{ domain_dn_base }}
+ sam_account_name: MyComp$
+ state: present
+ delegate_to: DC
+ register: comp_account
+
+- name: get offline join blob
+ microsoft.ad.offline_join:
+ identity: '{{ comp_account.object_guid }}'
+ delegate_to: DC
+ register: offline_join
+
+- name: get computer object info
+ microsoft.ad.object_info:
+ identity: '{{ comp_account.object_guid }}'
+ properties:
+ - pwdLastSet
+ delegate_to: DC
+ register: comp_account_pre_join
+
+- name: join domain by offline blob - check
+ microsoft.ad.membership:
+ offline_join_blob: '{{ offline_join.blob }}'
+ state: domain
+ register: offline_join_check
+ check_mode: true
+
+- name: get result of join domain by offline blob - check
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: offline_join_check_actual
+
+- name: get result of join domain by offline blob comp info - check
+ microsoft.ad.object_info:
+ identity: '{{ comp_account.object_guid }}'
+ properties:
+ - pwdLastSet
+ delegate_to: DC
+ register: offline_join_check_ad_actual
+
+- name: assert join domain by offline blob - check
+ assert:
+ that:
+ - offline_join_check is changed
+ - offline_join_check.reboot_required == true
+ - offline_join_check_actual.output[0].DnsDomainName == None
+ - offline_join_check_actual.output[0].PartOfDomain == False
+ - offline_join_check_ad_actual.objects[0].pwdLastSet == comp_account_pre_join.objects[0].pwdLastSet
+
+- name: join domain by offline blob
+ microsoft.ad.membership:
+ offline_join_blob: '{{ offline_join.blob }}'
+ state: domain
+ reboot: true
+ register: offline_join_res
+
+- name: get result of join domain by offline blob
+ ansible.windows.win_powershell:
+ script: '{{ get_result_script }}'
+ register: offline_join_actual
+
+- name: get result of join domain by offline blob comp info
+ microsoft.ad.object_info:
+ identity: '{{ comp_account.object_guid }}'
+ properties:
+ - pwdLastSet
+ delegate_to: DC
+ register: offline_join_ad_actual
+
+- name: assert join domain by offline blob
+ assert:
+ that:
+ - offline_join_res is changed
+ - offline_join_res.reboot_required == false
+ - offline_join_actual.output[0].DnsDomainName == domain_realm
+ - offline_join_actual.output[0].PartOfDomain == True
+ - offline_join_ad_actual.objects[0].pwdLastSet > offline_join_check_ad_actual.objects[0].pwdLastSet
+
+- name: join domain by offline blob - idempotent
+ microsoft.ad.membership:
+ offline_join_blob: '{{ offline_join.blob }}'
+ state: domain
+ register: offline_join_again
+
+- name: assert join domain by offline blob - idempotent
+ assert:
+ that:
+ - not offline_join_again is changed
+
+- name: change domain to workgroup
+ membership:
+ workgroup_name: WORKGROUP
+ domain_admin_user: '{{ domain_user_upn }}'
+ domain_admin_password: '{{ domain_password }}'
+ state: workgroup
+ reboot: true
+
+- name: remove orphaned AD account for later tests
+ microsoft.ad.computer:
+ name: My, Computer
+ path: CN=Users,{{ domain_dn_base }}
+ state: absent
+ delegate_to: DC
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/membership/test.yml b/ansible_collections/microsoft/ad/tests/integration/targets/membership/test.yml
new file mode 100644
index 000000000..238d92005
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/membership/test.yml
@@ -0,0 +1,30 @@
+- name: ensure time is in sync
+ hosts: windows
+ gather_facts: no
+ tasks:
+ - name: get current host datetime
+ command: date +%s
+ changed_when: False
+ delegate_to: localhost
+ run_once: True
+ register: local_time
+
+ - name: set datetime on Windows
+ ansible.windows.win_powershell:
+ parameters:
+ SecondsSinceEpoch: '{{ local_time.stdout | trim }}'
+ script: |
+ param($SecondsSinceEpoch)
+
+ $utc = [System.DateTimeKind]::Utc
+ $epoch = New-Object -TypeName System.DateTime -ArgumentList 1970, 1, 1, 0, 0, 0, 0, $utc
+ $date = $epoch.AddSeconds($SecondsSinceEpoch)
+
+ Set-Date -Date $date
+
+- name: run microsoft.ad.membership tests
+ hosts: TEST
+ gather_facts: no
+
+ tasks:
+ - import_tasks: tasks/main.yml
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/object/aliases
new file mode 100644
index 000000000..ccd8a25e8
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/object/aliases
@@ -0,0 +1,2 @@
+windows
+shippable/windows/group1
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object/defaults/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object/defaults/main.yml
new file mode 100644
index 000000000..e557e2d01
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/object/defaults/main.yml
@@ -0,0 +1,2 @@
+object_name: My, Contact
+object_dn: CN=My\, Contact,{{ setup_domain_info.output[0].defaultNamingContext }}
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object/meta/main.yml
new file mode 100644
index 000000000..4ce45dcfb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/object/meta/main.yml
@@ -0,0 +1,2 @@
+dependencies:
+- setup_domain
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/main.yml
new file mode 100644
index 000000000..1945e3a90
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/main.yml
@@ -0,0 +1,164 @@
+- name: add test attributes to the schema
+ object:
+ name: '{{ item.name }}'
+ path: '{{ setup_domain_info.output[0].schemaNamingContext }}'
+ state: present
+ type: attributeSchema
+ attributes:
+ set:
+ adminDescription: '{{ item.description }}'
+ lDAPDisplayName: '{{ item.name }}'
+ attributeId: 1.3.6.1.4.1.2312.99999.{{ item.id }}
+ attributeSyntax: '{{ item.syntax }}'
+ omSyntax: '{{ item.om_syntax }}'
+ isSingleValued: '{{ item.single_value }}'
+ systemOnly: false
+ isMemberOfPartialAttributeSet: false
+ searchFlags: 0
+ showInAdvancedViewOnly: false
+ loop:
+ # https://social.technet.microsoft.com/wiki/contents/articles/52570.active-directory-syntaxes-of-attributes.aspx
+ - name: ansible-BoolSingle
+ description: Test Attribute for Boolean Single
+ id: 1
+ syntax: 2.5.5.8
+ om_syntax: 1
+ single_value: true
+ - name: ansible-BoolMulti
+ description: Test Attribute for Boolean Multi
+ id: 2
+ syntax: 2.5.5.8
+ om_syntax: 1
+ single_value: false
+
+ - name: ansible-BytesSingle
+ description: Test Attribute for Bytes Single
+ id: 3
+ syntax: 2.5.5.10
+ om_syntax: 4
+ single_value: true
+ - name: ansible-BytesMulti
+ description: Test Attribute for Bytes Multi
+ id: 4
+ syntax: 2.5.5.10
+ om_syntax: 4
+ single_value: false
+
+ - name: ansible-DateTimeSingle
+ description: Test Attribute for DateTime Single
+ id: 5
+ syntax: 2.5.5.11
+ om_syntax: 24
+ single_value: true
+ - name: ansible-DateTimeMulti
+ description: Test Attribute for DateTime Multi
+ id: 6
+ syntax: 2.5.5.11
+ om_syntax: 24
+ single_value: false
+
+ - name: ansible-IntSingle
+ description: Test Attribute for Integer Single
+ id: 7
+ syntax: 2.5.5.16
+ om_syntax: 65
+ single_value: true
+ - name: ansible-IntMulti
+ description: Test Attribute for Integer Multi
+ id: 8
+ syntax: 2.5.5.16
+ om_syntax: 65
+ single_value: false
+
+ - name: ansible-SDSingle
+ description: Test Attribute for SD Single
+ id: 9
+ syntax: 2.5.5.15
+ om_syntax: 66
+ single_value: true
+ - name: ansible-SDMulti
+ description: Test Attribute for SD Multi
+ id: 10
+ syntax: 2.5.5.15
+ om_syntax: 66
+ single_value: false
+
+ - name: ansible-StringSingle
+ description: Test Attribute for String Single
+ id: 11
+ syntax: 2.5.5.12
+ om_syntax: 64
+ single_value: true
+ - name: ansible-StringMulti
+ description: Test Attribute for String Multi
+ id: 12
+ syntax: 2.5.5.12
+ om_syntax: 64
+ single_value: false
+
+ register: schema_attributes
+ become: true
+ become_method: runas
+ become_user: SYSTEM
+
+- name: create auxilary class to house the new attributes
+ object:
+ name: ansibleTesting
+ path: '{{ setup_domain_info.output[0].schemaNamingContext }}'
+ type: classSchema
+ attributes:
+ set:
+ adminDescription: Test auxilary class for Ansible microsoft.ad attribute tests
+ adminDisplayName: ansibleTesting
+ lDAPDisplayName: ansibleTesting
+ governsId: 1.3.6.1.4.1.2312.99999
+ objectClassCategory: 3
+ systemOnly: false
+ subclassOf: top
+ # This is unfortunately not idempotent, the set must be the OID but
+ # Get-ADObject returns the lDAPDisplayName
+ mayContain: '{{ schema_attributes.results | map(attribute="item.id") | map("regex_replace", "^(.*)$", "1.3.6.1.4.1.2312.99999.\1") | list }}'
+ when: schema_attributes is changed
+ register: schema_class
+ become: true
+ become_method: runas
+ become_user: SYSTEM
+
+- name: add auxilary class to the contact class
+ object:
+ name: Contact
+ path: '{{ setup_domain_info.output[0].schemaNamingContext }}'
+ type: classSchema
+ attributes:
+ add:
+ auxiliaryClass: 1.3.6.1.4.1.2312.99999
+ register: aux_reg
+ become: true
+ become_method: runas
+ become_user: SYSTEM
+ when: schema_class is changed
+
+- name: update schema
+ ansible.windows.win_powershell:
+ parameters:
+ Name: '{{ setup_domain_info.output[0].dnsHostName }}'
+ script: |
+ param($Name)
+
+ $dse = New-Object -TypeName System.DirectoryServices.DirectoryEntry -ArgumentList "LDAP://$Name/RootDSE"
+ $dse.Put("SchemaUpdateNow", 1)
+ $dse.SetInfo()
+ become: true
+ become_method: runas
+ become_user: SYSTEM
+ when: aux_reg is changed
+
+- block:
+ - import_tasks: tests.yml
+
+ always:
+ - name: remove temp object
+ object:
+ name: '{{ object_name }}'
+ identity: '{{ object_identity | default(omit) }}'
+ state: absent
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/tests.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/tests.yml
new file mode 100644
index 000000000..b642ce6eb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/object/tasks/tests.yml
@@ -0,0 +1,1446 @@
+- name: create contact object - check
+ object:
+ name: '{{ object_name }}'
+ type: contact
+ state: present
+ register: create_check
+ check_mode: true
+
+- name: get result of create contact object - check
+ object_info:
+ identity: '{{ create_check.distinguished_name }}'
+ register: create_check_actual
+
+- name: assert create contact object - check
+ assert:
+ that:
+ - create_check is changed
+ - create_check.distinguished_name == object_dn
+ - create_check.object_guid == '00000000-0000-0000-0000-000000000000'
+ - create_check_actual.objects == []
+
+- name: create contact object
+ object:
+ name: '{{ object_name }}'
+ type: contact
+ state: present
+ register: create
+
+- name: get result of create contact object
+ object_info:
+ identity: '{{ create_check.distinguished_name }}'
+ properties:
+ - objectClass
+ register: create_actual
+
+- name: assert create contact object
+ assert:
+ that:
+ - create is changed
+ - create_actual.objects | length == 1
+ - create_actual.objects[0].ObjectClass == 'contact'
+ - create.distinguished_name == object_dn
+ - create.distinguished_name == create_actual.objects[0].DistinguishedName
+ - create.object_guid == create_actual.objects[0].ObjectGUID
+
+- set_fact:
+ object_identity: '{{ create.object_guid }}'
+
+- name: create contact object - idempotent
+ object:
+ name: '{{ object_name }}'
+ type: contact
+ state: present
+ register: create_again
+
+- name: assert create contact object - idempotent
+ assert:
+ that:
+ - not create_again is changed
+ - create_again.distinguished_name == create_actual.objects[0].DistinguishedName
+ - create_again.object_guid == create_actual.objects[0].ObjectGUID
+
+- name: fail to change type
+ object:
+ name: '{{ object_name }}'
+ state: present
+ type: failure
+ register: fail_change_type
+ failed_when: fail_change_type.msg != "Cannot change object type contact of existing object " ~ object_dn ~ " to failure"
+
+- name: rename and set display name of object - check
+ object:
+ name: My, Contact 2
+ identity: '{{ object_identity }}'
+ display_name: Display Name
+ state: present
+ type: contact
+ register: rename_check
+ check_mode: true
+
+- name: get result of create contact object - check
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - displayName
+ - name
+ register: rename_check_actual
+
+- name: assert rename and set display name of object - check
+ assert:
+ that:
+ - rename_check is changed
+ - rename_check.distinguished_name == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext
+ - rename_check.object_guid == object_identity
+ - rename_check_actual.objects[0].DisplayName == None
+ - rename_check_actual.objects[0].DistinguishedName == object_dn
+ - rename_check_actual.objects[0].Name == object_name
+
+- name: rename and set display name of object
+ object:
+ name: My, Contact 2
+ identity: '{{ object_identity }}'
+ display_name: Display Name
+ state: present
+ type: contact
+ register: rename
+
+- name: get result of create contact object
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - displayName
+ - name
+ register: rename_actual
+
+- name: assert rename and set display name of object
+ assert:
+ that:
+ - rename is changed
+ - rename.distinguished_name == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext
+ - rename.object_guid == object_identity
+ - rename_actual.objects[0].DisplayName == 'Display Name'
+ - rename_actual.objects[0].DistinguishedName == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext
+ - rename_actual.objects[0].Name == 'My, Contact 2'
+
+- name: rename and set display name of object - idempotent
+ object:
+ name: My, Contact 2
+ identity: '{{ object_identity }}'
+ display_name: Display Name
+ state: present
+ type: contact
+ register: rename_again
+
+- name: assert rename and set display name of object - idempotent
+ assert:
+ that:
+ - not rename_again is changed
+ - rename_again.distinguished_name == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext
+ - rename_again.object_guid == object_identity
+
+- name: move and set description - check
+ object:
+ name: My, Contact 2
+ identity: '{{ object_identity }}'
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ description: My Description
+ state: present
+ type: contact
+ register: move_check
+ check_mode: true
+
+- name: move and set description - check
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - description
+ - name
+ register: move_check_actual
+
+- name: assert move and set description - check
+ assert:
+ that:
+ - move_check is changed
+ - move_check.distinguished_name == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - move_check.object_guid == object_identity
+ - move_check_actual.objects[0].Description == None
+ - move_check_actual.objects[0].DistinguishedName == 'CN=My\, Contact 2,' ~ setup_domain_info.output[0].defaultNamingContext
+ - move_check_actual.objects[0].Name == 'My, Contact 2'
+
+- name: move and set description
+ object:
+ name: My, Contact 2
+ identity: '{{ object_identity }}'
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ description: My Description
+ state: present
+ type: contact
+ register: move
+
+- name: move and set description
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - description
+ - name
+ register: move_actual
+
+- name: assert move and set description
+ assert:
+ that:
+ - move is changed
+ - move.distinguished_name == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - move.object_guid == object_identity
+ - move_actual.objects[0].Description == 'My Description'
+ - move_actual.objects[0].DistinguishedName == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - move_actual.objects[0].Name == 'My, Contact 2'
+
+- name: move and set description - idempotent
+ object:
+ name: My, Contact 2
+ identity: '{{ object_identity }}'
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ description: My Description
+ state: present
+ type: contact
+ register: move_again
+
+- name: assert move and set description - idempotent
+ assert:
+ that:
+ - not move_again is changed
+ - move_again.distinguished_name == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - move_again.object_guid == object_identity
+
+- name: rename and move - check
+ object:
+ name: '{{ object_name }}'
+ identity: '{{ object_identity }}'
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ state: present
+ type: contact
+ register: rename_and_move_check
+ check_mode: true
+
+- name: get result of rename and move - check
+ object_info:
+ identity: '{{ object_identity }}'
+ register: rename_and_move_check_actual
+
+- name: assert rename and move - check
+ assert:
+ that:
+ - rename_and_move_check is changed
+ - rename_and_move_check.distinguished_name == object_dn
+ - rename_and_move_check.object_guid == object_identity
+ - rename_and_move_check_actual.objects[0].DistinguishedName == 'CN=My\, Contact 2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - rename_and_move_check_actual.objects[0].Name == 'My, Contact 2'
+
+- name: rename and move
+ object:
+ name: '{{ object_name }}'
+ identity: '{{ object_identity }}'
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ state: present
+ type: contact
+ register: rename_and_move
+
+- name: get result of rename and move
+ object_info:
+ identity: '{{ object_identity }}'
+ register: rename_and_move_actual
+
+- name: assert rename and move
+ assert:
+ that:
+ - rename_and_move is changed
+ - rename_and_move.distinguished_name == object_dn
+ - rename_and_move.object_guid == object_identity
+ - rename_and_move_actual.objects[0].DistinguishedName == object_dn
+ - rename_and_move_actual.objects[0].Name == object_name
+
+- name: remove object by name - check
+ object:
+ name: '{{ object_name }}'
+ state: absent
+ register: remove_check
+ check_mode: true
+
+- name: get result of remove by name - check
+ object_info:
+ identity: '{{ object_identity }}'
+ register: remove_check_actual
+
+- name: assert remove object by name - check
+ assert:
+ that:
+ - remove_check is changed
+ - remove_check.distinguished_name == object_dn
+ - remove_check.object_guid == object_identity
+ - remove_check_actual.objects | length == 1
+
+- name: remove object by name
+ object:
+ name: '{{ object_name }}'
+ state: absent
+ register: remove
+
+- name: get result of remove by name
+ object_info:
+ identity: '{{ object_identity }}'
+ register: remove_actual
+
+- name: assert remove object by name - check
+ assert:
+ that:
+ - remove is changed
+ - remove.distinguished_name == object_dn
+ - remove.object_guid == object_identity
+ - remove_actual.objects == []
+
+- name: remove object by name - idempotent
+ object:
+ name: '{{ object_name }}'
+ state: absent
+ register: remove_again
+
+- name: assert remove object by name - check
+ assert:
+ that:
+ - not remove_again is changed
+ - remove_again.distinguished_name == None
+ - remove_again.object_guid == None
+
+- name: create object protected from deletion
+ object:
+ name: My, Container
+ state: present
+ type: organizationalUnit
+ protect_from_deletion: true
+ register: create_deletion
+
+- set_fact:
+ object_identity: '{{ create_deletion.object_guid }}'
+
+- name: get result of create object protected from deletion
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - ProtectedFromAccidentalDeletion
+ register: create_deletion_actual
+
+- name: assert create object protected from deletion
+ assert:
+ that:
+ - create_deletion is changed
+ - create_deletion.distinguished_name == 'OU=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext
+ - create_deletion_actual.objects[0].ProtectedFromAccidentalDeletion == true
+
+- name: unset protect from deletion
+ object:
+ name: My, Container
+ state: present
+ type: organizationalUnit
+ protect_from_deletion: false
+ register: unset_deletion
+
+- name: get result of unset protect from deletion
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - ProtectedFromAccidentalDeletion
+ register: unset_deletion_actual
+
+- name: assert set protect from deletion
+ assert:
+ that:
+ - unset_deletion is changed
+ - unset_deletion.distinguished_name == 'OU=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext
+ - unset_deletion_actual.objects[0].ProtectedFromAccidentalDeletion == false
+
+- name: set protect from deletion
+ object:
+ name: My, Container
+ state: present
+ type: organizationalUnit
+ protect_from_deletion: true
+ register: set_deletion
+
+- name: get result of set protect from deletion
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - ProtectedFromAccidentalDeletion
+ register: set_deletion_actual
+
+- name: assert set protect from deletion
+ assert:
+ that:
+ - set_deletion is changed
+ - set_deletion.distinguished_name == 'OU=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext
+ - set_deletion_actual.objects[0].ProtectedFromAccidentalDeletion == true
+
+- name: create sub ous for move test
+ ou:
+ name: '{{ item }}'
+ path: '{{ set_deletion.distinguished_name }}'
+ state: present
+ protect_from_deletion: true
+ register: sub_ous
+ loop:
+ - SubOU
+ - TestOU
+
+- name: move and rename object that is protected from deletion - check
+ object:
+ name: TestOU 2
+ path: '{{ sub_ous.results[0].distinguished_name }}'
+ identity: '{{ sub_ous.results[1].object_guid }}'
+ type: organizationalUnit
+ register: move_ou_check
+ check_mode: true
+
+- name: get result of move and rename object that is protected from deletion - check
+ object_info:
+ identity: '{{ sub_ous.results[1].object_guid }}'
+ properties:
+ - ProtectedFromAccidentalDeletion
+ register: move_ou_check_actual
+
+- name: assert move and rename object that is protected from deletion - check
+ assert:
+ that:
+ - move_ou_check is changed
+ - move_ou_check.distinguished_name == 'OU=TestOU 2,' ~ sub_ous.results[0].distinguished_name
+ - move_ou_check_actual.objects[0].Name == 'TestOU'
+ - move_ou_check_actual.objects[0].DistinguishedName == sub_ous.results[1].distinguished_name
+ - move_ou_check_actual.objects[0].ProtectedFromAccidentalDeletion == true
+
+- name: move and rename object that is protected from deletion
+ object:
+ name: TestOU 2
+ path: '{{ sub_ous.results[0].distinguished_name }}'
+ identity: '{{ sub_ous.results[1].object_guid }}'
+ type: organizationalUnit
+ register: move_ou
+
+- name: get result of move and rename object that is protected from deletion
+ object_info:
+ identity: '{{ sub_ous.results[1].object_guid }}'
+ properties:
+ - ProtectedFromAccidentalDeletion
+ register: move_ou_actual
+
+- name: assert move and rename object that is protected from deletion
+ assert:
+ that:
+ - move_ou is changed
+ - move_ou.distinguished_name == 'OU=TestOU 2,' ~ sub_ous.results[0].distinguished_name
+ - move_ou_actual.objects[0].Name == 'TestOU 2'
+ - move_ou_actual.objects[0].DistinguishedName == 'OU=TestOU 2,' ~ sub_ous.results[0].distinguished_name
+ - move_ou_actual.objects[0].ProtectedFromAccidentalDeletion == true
+
+- name: remove object that is protected from deletion - check
+ object:
+ name: My, Container
+ state: absent
+ type: organizationalUnit
+ register: remove_deletion_check
+ check_mode: true
+
+- name: get result of remove object that is protected from deletion - check
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - ProtectedFromAccidentalDeletion
+ register: remove_deletion_actual_check
+
+- name: assert remove object that is protected from deletion - check
+ assert:
+ that:
+ - remove_deletion_check is changed
+ - remove_deletion_check.distinguished_name == 'OU=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext
+ - remove_deletion_actual_check.objects[0].ProtectedFromAccidentalDeletion == true
+
+- name: remove object that is protected from deletion
+ object:
+ name: My, Container
+ state: absent
+ type: organizationalUnit
+ register: remove_deletion
+
+- name: get result of remove object that is protected from deletion
+ object_info:
+ identity: '{{ object_identity }}'
+ register: remove_deletion_actual
+
+- name: assert remove object that is protected from deletion
+ assert:
+ that:
+ - remove_deletion is changed
+ - remove_deletion.distinguished_name == 'OU=My\, Container,' ~ setup_domain_info.output[0].defaultNamingContext
+ - remove_deletion_actual.objects == []
+
+- name: create object with custom path, description, and display_name
+ object:
+ name: My, Container
+ description: Test Description
+ display_name: Display Name
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ state: present
+ type: container
+ register: create_custom
+
+- set_fact:
+ object_identity: '{{ create_custom.object_guid }}'
+
+- name: get result of create object with custom path, description, and display_name
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - description
+ - displayName
+ - objectClass
+ register: create_custom_actual
+
+- name: assert create object with custom path, description, and display_name
+ assert:
+ that:
+ - create_custom is changed
+ - create_custom.distinguished_name == 'CN=My\, Container,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - create_custom.object_guid == object_identity
+ - create_custom_actual.objects[0].Description == 'Test Description'
+ - create_custom_actual.objects[0].DisplayName == 'Display Name'
+ - create_custom_actual.objects[0].ObjectClass == 'container'
+
+- name: create child object in container with attributes
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ description: Test Description
+ display_name: Display Name
+ attributes:
+ add:
+ ansible-BoolSingle: false
+ ansible-BoolMulti:
+ - false
+ - true
+ ansible-BytesSingle:
+ type: bytes
+ value: Zm9v
+ ansible-BytesMulti:
+ - type: bytes
+ value: Zm9v
+ - type: bytes
+ value: YmFy
+ ansible-DateTimeSingle:
+ type: date_time
+ value: '1970-01-01T00:00:00Z'
+ ansible-DateTimeMulti:
+ - type: date_time
+ value: '1970-01-01T00:00:00+01:00'
+ - type: date_time
+ value: '1601-01-01T00:00:00.1-01:00'
+ ansible-IntSingle: 0
+ ansible-IntMulti:
+ - 0
+ - 1
+ ansible-SDSingle:
+ type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)
+ ansible-SDMulti:
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)
+ ansible-StringSingle: single
+ ansible-StringMulti:
+ - multi 1
+ - multi 2
+ type: contact
+ state: present
+ register: sub_object
+
+- name: get result of child object attributes
+ object_info:
+ identity: '{{ sub_object.object_guid }}'
+ properties:
+ - ansible-BoolSingle
+ - ansible-BoolMulti
+ - ansible-BytesSingle
+ - ansible-BytesMulti
+ - ansible-DateTimeSingle
+ - ansible-DateTimeMulti
+ - ansible-IntSingle
+ - ansible-IntMulti
+ - ansible-SDSingle
+ - ansible-SDMulti
+ - ansible-StringSingle
+ - ansible-StringMulti
+ register: sub_object_actual
+
+- name: assert create child object in container with attributes
+ assert:
+ that:
+ - sub_object is changed
+ - sub_object.distinguished_name == 'CN=Contact,CN=My\, Container,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - sub_object_actual.objects[0]['ansible-BoolSingle'] == False
+ - sub_object_actual.objects[0]['ansible-BoolMulti'] | length == 2
+ - True in sub_object_actual.objects[0]['ansible-BoolMulti']
+ - False in sub_object_actual.objects[0]['ansible-BoolMulti']
+ - sub_object_actual.objects[0]['ansible-BytesSingle'] == "Zm9v"
+ - sub_object_actual.objects[0]['ansible-BytesMulti'] | length == 2
+ - "'YmFy' in sub_object_actual.objects[0]['ansible-BytesMulti']"
+ - "'Zm9v' in sub_object_actual.objects[0]['ansible-BytesMulti']"
+ - sub_object_actual.objects[0]['ansible-DateTimeSingle'] == "1970-01-01T00:00:00.0000000Z"
+ - sub_object_actual.objects[0]['ansible-DateTimeMulti'] | length == 2
+ - "'1601-01-01T01:00:00.0000000Z' in sub_object_actual.objects[0]['ansible-DateTimeMulti']"
+ - "'1969-12-31T23:00:00.0000000Z' in sub_object_actual.objects[0]['ansible-DateTimeMulti']"
+ - sub_object_actual.objects[0]['ansible-IntSingle'] == 0
+ - sub_object_actual.objects[0]['ansible-IntMulti'] | length == 2
+ - 0 in sub_object_actual.objects[0]['ansible-IntMulti']
+ - 1 in sub_object_actual.objects[0]['ansible-IntMulti']
+ - sub_object_actual.objects[0]['ansible-SDSingle'] == "O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)"
+ - sub_object_actual.objects[0]['ansible-SDMulti'] | length == 2
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in sub_object_actual.objects[0]['ansible-SDMulti']"
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)' in sub_object_actual.objects[0]['ansible-SDMulti']"
+ - sub_object_actual.objects[0]['ansible-StringSingle'] == "single"
+ - sub_object_actual.objects[0]['ansible-StringMulti'] | length == 2
+ - "'multi 1' in sub_object_actual.objects[0]['ansible-StringMulti']"
+ - "'multi 2' in sub_object_actual.objects[0]['ansible-StringMulti']"
+
+- name: add attribute - check
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ description: test description
+ display_name: display name
+ attributes:
+ add:
+ ansible-BoolMulti:
+ - false
+ - true
+ ansible-BytesMulti:
+ - type: bytes
+ value: Zm9v
+ - type: bytes
+ value: dGVzdA==
+ - type: bytes
+ value: Y2FmZQ==
+ ansible-DateTimeMulti:
+ - type: date_time
+ value: '1970-01-01T00:00:00+01:00'
+ - type: date_time
+ value: '1970-01-01T00:00:00'
+ - type: date_time
+ value: '2023-01-17T16:00:31+00:30'
+ ansible-IntMulti:
+ - 0
+ - 2
+ - 3
+ ansible-SDMulti:
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG)
+ ansible-StringMulti:
+ - multi 1
+ - '3'
+ - '4'
+ state: present
+ register: add_attr_check
+ check_mode: true
+
+- name: get result of add attribute - check
+ object_info:
+ identity: '{{ sub_object.object_guid }}'
+ properties:
+ - ansible-BoolMulti
+ - ansible-BytesMulti
+ - ansible-DateTimeMulti
+ - ansible-IntMulti
+ - ansible-SDMulti
+ - ansible-StringMulti
+ register: add_attr_check_actual
+
+- name: assert add attribute - check
+ assert:
+ that:
+ - add_attr_check is changed
+ - add_attr_check_actual.objects[0]['ansible-BoolMulti'] | length == 2
+ - True in add_attr_check_actual.objects[0]['ansible-BoolMulti']
+ - False in add_attr_check_actual.objects[0]['ansible-BoolMulti']
+ - add_attr_check_actual.objects[0]['ansible-BytesMulti'] | length == 2
+ - "'YmFy' in add_attr_check_actual.objects[0]['ansible-BytesMulti']"
+ - "'Zm9v' in add_attr_check_actual.objects[0]['ansible-BytesMulti']"
+ - add_attr_check_actual.objects[0]['ansible-DateTimeMulti'] | length == 2
+ - "'1601-01-01T01:00:00.0000000Z' in add_attr_check_actual.objects[0]['ansible-DateTimeMulti']"
+ - "'1969-12-31T23:00:00.0000000Z' in add_attr_check_actual.objects[0]['ansible-DateTimeMulti']"
+ - add_attr_check_actual.objects[0]['ansible-IntMulti'] | length == 2
+ - 0 in add_attr_check_actual.objects[0]['ansible-IntMulti']
+ - 1 in add_attr_check_actual.objects[0]['ansible-IntMulti']
+ - add_attr_check_actual.objects[0]['ansible-SDMulti'] | length == 2
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in add_attr_check_actual.objects[0]['ansible-SDMulti']"
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)' in add_attr_check_actual.objects[0]['ansible-SDMulti']"
+ - add_attr_check_actual.objects[0]['ansible-StringMulti'] | length == 2
+ - "'multi 1' in add_attr_check_actual.objects[0]['ansible-StringMulti']"
+ - "'multi 2' in add_attr_check_actual.objects[0]['ansible-StringMulti']"
+
+- name: add attribute
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ description: test description
+ display_name: display name
+ attributes:
+ add:
+ ansible-BoolMulti:
+ - false
+ - true
+ ansible-BytesMulti:
+ - type: bytes
+ value: Zm9v
+ - type: bytes
+ value: dGVzdA==
+ - type: bytes
+ value: Y2FmZQ==
+ ansible-DateTimeMulti:
+ - type: date_time
+ value: '1970-01-01T00:00:00+01:00'
+ - type: date_time
+ value: '1970-01-01T00:00:00'
+ - type: date_time
+ value: '2023-01-17T16:00:31+00:30'
+ ansible-IntMulti:
+ - 0
+ - 2
+ - 3
+ ansible-SDMulti:
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG)
+ ansible-StringMulti:
+ - multi 1
+ - '3'
+ - '4'
+ state: present
+ register: add_attr
+
+- name: get result of add attribute
+ object_info:
+ identity: '{{ sub_object.object_guid }}'
+ properties:
+ - ansible-BoolMulti
+ - ansible-BytesMulti
+ - ansible-DateTimeMulti
+ - ansible-IntMulti
+ - ansible-SDMulti
+ - ansible-StringMulti
+ register: add_attr_actual
+
+- name: assert add attribute
+ assert:
+ that:
+ - add_attr is changed
+ - add_attr_actual.objects[0]['ansible-BoolMulti'] | length == 2
+ - True in add_attr_actual.objects[0]['ansible-BoolMulti']
+ - False in add_attr_actual.objects[0]['ansible-BoolMulti']
+ - add_attr_actual.objects[0]['ansible-BytesMulti'] | length == 4
+ - "'YmFy' in add_attr_actual.objects[0]['ansible-BytesMulti']"
+ - "'Zm9v' in add_attr_actual.objects[0]['ansible-BytesMulti']"
+ - "'dGVzdA==' in add_attr_actual.objects[0]['ansible-BytesMulti']"
+ - "'Y2FmZQ==' in add_attr_actual.objects[0]['ansible-BytesMulti']"
+ - add_attr_actual.objects[0]['ansible-DateTimeMulti'] | length == 4
+ - "'1601-01-01T01:00:00.0000000Z' in add_attr_actual.objects[0]['ansible-DateTimeMulti']"
+ - "'1969-12-31T23:00:00.0000000Z' in add_attr_actual.objects[0]['ansible-DateTimeMulti']"
+ - "'1970-01-01T00:00:00.0000000Z' in add_attr_actual.objects[0]['ansible-DateTimeMulti']"
+ - "'2023-01-17T15:30:31.0000000Z' in add_attr_actual.objects[0]['ansible-DateTimeMulti']"
+ - add_attr_actual.objects[0]['ansible-IntMulti'] | length == 4
+ - 0 in add_attr_actual.objects[0]['ansible-IntMulti']
+ - 1 in add_attr_actual.objects[0]['ansible-IntMulti']
+ - 2 in add_attr_actual.objects[0]['ansible-IntMulti']
+ - 3 in add_attr_actual.objects[0]['ansible-IntMulti']
+ - add_attr_actual.objects[0]['ansible-SDMulti'] | length == 4
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in add_attr_actual.objects[0]['ansible-SDMulti']"
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)' in add_attr_actual.objects[0]['ansible-SDMulti']"
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)' in add_attr_actual.objects[0]['ansible-SDMulti']"
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG)' in add_attr_actual.objects[0]['ansible-SDMulti']"
+ - add_attr_actual.objects[0]['ansible-StringMulti'] | length == 4
+ - "'multi 1' in add_attr_actual.objects[0]['ansible-StringMulti']"
+ - "'multi 2' in add_attr_actual.objects[0]['ansible-StringMulti']"
+ - "'3' in add_attr_actual.objects[0]['ansible-StringMulti']"
+ - "'4' in add_attr_actual.objects[0]['ansible-StringMulti']"
+
+- name: add attribute - idempotent
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ description: test description
+ display_name: display name
+ attributes:
+ add:
+ ansible-BoolMulti:
+ - false
+ - true
+ ansible-BytesMulti:
+ - type: bytes
+ value: Zm9v
+ - type: bytes
+ value: dGVzdA==
+ - type: bytes
+ value: Y2FmZQ==
+ ansible-DateTimeMulti:
+ - type: date_time
+ value: '1970-01-01T00:00:00+01:00'
+ - type: date_time
+ value: '1970-01-01T00:00:00'
+ - type: date_time
+ value: '2023-01-17T16:00:31+00:30'
+ ansible-IntMulti:
+ - 0
+ - 2
+ - 3
+ ansible-SDMulti:
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG)
+ ansible-StringMulti:
+ - multi 1
+ - '3'
+ - '4'
+ state: present
+ register: add_attr_again
+
+- name: assert add attribute - idempotent
+ assert:
+ that:
+ - not add_attr_again is changed
+
+- name: remove attribute - check
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ attributes:
+ remove:
+ ansible-BoolMulti:
+ - type: bool
+ value: '' # Will be false when casted
+ ansible-BytesMulti:
+ - type: bytes
+ value: dGVzdA==
+ - type: bytes
+ value: Y2FmZQ==
+ ansible-DateTimeMulti:
+ - type: date_time
+ value: '1970-01-01T00:00:00'
+ - type: date_time
+ value: '2023-01-17T16:00:31+00:30'
+ ansible-IntMulti:
+ - type: int
+ value: '2'
+ - type: raw
+ value: 3
+ ansible-SDMulti:
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG)
+ ansible-StringMulti:
+ - multi 1
+ - type: string
+ value: 3
+ - type: raw
+ value: '4'
+ state: present
+ register: remove_attr_check
+ check_mode: true
+
+- name: get result of remove attribute - check
+ object_info:
+ identity: '{{ sub_object.object_guid }}'
+ properties:
+ - ansible-BoolMulti
+ - ansible-BytesMulti
+ - ansible-DateTimeMulti
+ - ansible-IntMulti
+ - ansible-SDMulti
+ - ansible-StringMulti
+ register: remove_attr_check_actual
+
+- name: assert remove attribute - check
+ assert:
+ that:
+ - remove_attr_check is changed
+ - remove_attr_check_actual.objects[0]['ansible-BoolMulti'] | length == 2
+ - True in remove_attr_check_actual.objects[0]['ansible-BoolMulti']
+ - False in remove_attr_check_actual.objects[0]['ansible-BoolMulti']
+ - remove_attr_check_actual.objects[0]['ansible-BytesMulti'] | length == 4
+ - "'YmFy' in remove_attr_check_actual.objects[0]['ansible-BytesMulti']"
+ - "'Zm9v' in remove_attr_check_actual.objects[0]['ansible-BytesMulti']"
+ - "'dGVzdA==' in remove_attr_check_actual.objects[0]['ansible-BytesMulti']"
+ - "'Y2FmZQ==' in remove_attr_check_actual.objects[0]['ansible-BytesMulti']"
+ - remove_attr_check_actual.objects[0]['ansible-DateTimeMulti'] | length == 4
+ - "'1601-01-01T01:00:00.0000000Z' in remove_attr_check_actual.objects[0]['ansible-DateTimeMulti']"
+ - "'1969-12-31T23:00:00.0000000Z' in remove_attr_check_actual.objects[0]['ansible-DateTimeMulti']"
+ - "'1970-01-01T00:00:00.0000000Z' in remove_attr_check_actual.objects[0]['ansible-DateTimeMulti']"
+ - "'2023-01-17T15:30:31.0000000Z' in remove_attr_check_actual.objects[0]['ansible-DateTimeMulti']"
+ - remove_attr_check_actual.objects[0]['ansible-IntMulti'] | length == 4
+ - 0 in remove_attr_check_actual.objects[0]['ansible-IntMulti']
+ - 1 in remove_attr_check_actual.objects[0]['ansible-IntMulti']
+ - 2 in remove_attr_check_actual.objects[0]['ansible-IntMulti']
+ - 3 in remove_attr_check_actual.objects[0]['ansible-IntMulti']
+ - remove_attr_check_actual.objects[0]['ansible-SDMulti'] | length == 4
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in remove_attr_check_actual.objects[0]['ansible-SDMulti']"
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)' in remove_attr_check_actual.objects[0]['ansible-SDMulti']"
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)' in remove_attr_check_actual.objects[0]['ansible-SDMulti']"
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG)' in remove_attr_check_actual.objects[0]['ansible-SDMulti']"
+ - remove_attr_check_actual.objects[0]['ansible-StringMulti'] | length == 4
+ - "'multi 1' in remove_attr_check_actual.objects[0]['ansible-StringMulti']"
+ - "'multi 2' in remove_attr_check_actual.objects[0]['ansible-StringMulti']"
+ - "'3' in remove_attr_check_actual.objects[0]['ansible-StringMulti']"
+ - "'4' in remove_attr_check_actual.objects[0]['ansible-StringMulti']"
+
+- name: remove attribute
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ attributes:
+ remove:
+ ansible-BoolMulti:
+ - type: bool
+ value: '' # Will be false when casted
+ ansible-BytesMulti:
+ - type: bytes
+ value: dGVzdA==
+ - type: bytes
+ value: Y2FmZQ==
+ ansible-DateTimeMulti:
+ - type: date_time
+ value: '1970-01-01T00:00:00'
+ - type: date_time
+ value: '2023-01-17T16:00:31+00:30'
+ ansible-IntMulti:
+ - type: int
+ value: '2'
+ - type: raw
+ value: 3
+ ansible-SDMulti:
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG)
+ ansible-StringMulti:
+ - type: string
+ value: 3
+ - type: raw
+ value: '4'
+ state: present
+ register: remove_attr
+
+- name: get result of remove attribute
+ object_info:
+ identity: '{{ sub_object.object_guid }}'
+ properties:
+ - ansible-BoolMulti
+ - ansible-BytesMulti
+ - ansible-DateTimeMulti
+ - ansible-IntMulti
+ - ansible-SDMulti
+ - ansible-StringMulti
+ register: remove_attr_actual
+
+- name: assert remove attribute
+ assert:
+ that:
+ - remove_attr is changed
+ - remove_attr_actual.objects[0]['ansible-BoolMulti'] == True
+ - remove_attr_actual.objects[0]['ansible-BytesMulti'] | length == 2
+ - "'YmFy' in remove_attr_actual.objects[0]['ansible-BytesMulti']"
+ - "'Zm9v' in remove_attr_actual.objects[0]['ansible-BytesMulti']"
+ - remove_attr_actual.objects[0]['ansible-DateTimeMulti'] | length == 2
+ - "'1601-01-01T01:00:00.0000000Z' in remove_attr_actual.objects[0]['ansible-DateTimeMulti']"
+ - "'1969-12-31T23:00:00.0000000Z' in remove_attr_actual.objects[0]['ansible-DateTimeMulti']"
+ - remove_attr_actual.objects[0]['ansible-IntMulti'] | length == 2
+ - 0 in remove_attr_actual.objects[0]['ansible-IntMulti']
+ - 1 in remove_attr_actual.objects[0]['ansible-IntMulti']
+ - remove_attr_actual.objects[0]['ansible-SDMulti'] | length == 2
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in remove_attr_actual.objects[0]['ansible-SDMulti']"
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)' in remove_attr_actual.objects[0]['ansible-SDMulti']"
+ - remove_attr_actual.objects[0]['ansible-StringMulti'] | length == 2
+ - "'multi 1' in remove_attr_actual.objects[0]['ansible-StringMulti']"
+ - "'multi 2' in remove_attr_actual.objects[0]['ansible-StringMulti']"
+
+- name: remove attribute - idempotent
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ attributes:
+ remove:
+ ansible-BoolMulti:
+ - type: bool
+ value: '' # Will be false when casted
+ ansible-BytesMulti:
+ - type: bytes
+ value: dGVzdA==
+ - type: bytes
+ value: Y2FmZQ==
+ ansible-DateTimeMulti:
+ - type: date_time
+ value: '1970-01-01T00:00:00'
+ - type: date_time
+ value: '2023-01-17T16:00:31+00:30'
+ ansible-IntMulti:
+ - type: int
+ value: '2'
+ - type: raw
+ value: 3
+ ansible-SDMulti:
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DG)
+ ansible-StringMulti:
+ - type: string
+ value: 3
+ - type: raw
+ value: '4'
+ state: present
+ register: remove_attr_again
+
+- name: assert remove attribute - idempotent
+ assert:
+ that:
+ - not remove_attr_again is changed
+
+- name: set attribute - check
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ attributes:
+ set:
+ ansible-BoolSingle: true
+ ansible-BoolMulti: false
+ ansible-BytesSingle:
+ type: bytes
+ value: YmFy
+ ansible-BytesMulti:
+ - type: bytes
+ value: dGVzdA==
+ - type: bytes
+ value: YmFy
+ ansible-DateTimeSingle:
+ type: date_time
+ value: '1601-01-01T00:00:00Z'
+ ansible-DateTimeMulti:
+ - type: date_time
+ value: '1970-01-01T00:00:00'
+ - type: date_time
+ value: '1601-01-01T00:01:00.1-02:00'
+ ansible-IntSingle: 1
+ ansible-IntMulti:
+ - 0
+ - 3
+ ansible-SDSingle:
+ type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)
+ ansible-SDMulti:
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)
+ ansible-StringSingle: Single
+ ansible-StringMulti:
+ - multi 1
+ - multi 3
+ state: present
+ register: set_attr_check
+ check_mode: true
+
+- name: get result of set attribute - check
+ object_info:
+ identity: '{{ sub_object.object_guid }}'
+ properties:
+ - ansible-BoolSingle
+ - ansible-BoolMulti
+ - ansible-BytesSingle
+ - ansible-BytesMulti
+ - ansible-DateTimeSingle
+ - ansible-DateTimeMulti
+ - ansible-IntSingle
+ - ansible-IntMulti
+ - ansible-SDSingle
+ - ansible-SDMulti
+ - ansible-StringSingle
+ - ansible-StringMulti
+ register: set_attr_check_actual
+
+- name: assert set attribute - check
+ assert:
+ that:
+ - set_attr_check is changed
+ - set_attr_check_actual.objects[0]['ansible-BoolSingle'] == False
+ - set_attr_check_actual.objects[0]['ansible-BoolMulti'] == True
+ - set_attr_check_actual.objects[0]['ansible-BytesSingle'] == "Zm9v"
+ - set_attr_check_actual.objects[0]['ansible-BytesMulti'] | length == 2
+ - "'YmFy' in set_attr_check_actual.objects[0]['ansible-BytesMulti']"
+ - "'Zm9v' in set_attr_check_actual.objects[0]['ansible-BytesMulti']"
+ - set_attr_check_actual.objects[0]['ansible-DateTimeSingle'] == "1970-01-01T00:00:00.0000000Z"
+ - set_attr_check_actual.objects[0]['ansible-DateTimeMulti'] | length == 2
+ - "'1601-01-01T01:00:00.0000000Z' in set_attr_check_actual.objects[0]['ansible-DateTimeMulti']"
+ - "'1969-12-31T23:00:00.0000000Z' in set_attr_check_actual.objects[0]['ansible-DateTimeMulti']"
+ - set_attr_check_actual.objects[0]['ansible-IntSingle'] == 0
+ - set_attr_check_actual.objects[0]['ansible-IntMulti'] | length == 2
+ - 0 in set_attr_check_actual.objects[0]['ansible-IntMulti']
+ - 1 in set_attr_check_actual.objects[0]['ansible-IntMulti']
+ - set_attr_check_actual.objects[0]['ansible-SDSingle'] == "O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)"
+ - set_attr_check_actual.objects[0]['ansible-SDMulti'] | length == 2
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in set_attr_check_actual.objects[0]['ansible-SDMulti']"
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)' in set_attr_check_actual.objects[0]['ansible-SDMulti']"
+ - set_attr_check_actual.objects[0]['ansible-StringSingle'] == "single"
+ - set_attr_check_actual.objects[0]['ansible-StringMulti'] | length == 2
+ - "'multi 1' in set_attr_check_actual.objects[0]['ansible-StringMulti']"
+ - "'multi 2' in set_attr_check_actual.objects[0]['ansible-StringMulti']"
+
+- name: set attribute
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ attributes:
+ set:
+ ansible-BoolSingle: true
+ ansible-BoolMulti: false
+ ansible-BytesSingle:
+ type: bytes
+ value: YmFy
+ ansible-BytesMulti:
+ - type: bytes
+ value: dGVzdA==
+ - type: bytes
+ value: YmFy
+ ansible-DateTimeSingle:
+ type: date_time
+ value: '1601-01-01T00:00:00Z'
+ ansible-DateTimeMulti:
+ - type: date_time
+ value: '1970-01-01T00:00:00'
+ - type: date_time
+ value: '1601-01-01T00:01:00.0-02:00'
+ ansible-IntSingle: 1
+ ansible-IntMulti:
+ - 0
+ - 3
+ ansible-SDSingle:
+ type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)
+ ansible-SDMulti:
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)
+ ansible-StringSingle: Single
+ ansible-StringMulti:
+ - multi 1
+ - multi 3
+ state: present
+ register: set_attr
+
+- name: get result of set attribute
+ object_info:
+ identity: '{{ sub_object.object_guid }}'
+ properties:
+ - ansible-BoolSingle
+ - ansible-BoolMulti
+ - ansible-BytesSingle
+ - ansible-BytesMulti
+ - ansible-DateTimeSingle
+ - ansible-DateTimeMulti
+ - ansible-IntSingle
+ - ansible-IntMulti
+ - ansible-SDSingle
+ - ansible-SDMulti
+ - ansible-StringSingle
+ - ansible-StringMulti
+ register: set_attr_actual
+
+- name: assert set attribute
+ assert:
+ that:
+ - set_attr is changed
+ - set_attr_actual.objects[0]['ansible-BoolSingle'] == True
+ - set_attr_actual.objects[0]['ansible-BoolMulti'] == False
+ - set_attr_actual.objects[0]['ansible-BytesSingle'] == "YmFy"
+ - set_attr_actual.objects[0]['ansible-BytesMulti'] | length == 2
+ - "'YmFy' in set_attr_actual.objects[0]['ansible-BytesMulti']"
+ - "'dGVzdA==' in set_attr_actual.objects[0]['ansible-BytesMulti']"
+ - set_attr_actual.objects[0]['ansible-DateTimeSingle'] == "1601-01-01T00:00:00.0000000Z"
+ - set_attr_actual.objects[0]['ansible-DateTimeMulti'] | length == 2
+ - "'1601-01-01T02:01:00.0000000Z' in set_attr_actual.objects[0]['ansible-DateTimeMulti']"
+ - "'1970-01-01T00:00:00.0000000Z' in set_attr_actual.objects[0]['ansible-DateTimeMulti']"
+ - set_attr_actual.objects[0]['ansible-IntSingle'] == 1
+ - set_attr_actual.objects[0]['ansible-IntMulti'] | length == 2
+ - 0 in set_attr_actual.objects[0]['ansible-IntMulti']
+ - 3 in set_attr_actual.objects[0]['ansible-IntMulti']
+ - set_attr_actual.objects[0]['ansible-SDSingle'] == "O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)"
+ - set_attr_actual.objects[0]['ansible-SDMulti'] | length == 2
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in set_attr_actual.objects[0]['ansible-SDMulti']"
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)' in set_attr_actual.objects[0]['ansible-SDMulti']"
+ - set_attr_actual.objects[0]['ansible-StringSingle'] == "Single"
+ - set_attr_actual.objects[0]['ansible-StringMulti'] | length == 2
+ - "'multi 1' in set_attr_actual.objects[0]['ansible-StringMulti']"
+ - "'multi 3' in set_attr_actual.objects[0]['ansible-StringMulti']"
+
+- name: set attribute - idempotent
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ attributes:
+ set:
+ ansible-BoolSingle: true
+ ansible-BoolMulti: false
+ ansible-BytesSingle:
+ type: bytes
+ value: YmFy
+ ansible-BytesMulti:
+ - type: bytes
+ value: dGVzdA==
+ - type: bytes
+ value: YmFy
+ ansible-DateTimeSingle:
+ type: date_time
+ value: '1601-01-01T00:00:00Z'
+ ansible-DateTimeMulti:
+ - type: date_time
+ value: '1970-01-01T00:00:00'
+ - type: date_time
+ value: '1601-01-01T00:01:00.0-02:00'
+ ansible-IntSingle: 1
+ ansible-IntMulti:
+ - 0
+ - 3
+ ansible-SDSingle:
+ type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)
+ ansible-SDMulti:
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)
+ - type: security_descriptor
+ value: O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)
+ ansible-StringSingle: Single
+ ansible-StringMulti:
+ - multi 1
+ - multi 3
+ state: present
+ register: set_attr_again
+
+- name: assert set attribute - idempotent
+ assert:
+ that:
+ - not set_attr_again is changed
+
+- name: clear attribute - check
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ attributes:
+ set:
+ ansible-BoolSingle:
+ ansible-BoolMulti:
+ ansible-BytesSingle: []
+ ansible-BytesMulti: []
+ ansible-DateTimeSingle: ~
+ ansible-DateTimeMulti: ~
+ ansible-IntSingle:
+ ansible-IntMulti:
+ ansible-SDSingle:
+ ansible-SDMulti:
+ ansible-StringSingle:
+ ansible-StringMulti:
+ state: present
+ register: clear_attr_check
+ check_mode: true
+
+- name: get result of clear attribute - check
+ object_info:
+ identity: '{{ sub_object.object_guid }}'
+ properties:
+ - ansible-BoolSingle
+ - ansible-BoolMulti
+ - ansible-BytesSingle
+ - ansible-BytesMulti
+ - ansible-DateTimeSingle
+ - ansible-DateTimeMulti
+ - ansible-IntSingle
+ - ansible-IntMulti
+ - ansible-SDSingle
+ - ansible-SDMulti
+ - ansible-StringSingle
+ - ansible-StringMulti
+ register: clear_attr_check_actual
+
+- name: assert clear attribute - check
+ assert:
+ that:
+ - clear_attr_check is changed
+ - clear_attr_check_actual.objects[0]['ansible-BoolSingle'] == True
+ - clear_attr_check_actual.objects[0]['ansible-BoolMulti'] == False
+ - clear_attr_check_actual.objects[0]['ansible-BytesSingle'] == "YmFy"
+ - clear_attr_check_actual.objects[0]['ansible-BytesMulti'] | length == 2
+ - "'YmFy' in clear_attr_check_actual.objects[0]['ansible-BytesMulti']"
+ - "'dGVzdA==' in clear_attr_check_actual.objects[0]['ansible-BytesMulti']"
+ - clear_attr_check_actual.objects[0]['ansible-DateTimeSingle'] == "1601-01-01T00:00:00.0000000Z"
+ - clear_attr_check_actual.objects[0]['ansible-DateTimeMulti'] | length == 2
+ - "'1601-01-01T02:01:00.0000000Z' in clear_attr_check_actual.objects[0]['ansible-DateTimeMulti']"
+ - "'1970-01-01T00:00:00.0000000Z' in clear_attr_check_actual.objects[0]['ansible-DateTimeMulti']"
+ - clear_attr_check_actual.objects[0]['ansible-IntSingle'] == 1
+ - clear_attr_check_actual.objects[0]['ansible-IntMulti'] | length == 2
+ - 0 in clear_attr_check_actual.objects[0]['ansible-IntMulti']
+ - 3 in clear_attr_check_actual.objects[0]['ansible-IntMulti']
+ - clear_attr_check_actual.objects[0]['ansible-SDSingle'] == "O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AN)"
+ - clear_attr_check_actual.objects[0]['ansible-SDMulti'] | length == 2
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)' in clear_attr_check_actual.objects[0]['ansible-SDMulti']"
+ - "'O:DAG:DAD:PAI(A;CI;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;DC)' in clear_attr_check_actual.objects[0]['ansible-SDMulti']"
+ - clear_attr_check_actual.objects[0]['ansible-StringSingle'] == "Single"
+ - clear_attr_check_actual.objects[0]['ansible-StringMulti'] | length == 2
+ - "'multi 1' in clear_attr_check_actual.objects[0]['ansible-StringMulti']"
+ - "'multi 3' in clear_attr_check_actual.objects[0]['ansible-StringMulti']"
+
+- name: clear attribute
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ attributes:
+ set:
+ ansible-BoolSingle:
+ ansible-BoolMulti:
+ ansible-BytesSingle: []
+ ansible-BytesMulti: []
+ ansible-DateTimeSingle: ~
+ ansible-DateTimeMulti: ~
+ ansible-IntSingle:
+ ansible-IntMulti:
+ ansible-SDSingle:
+ ansible-SDMulti:
+ ansible-StringSingle:
+ ansible-StringMulti:
+ state: present
+ register: clear_attr
+
+- name: get result of clear attribute
+ object_info:
+ identity: '{{ sub_object.object_guid }}'
+ properties:
+ - ansible-BoolSingle
+ - ansible-BoolMulti
+ - ansible-BytesSingle
+ - ansible-BytesMulti
+ - ansible-DateTimeSingle
+ - ansible-DateTimeMulti
+ - ansible-IntSingle
+ - ansible-IntMulti
+ - ansible-SDSingle
+ - ansible-SDMulti
+ - ansible-StringSingle
+ - ansible-StringMulti
+ register: clear_attr_actual
+
+- name: assert clear attribute
+ assert:
+ that:
+ - clear_attr is changed
+ - clear_attr_actual.objects[0]['ansible-BoolSingle'] == None
+ - clear_attr_actual.objects[0]['ansible-BoolMulti'] == None
+ - clear_attr_actual.objects[0]['ansible-BytesSingle'] == None
+ - clear_attr_actual.objects[0]['ansible-BytesMulti'] == None
+ - clear_attr_actual.objects[0]['ansible-DateTimeSingle'] == None
+ - clear_attr_actual.objects[0]['ansible-DateTimeMulti'] == None
+ - clear_attr_actual.objects[0]['ansible-IntSingle'] == None
+ - clear_attr_actual.objects[0]['ansible-IntMulti'] == None
+ - clear_attr_actual.objects[0]['ansible-SDSingle'] == None
+ - clear_attr_actual.objects[0]['ansible-SDMulti'] == None
+ - clear_attr_actual.objects[0]['ansible-StringSingle'] == None
+ - clear_attr_actual.objects[0]['ansible-StringMulti'] == None
+
+- name: clear attribute - idempotent
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ attributes:
+ set:
+ ansible-BoolSingle:
+ ansible-BoolMulti:
+ ansible-BytesSingle: []
+ ansible-BytesMulti: []
+ ansible-DateTimeSingle: ~
+ ansible-DateTimeMulti: ~
+ ansible-IntSingle:
+ ansible-IntMulti:
+ ansible-SDSingle:
+ ansible-SDMulti:
+ ansible-StringSingle:
+ ansible-StringMulti:
+ state: present
+ register: clear_attr_again
+
+- name: assert clear attribute - idempotent
+ assert:
+ that:
+ - not clear_attr_again is changed
+
+- name: unset display and description
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ display_name: ''
+ description: ''
+ register: unset_normal
+
+- name: get result of unset display and description
+ object_info:
+ identity: '{{ sub_object.object_guid }}'
+ properties:
+ - DisplayName
+ - Description
+ register: unset_normal_actual
+
+- name: assert unset display and description
+ assert:
+ that:
+ - unset_normal is changed
+ - unset_normal_actual.objects[0].DisplayName == None
+ - unset_normal_actual.objects[0].Description == None
+
+- name: unset display and description - idempotent
+ object:
+ name: Contact
+ path: CN=My\, Container,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ type: contact
+ display_name: ''
+ description: ''
+ register: unset_normal_again
+
+- name: assert unset display and description - idempotent
+ assert:
+ that:
+ - not unset_normal_again is changed
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object_info/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/aliases
new file mode 100644
index 000000000..ccd8a25e8
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/aliases
@@ -0,0 +1,2 @@
+windows
+shippable/windows/group1
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object_info/handlers/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/handlers/main.yml
new file mode 100644
index 000000000..fa30dcbb3
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/handlers/main.yml
@@ -0,0 +1,4 @@
+- name: remove test domain user
+ microsoft.ad.user:
+ name: '{{ test_user.distinguished_name }}'
+ state: absent
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object_info/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/meta/main.yml
new file mode 100644
index 000000000..4ce45dcfb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/meta/main.yml
@@ -0,0 +1,2 @@
+dependencies:
+- setup_domain
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/object_info/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/tasks/main.yml
new file mode 100644
index 000000000..01cee436b
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/object_info/tasks/main.yml
@@ -0,0 +1,186 @@
+- name: create test ad user
+ microsoft.ad.user:
+ name: Ansible Test
+ firstname: Ansible
+ surname: Test
+ company: Contoso R Us
+ password: Password01
+ state: present
+ password_never_expires: yes
+ groups:
+ set:
+ - Domain Users
+ enabled: false
+ register: test_user
+ notify: remove test domain user
+
+- name: set a binary attribute and return other useful info missing from above
+ ansible.windows.win_powershell:
+ parameters:
+ SecurityId: '{{ test_user.sid }}'
+ script: |
+ param($SecurityId)
+
+ Set-ADUser -Identity $SecurityId -Replace @{ audio = @([byte[]]@(1, 2, 3, 4), [byte[]]@(5, 6, 7, 8)) }
+
+ $user = Get-ADUser -Identity $SecurityId -Properties modifyTimestamp, ObjectGUID
+
+ [TimeZoneInfo]::ConvertTimeToUtc($user.modifyTimestamp).ToString('o')
+ $user.ObjectGUID.ToString()
+ ([System.Security.Principal.SecurityIdentifier]$SecurityId).Translate([System.Security.Principal.NTAccount]).Value
+ register: test_user_extras
+
+- name: set other test info for easier access
+ set_fact:
+ test_user_mod_date: '{{ test_user_extras.output[0] }}'
+ test_user_id: '{{ test_user_extras.output[1] }}'
+ test_user_name: '{{ test_user_extras.output[2] }}'
+
+- name: get properties for single user by DN
+ object_info:
+ identity: '{{ test_user.distinguished_name }}'
+ register: by_identity
+ check_mode: yes # Just verifies it runs in check mode
+
+- name: assert get properties for single user by DN
+ assert:
+ that:
+ - not by_identity is changed
+ - by_identity.objects | length == 1
+ - by_identity.objects[0].keys() | list | length == 4
+ - by_identity.objects[0].DistinguishedName == test_user.distinguished_name
+ - by_identity.objects[0].Name == 'Ansible Test'
+ - by_identity.objects[0].ObjectClass == 'user'
+ - by_identity.objects[0].ObjectGUID == test_user_id
+
+- name: get specific properties by GUID
+ object_info:
+ identity: '{{ test_user_id }}'
+ properties:
+ - audio # byte[]
+ - company # string
+ - department # not set
+ - logonCount # int
+ - modifyTimestamp # DateTime
+ - nTSecurityDescriptor # SecurityDescriptor as SDDL
+ - objectSID # SID
+ - ProtectedFromAccidentalDeletion # bool
+ - sAMAccountType # Test out the enum string attribute that we add
+ - userAccountControl # Test ou the enum string attribute that we add
+ register: by_guid_custom_props
+
+- name: assert get specific properties by GUID
+ assert:
+ that:
+ - not by_guid_custom_props is changed
+ - by_guid_custom_props.objects | length == 1
+ - by_guid_custom_props.objects[0].DistinguishedName == test_user.distinguished_name
+ - by_guid_custom_props.objects[0].Name == 'Ansible Test'
+ - by_guid_custom_props.objects[0].ObjectClass == 'user'
+ - by_guid_custom_props.objects[0].ObjectGUID == test_user_id
+ - not by_guid_custom_props.objects[0].ProtectedFromAccidentalDeletion
+ - by_guid_custom_props.objects[0].audio == ['BQYHCA==', 'AQIDBA==']
+ - by_guid_custom_props.objects[0].company == 'Contoso R Us'
+ - by_guid_custom_props.objects[0].department == None
+ - by_guid_custom_props.objects[0].logonCount == 0
+ - by_guid_custom_props.objects[0].modifyTimestamp == test_user_mod_date
+ - by_guid_custom_props.objects[0].nTSecurityDescriptor.startswith('O:')
+ - by_guid_custom_props.objects[0].objectSID.Name == test_user_name
+ - by_guid_custom_props.objects[0].objectSID.Sid == test_user.sid
+ - by_guid_custom_props.objects[0].sAMAccountType == 805306368
+ - by_guid_custom_props.objects[0].sAMAccountType_AnsibleFlags == ['SAM_USER_OBJECT']
+ - by_guid_custom_props.objects[0].userAccountControl == 66050
+ - by_guid_custom_props.objects[0].userAccountControl_AnsibleFlags == ['ADS_UF_ACCOUNTDISABLE', 'ADS_UF_NORMAL_ACCOUNT', 'ADS_UF_DONT_EXPIRE_PASSWD']
+
+- name: get the groupType attribute
+ object_info:
+ filter: sAMAccountName -eq 'Domain Admins'
+ properties:
+ - groupType
+ register: group_type_prop
+
+- name: assert get the groupType attribute
+ assert:
+ that:
+ - group_type_prop.objects[0].groupType_AnsibleFlags == ["GROUP_TYPE_ACCOUNT_GROUP", "GROUP_TYPE_SECURITY_ENABLED"]
+
+- name: create computer object for enc type test
+ computer:
+ name: MyComputer
+ state: present
+ kerberos_encryption_types:
+ set:
+ - des
+ - rc4
+ - aes128
+ - aes256
+ register: comp_info
+
+- block:
+ - name: get the supported encryption type attribute
+ object_info:
+ identity: '{{ comp_info.object_guid }}'
+ properties:
+ - msDS-SupportedEncryptionTypes
+ register: enc_type_prop
+
+ - name: assert get the supported encryption type attribute
+ assert:
+ that:
+ - enc_type_prop.objects[0]["msDS-SupportedEncryptionTypes"] == 31
+ - enc_type_prop.objects[0]["msDS-SupportedEncryptionTypes_AnsibleFlags"] == ["DES_CBC_CRC", "DES_CBC_MD5", "RC4_HMAC", "AES128_CTS_HMAC_SHA1_96", "AES256_CTS_HMAC_SHA1_96"]
+
+ always:
+ - name: remove computer object
+ computer:
+ name: MyComputer
+ identity: '{{ comp_info.object_guid }}'
+ state: absent
+
+- name: get invalid property
+ object_info:
+ filter: sAMAccountName -eq 'Ansible Test'
+ properties:
+ - FakeProperty
+ register: invalid_prop_warning
+
+- name: assert get invalid property
+ assert:
+ that:
+ - not invalid_prop_warning is changed
+ - invalid_prop_warning.objects | length == 0
+ - invalid_prop_warning.warnings | length == 1
+ - '"Failed to retrieve properties for AD object" not in invalid_prop_warning.warnings[0]'
+
+- name: get by ldap filter returning multiple
+ object_info:
+ ldap_filter: (&(objectClass=computer)(objectCategory=computer))
+ properties: '*'
+ register: multiple_ldap
+
+- name: assert get by ldap filter returning multiple
+ assert:
+ that:
+ - not multiple_ldap is changed
+ - multiple_ldap.objects | length > 0
+
+- name: get by filter returning multiple
+ object_info:
+ filter: objectClass -eq 'computer' -and objectCategory -eq 'computer'
+ properties: '*'
+ register: multiple_filter
+
+- name: assert get by filter returning multiple
+ assert:
+ that:
+ - not multiple_filter is changed
+ - multiple_filter.objects | length > 0
+
+- name: fail trying to use variable in filter
+ object_info:
+ filter: sAMAccountName -eq $domainUsername
+ properties:
+ - sAMAccountName
+ register: fail_filter_var
+ failed_when:
+ - '"Variable: ''domainUsername'' found in expression: $domainUsername is not defined." not in fail_filter_var.msg'
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/aliases
new file mode 100644
index 000000000..ccd8a25e8
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/aliases
@@ -0,0 +1,2 @@
+windows
+shippable/windows/group1
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/meta/main.yml
new file mode 100644
index 000000000..4ce45dcfb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/meta/main.yml
@@ -0,0 +1,2 @@
+dependencies:
+- setup_domain
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/main.yml
new file mode 100644
index 000000000..87f814655
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/main.yml
@@ -0,0 +1,14 @@
+- name: remove temp computer
+ computer:
+ name: My, Computer
+ state: absent
+
+- block:
+ - import_tasks: tests.yml
+
+ always:
+ - name: remove temp computer
+ computer:
+ name: My, Computer
+ identity: '{{ object_identity | default(omit) }}'
+ state: absent
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/tests.yml b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/tests.yml
new file mode 100644
index 000000000..c16237d40
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/offline_join/tasks/tests.yml
@@ -0,0 +1,150 @@
+- name: create computer account
+ computer:
+ name: My, Computer
+ state: present
+ sam_account_name: MyComputer
+ register: comp_account
+
+- set_fact:
+ object_identity: '{{ comp_account.object_guid }}'
+
+- name: get initial password info
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - pwdLastSet
+ register: pwd_info
+
+- name: get blob - check
+ offline_join:
+ name: My, Computer
+ register: blob_check
+ check_mode: true
+
+- name: get result of get blob - check
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - pwdLastSet
+ register: blob_check_actual
+
+- name: assert get blob - check
+ assert:
+ that:
+ - blob_check is changed
+ - blob_check.blob == ''
+ - blob_check_actual.objects[0].pwdLastSet == pwd_info.objects[0].pwdLastSet
+
+- name: get blob
+ offline_join:
+ name: My, Computer
+ register: blob
+
+- name: get result of get blob
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - pwdLastSet
+ register: blob_actual
+
+- name: assert get blob
+ assert:
+ that:
+ - blob is changed
+ - blob.blob != None
+ - blob.blob != ''
+ - blob_actual.objects[0].pwdLastSet > pwd_info.objects[0].pwdLastSet
+
+- block:
+ - name: create blob in file
+ offline_join:
+ identity: '{{ object_identity }}'
+ blob_path: C:\Windows\TEMP\ansible-blob
+ provision_root_ca_certs: true
+ register: blob_path
+
+ - name: get pwd result of create blob in file
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - pwdLastSet
+ register: blob_path_ad_actual
+
+ - name: get path result of create blob in file
+ ansible.windows.win_stat:
+ path: C:\Windows\TEMP\ansible-blob
+ register: blob_path_file_actual
+
+ - name: assert create blob in file
+ assert:
+ that:
+ - blob_path is changed
+ - blob_path.blob == None
+ - blob_path_ad_actual.objects[0].pwdLastSet > blob_actual.objects[0].pwdLastSet
+ - blob_path_file_actual.stat.exists
+
+ - name: create blob in file - idempotent
+ offline_join:
+ identity: '{{ object_identity }}'
+ blob_path: C:\Windows\TEMP\ansible-blob
+ register: blob_path_again
+
+ - name: get pwd result of create blob in file - idempotent
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - pwdLastSet
+ register: blob_path_ad_actual_again
+
+ - name: get path result of create blob in file - idempotent
+ ansible.windows.win_stat:
+ path: C:\Windows\TEMP\ansible-blob
+ register: blob_path_file_actual_again
+
+ - name: assert create blob in file - idempotent
+ assert:
+ that:
+ - not blob_path_again is changed
+ - blob_path_again.blob == None
+ - blob_path_ad_actual_again.objects[0].pwdLastSet == blob_path_ad_actual.objects[0].pwdLastSet
+ - blob_path_file_actual_again.stat.size == blob_path_file_actual.stat.size
+ - blob_path_file_actual_again.stat.lastwritetime == blob_path_file_actual.stat.lastwritetime
+
+ always:
+ - name: remove temp blob file
+ ansible.windows.win_file:
+ path: C:\Windows\TEMP\ansible-blob
+ state: absent
+
+- name: move computer object
+ computer:
+ name: My, Computer
+ identity: '{{ object_identity }}'
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+
+- name: fail to find computer outside default path
+ offline_join:
+ name: My, Computer
+ register: fail_find
+ failed_when: '("Failed to find domain computer account ''CN=My\, Computer,CN=Computers," ~ setup_domain_info.output[0].defaultNamingContext) not in fail_find.msg'
+
+- name: get blob of computer in different path
+ offline_join:
+ name: My, Computer
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ register: blob_ad_path
+
+- name: get result of get blob of computer in different pth
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - pwdLastSet
+ register: blob_ad_path_actual
+
+- name: assert get blob of computer in different path
+ assert:
+ that:
+ - blob_ad_path is changed
+ - blob_ad_path.blob != None
+ - blob_ad_path.blob != ''
+ - blob_ad_path_actual.objects[0].pwdLastSet > blob_path_ad_actual_again.objects[0].pwdLastSet
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/ou/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/ou/aliases
new file mode 100644
index 000000000..ccd8a25e8
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/ou/aliases
@@ -0,0 +1,2 @@
+windows
+shippable/windows/group1
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/ou/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/ou/meta/main.yml
new file mode 100644
index 000000000..4ce45dcfb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/ou/meta/main.yml
@@ -0,0 +1,2 @@
+dependencies:
+- setup_domain
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/main.yml
new file mode 100644
index 000000000..b024959e7
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/main.yml
@@ -0,0 +1,14 @@
+- name: remove temp OU
+ ou:
+ name: MyOU
+ state: absent
+
+- block:
+ - import_tasks: tests.yml
+
+ always:
+ - name: remove temp OU
+ ou:
+ name: MyOU
+ identity: '{{ object_identity | default(omit) }}'
+ state: absent
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/tests.yml b/ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/tests.yml
new file mode 100644
index 000000000..49d06aefb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/ou/tasks/tests.yml
@@ -0,0 +1,254 @@
+- name: create ou - check
+ ou:
+ name: MyOU
+ state: present
+ register: create_ou_check
+ check_mode: true
+
+- name: get result of create ou - check
+ object_info:
+ identity: '{{ create_ou_check.distinguished_name }}'
+ register: create_ou_check_actual
+
+- name: assert create ou - check
+ assert:
+ that:
+ - create_ou_check is changed
+ - create_ou_check.distinguished_name == 'OU=MyOU,' ~ setup_domain_info.output[0].defaultNamingContext
+ - create_ou_check.object_guid == '00000000-0000-0000-0000-000000000000'
+ - create_ou_check_actual.objects == []
+
+- name: create ou
+ ou:
+ name: MyOU
+ state: present
+ register: create_ou
+
+- set_fact:
+ object_identity: '{{ create_ou.object_guid }}'
+
+- name: get result of create ou
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - ProtectedFromAccidentalDeletion
+ register: create_ou_actual
+
+- name: assert create ou
+ assert:
+ that:
+ - create_ou is changed
+ - create_ou_actual.objects | length == 1
+ - create_ou.distinguished_name == 'OU=MyOU,' ~ setup_domain_info.output[0].defaultNamingContext
+ - create_ou.distinguished_name == create_ou_actual.objects[0].DistinguishedName
+ - create_ou.object_guid == create_ou_actual.objects[0].ObjectGUID
+ - create_ou_actual.objects[0].Name == 'MyOU'
+ - create_ou_actual.objects[0].ObjectClass == 'organizationalUnit'
+ - create_ou_actual.objects[0].ProtectedFromAccidentalDeletion == true
+
+- name: remove ou - check
+ ou:
+ name: MyOU
+ state: absent
+ register: remove_ou_check
+ check_mode: true
+
+- name: get result of remove ou - check
+ object_info:
+ identity: '{{ object_identity }}'
+ register: remove_ou_check_actual
+
+- name: assert remove ou - check
+ assert:
+ that:
+ - remove_ou_check is changed
+ - remove_ou_check_actual.objects | length == 1
+
+- name: remove ou
+ ou:
+ name: MyOU
+ state: absent
+ register: remove_ou
+
+- name: get result of remove ou
+ object_info:
+ identity: '{{ object_identity }}'
+ register: remove_ou_actual
+
+- name: assert remove ou
+ assert:
+ that:
+ - remove_ou is changed
+ - remove_ou_actual.objects == []
+
+- name: remove ou - idempotent
+ ou:
+ name: MyOU
+ state: absent
+ register: remove_ou_again
+
+- name: assert remove ou - idempotent
+ assert:
+ that:
+ - not remove_ou_again is changed
+
+- name: create parent OU
+ ou:
+ name: MyOU
+ state: present
+ register: parent_ou
+
+- set_fact:
+ object_identity: '{{ parent_ou.object_guid }}'
+
+- name: create ou with custom options
+ ou:
+ name: SubOU
+ path: '{{ parent_ou.distinguished_name }}'
+ state: present
+ city: Brisbane
+ country: AU
+ description: Custom Description
+ display_name: OU Display Name
+ managed_by: Domain Admins
+ postal_code: 4000
+ state_province: QLD
+ street: Main St
+ protect_from_deletion: false
+ attributes:
+ set:
+ postOfficeBox: My Box
+ register: create_ou_custom
+
+- name: get result of create ou with custom options
+ object_info:
+ identity: '{{ create_ou_custom.object_guid }}'
+ properties:
+ - c
+ - Description
+ - DisplayName
+ - l
+ - managedBy
+ - postalcode
+ - postOfficeBox
+ - st
+ - street
+ - ProtectedFromAccidentalDeletion
+ register: create_ou_custom_actual
+
+- name: assert create ou with custom options
+ assert:
+ that:
+ - create_ou_custom is changed
+ - create_ou_custom_actual.objects | length == 1
+ - create_ou_custom.distinguished_name == create_ou_custom_actual.objects[0].DistinguishedName
+ - create_ou_custom.object_guid == create_ou_custom_actual.objects[0].ObjectGUID
+ - create_ou_custom_actual.objects[0].l == 'Brisbane'
+ - create_ou_custom_actual.objects[0].c == 'AU'
+ - create_ou_custom_actual.objects[0].Description == 'Custom Description'
+ - create_ou_custom_actual.objects[0].DisplayName == 'OU Display Name'
+ - create_ou_custom_actual.objects[0].managedBy == 'CN=Domain Admins,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - create_ou_custom_actual.objects[0].postalcode == '4000'
+ - create_ou_custom_actual.objects[0].st == 'QLD'
+ - create_ou_custom_actual.objects[0].street == 'Main St'
+ - create_ou_custom_actual.objects[0].ProtectedFromAccidentalDeletion == False
+ - create_ou_custom_actual.objects[0].postOfficeBox == 'My Box'
+
+- name: change ou with custom options
+ ou:
+ name: SubOU
+ path: '{{ parent_ou.distinguished_name }}'
+ state: present
+ city: New York
+ country: US
+ description: Custom description
+ display_name: OU display Name
+ managed_by: Domain Users
+ postal_code: 10001
+ state_province: ''
+ street: Main
+ attributes:
+ set:
+ postOfficeBox: My box
+ register: change_ou
+
+- name: get result of change ou with custom options
+ object_info:
+ identity: '{{ create_ou_custom.object_guid }}'
+ properties:
+ - c
+ - Description
+ - DisplayName
+ - l
+ - managedBy
+ - postalcode
+ - postOfficeBox
+ - st
+ - street
+ - ProtectedFromAccidentalDeletion
+ register: change_ou_actual
+
+- name: assert change ou with custom options
+ assert:
+ that:
+ - change_ou is changed
+ - change_ou.distinguished_name == create_ou_custom.distinguished_name
+ - change_ou.object_guid == create_ou_custom.object_guid
+ - change_ou_actual.objects[0].l == 'New York'
+ - change_ou_actual.objects[0].c == 'US'
+ - change_ou_actual.objects[0].Description == 'Custom description'
+ - change_ou_actual.objects[0].DisplayName == 'OU display Name'
+ - change_ou_actual.objects[0].managedBy == 'CN=Domain Users,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - change_ou_actual.objects[0].postalcode == '10001'
+ - change_ou_actual.objects[0].st == None
+ - change_ou_actual.objects[0].street == 'Main'
+ - change_ou_actual.objects[0].ProtectedFromAccidentalDeletion == False
+ - change_ou_actual.objects[0].postOfficeBox == 'My box'
+
+- name: create new sub OU
+ ou:
+ name: NewSubOU
+ path: '{{ parent_ou.distinguished_name }}'
+ state: present
+ register: new_parent_ou
+
+- name: move and rename ou - check
+ ou:
+ name: InnerOU
+ path: '{{ new_parent_ou.distinguished_name }}'
+ identity: '{{ create_ou_custom.object_guid }}'
+ register: move_rename_check
+ check_mode: true
+
+- name: get result of move and rename ou - check
+ object_info:
+ identity: '{{ create_ou_custom.object_guid }}'
+ register: move_rename_check_actual
+
+- name: assert move and rename ou - check
+ assert:
+ that:
+ - move_rename_check is changed
+ - move_rename_check.distinguished_name == 'OU=InnerOU,' ~ new_parent_ou.distinguished_name
+ - move_rename_check_actual.objects[0].Name == 'SubOU'
+ - move_rename_check_actual.objects[0].DistinguishedName == create_ou_custom_actual.objects[0].DistinguishedName
+
+- name: move and rename ou
+ ou:
+ name: InnerOU
+ path: '{{ new_parent_ou.distinguished_name }}'
+ identity: '{{ create_ou_custom.object_guid }}'
+ register: move_rename
+
+- name: get result of move and rename ou
+ object_info:
+ identity: '{{ create_ou_custom.object_guid }}'
+ register: move_rename_actual
+
+- name: assert move and rename ou
+ assert:
+ that:
+ - move_rename is changed
+ - move_rename.distinguished_name == 'OU=InnerOU,' ~ new_parent_ou.distinguished_name
+ - move_rename_actual.objects[0].Name == 'InnerOU'
+ - move_rename_actual.objects[0].DistinguishedName == 'OU=InnerOU,' ~ new_parent_ou.distinguished_name
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/defaults/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/defaults/main.yml
new file mode 100644
index 000000000..5f70cc3f3
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/defaults/main.yml
@@ -0,0 +1,2 @@
+domain_realm: ansible.test
+domain_password: Password123!
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/main.yml
new file mode 100644
index 000000000..3376208c2
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/main.yml
@@ -0,0 +1,55 @@
+- name: ensure the ActiveDirectory module is installed
+ ansible.windows.win_feature:
+ name:
+ - RSAT-AD-PowerShell
+ state: present
+
+- name: check if domain is already set up
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ check_mode: true
+ register: domain_res
+
+# If the domain is not already set up and this is running under the domain
+# test target then run the tests for that target, otherwise do the bare
+# minimum setup. This allows us to do more complex testing for that module
+# if it is the only one to run.
+- include_tasks: '{{ (run_domain_test | default(False)) | ternary(role_path ~ "/../domain/tasks/test.yml", "setup.yml") }}'
+ when: domain_res is changed
+
+# While usually the reboot waits until it is fully done before continuing I've seen Server 2019 in CI still waiting
+# for things to initialise. By tested if ADWS is available with a simple check we can ensure the host is at least
+# ready to test AD. Typically I've found it takes about 60 retries so doubling it should cover even an absolute worst# case.
+- name: post domain setup test for ADWS to come online
+ ansible.windows.win_powershell:
+ parameters:
+ Delay: 5
+ Retries: 120
+ script: |
+ [CmdletBinding()]
+ param (
+ [int]$Delay,
+ [int]$Retries
+ )
+ $Ansible.Changed = $false
+ $attempts = 0
+ $err = $null
+ while ($true) {
+ $attempts++
+ try {
+ Get-ADRootDSE -ErrorAction Stop
+ break
+ }
+ catch {
+ if ($attempts -eq $Retries) {
+ throw
+ }
+ Start-Sleep -Seconds $Delay
+ }
+ }
+ $attempts
+ register: setup_domain_info
+ become: yes
+ become_method: runas
+ become_user: SYSTEM
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/setup.yml b/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/setup.yml
new file mode 100644
index 000000000..8c7cce1f6
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/setup_domain/tasks/setup.yml
@@ -0,0 +1,5 @@
+- name: ensure domain is present
+ domain:
+ dns_domain_name: '{{ domain_realm }}'
+ safe_mode_password: '{{ domain_password }}'
+ reboot: true
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/user/aliases b/ansible_collections/microsoft/ad/tests/integration/targets/user/aliases
new file mode 100644
index 000000000..ccd8a25e8
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/user/aliases
@@ -0,0 +1,2 @@
+windows
+shippable/windows/group1
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/user/meta/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/user/meta/main.yml
new file mode 100644
index 000000000..4ce45dcfb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/user/meta/main.yml
@@ -0,0 +1,2 @@
+dependencies:
+- setup_domain
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/main.yml b/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/main.yml
new file mode 100644
index 000000000..6dc722602
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/main.yml
@@ -0,0 +1,14 @@
+- name: remove temp user
+ user:
+ name: MyUser
+ state: absent
+
+- block:
+ - import_tasks: tests.yml
+
+ always:
+ - name: remove temp user
+ user:
+ name: MyUser
+ identity: '{{ object_identity | default(omit) }}'
+ state: absent
diff --git a/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/tests.yml b/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/tests.yml
new file mode 100644
index 000000000..e06c54959
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/targets/user/tasks/tests.yml
@@ -0,0 +1,1065 @@
+- name: create user with defaults - check
+ user:
+ name: MyUser
+ state: present
+ register: default_user_check
+ check_mode: true
+
+- name: get result of create user with defaults - check
+ object_info:
+ ldap_filter: (sAMAccountName=MyUser)
+ register: default_user_check_actual
+
+- name: assert create user with defaults - check
+ assert:
+ that:
+ - default_user_check is changed
+ - default_user_check_actual.objects == []
+ - default_user_check.distinguished_name == 'CN=MyUser,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - default_user_check.object_guid == '00000000-0000-0000-0000-000000000000'
+ - default_user_check.sid == 'S-1-5-0000'
+
+- name: create user with defaults
+ user:
+ name: MyUser
+ state: present
+ register: default_user
+
+- name: get result of create user with defaults
+ object_info:
+ ldap_filter: (sAMAccountName=MyUser)
+ properties:
+ - objectSid
+ - sAMAccountName
+ - userAccountControl
+ - userPrincipalName
+ register: default_user_actual
+
+- name: assert create user with defaults - check
+ assert:
+ that:
+ - default_user is changed
+ - default_user_actual.objects | length == 1
+ - default_user.distinguished_name == 'CN=MyUser,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - default_user.object_guid == default_user_actual.objects[0].ObjectGUID
+ - default_user.sid == default_user_actual.objects[0].objectSid.Sid
+ - default_user_actual.objects[0].sAMAccountName == 'MyUser'
+ - default_user_actual.objects[0].userPrincipalName == None
+ - '"ADS_UF_ACCOUNTDISABLE" in default_user_actual.objects[0].userAccountControl_AnsibleFlags'
+
+- set_fact:
+ object_identity: '{{ default_user.object_guid }}'
+ object_sid: '{{ default_user.sid }}'
+
+- name: create user with defaults - idempotent
+ user:
+ name: MyUser
+ state: present
+ register: default_user_again
+
+- name: assert create user with defaults - idempotent
+ assert:
+ that:
+ - not default_user_again is changed
+
+- name: rename user - check
+ user:
+ name: MyUser2
+ identity: '{{ object_identity }}'
+ register: rename_user_check
+ check_mode: true
+
+- name: get result of rename user - check
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - sAMAccountName
+ register: rename_user_check_actual
+
+- name: assert rename user - check
+ assert:
+ that:
+ - rename_user_check is changed
+ - rename_user_check.distinguished_name == 'CN=MyUser2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - rename_user_check_actual.objects[0].DistinguishedName == default_user.distinguished_name
+ - rename_user_check_actual.objects[0].Name == 'MyUser'
+ - rename_user_check_actual.objects[0].sAMAccountName == 'MyUser'
+
+- name: rename user
+ user:
+ name: MyUser2
+ identity: '{{ object_identity }}'
+ register: rename_user
+
+- name: get result of rename user
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - sAMAccountName
+ register: rename_user_actual
+
+- name: assert rename user
+ assert:
+ that:
+ - rename_user is changed
+ - rename_user.distinguished_name == 'CN=MyUser2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - rename_user_actual.objects[0].DistinguishedName == 'CN=MyUser2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - rename_user_actual.objects[0].Name == 'MyUser2'
+ - rename_user_actual.objects[0].sAMAccountName == 'MyUser'
+
+- name: rename user - idempotent
+ user:
+ name: MyUser2
+ identity: '{{ object_identity }}'
+ register: rename_user_again
+
+- name: assert rename user - idempotent
+ assert:
+ that:
+ - not rename_user_again is changed
+
+- name: move user - check
+ user:
+ name: MyUser2
+ identity: '{{ object_sid }}' # ID by SID
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ register: move_user_check
+ check_mode: true
+
+- name: get result of move user - check
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - sAMAccountName
+ register: move_user_check_actual
+
+- name: assert move user - check
+ assert:
+ that:
+ - move_user_check is changed
+ - move_user_check.distinguished_name == 'CN=MyUser2,' ~ setup_domain_info.output[0].defaultNamingContext
+ - move_user_check_actual.objects[0].DistinguishedName == 'CN=MyUser2,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - move_user_check_actual.objects[0].Name == 'MyUser2'
+ - move_user_check_actual.objects[0].sAMAccountName == 'MyUser'
+
+- name: move user
+ user:
+ name: MyUser2
+ identity: '{{ object_sid }}' # ID by SID
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ register: move_user
+
+- name: get result of move user
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - sAMAccountName
+ register: move_user_actual
+
+- name: assert move user
+ assert:
+ that:
+ - move_user is changed
+ - move_user.distinguished_name == 'CN=MyUser2,' ~ setup_domain_info.output[0].defaultNamingContext
+ - move_user_actual.objects[0].DistinguishedName == 'CN=MyUser2,' ~ setup_domain_info.output[0].defaultNamingContext
+ - move_user_actual.objects[0].Name == 'MyUser2'
+ - move_user_actual.objects[0].sAMAccountName == 'MyUser'
+
+- name: move user - idempotent
+ user:
+ name: MyUser2
+ identity: '{{ object_sid }}' # ID by SID
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ register: move_user_again
+
+- name: assert move user - idempotent
+ assert:
+ that:
+ - not move_user_again is changed
+
+- name: move user back
+ user:
+ name: MyUser
+ identity: MyUser # By sAMAccountName
+ path: CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+
+- name: update password from blank - skip for on_create
+ user:
+ name: MyUser
+ password: Password123!
+ update_password: on_create
+ register: change_pass_on_create
+
+- name: assert update password from blank - skip for on_create
+ assert:
+ that:
+ - not change_pass_on_create is changed
+
+- name: update password - check
+ user:
+ name: MyUser
+ password: Password123!
+ update_password: when_changed
+ enabled: true
+ register: change_pass_check
+ check_mode: true
+
+- name: get result of update password - check
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - pwdLastSet
+ - userAccountControl
+ register: change_pass_check_actual
+
+- name: assert update password - check
+ assert:
+ that:
+ - change_pass_check is changed
+ - change_pass_check_actual.objects[0].pwdLastSet == 0
+ - '"ADS_UF_ACCOUNTDISABLE" in change_pass_check_actual.objects[0].userAccountControl_AnsibleFlags'
+
+- name: update password - check
+ user:
+ name: MyUser
+ password: Password123!
+ update_password: when_changed
+ enabled: true
+ register: change_pass
+
+- name: get result of update password
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - pwdLastSet
+ - userAccountControl
+ register: change_pass_actual
+
+- name: assert update password
+ assert:
+ that:
+ - change_pass is changed
+ - change_pass_actual.objects[0].pwdLastSet != 0
+ - '"ADS_UF_ACCOUNTDISABLE" not in change_pass_actual.objects[0].userAccountControl_AnsibleFlags'
+
+- name: update password - idempotent
+ user:
+ name: MyUser
+ password: Password123!
+ update_password: when_changed
+ enabled: true
+ register: change_pass_again
+
+- name: assert update password - idempotent
+ assert:
+ that:
+ - not change_pass_again is changed
+
+- name: force update password
+ user:
+ name: MyUser
+ password: Password123!
+ register: always_update_password
+
+- name: get result of force update password
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - pwdLastSet
+ register: always_update_password_actual
+
+- name: assert force update password
+ assert:
+ that:
+ - always_update_password is changed
+ - always_update_password_actual.objects[0].pwdLastSet > change_pass_actual.objects[0].pwdLastSet
+
+- name: remove user - check
+ user:
+ name: MyUser
+ state: absent
+ register: remove_user_check
+ check_mode: true
+
+- name: get result of remove user - check
+ object_info:
+ identity: '{{ object_identity }}'
+ register: remove_user_check_actual
+
+- name: assert remove user - check
+ assert:
+ that:
+ - remove_user_check is changed
+ - remove_user_check_actual.objects | length == 1
+
+- name: remove user
+ user:
+ name: MyUser
+ state: absent
+ register: remove_user
+
+- name: get result of remove user
+ object_info:
+ identity: '{{ object_identity }}'
+ register: remove_user_actual
+
+- name: assert remove user
+ assert:
+ that:
+ - remove_user is changed
+ - remove_user_actual.objects == []
+
+- name: remove user - idempotent
+ user:
+ name: MyUser
+ state: absent
+ register: remove_user_again
+
+- name: assert remove user - idempotent
+ assert:
+ that:
+ - not remove_user_again is changed
+
+# https://github.com/ansible-collections/microsoft.ad/issues/25
+- name: create user with expired password
+ user:
+ name: MyUser
+ password: Password123!
+ password_expired: true
+ state: present
+ register: create_user_pass_expired
+
+- set_fact:
+ object_identity: '{{ create_user_pass_expired.object_guid }}'
+ object_sid: '{{ create_user_pass_expired.sid }}'
+
+- name: get result of create user with expired password
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - pwdLastSet
+ register: create_user_pass_expired_actual
+
+- name: assert create user with expired password
+ assert:
+ that:
+ - create_user_pass_expired_actual.objects[0].pwdLastSet == 0
+
+- name: remove expired password flag on existing user
+ user:
+ name: MyUser
+ password_expired: false
+ state: present
+ register: remove_password_expiry
+
+- name: get result of remove expired password flag on existing user
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - pwdLastSet
+ register: remove_password_expiry_actual
+
+- name: assert remove expired password flag on existing user
+ assert:
+ that:
+ - remove_password_expiry_actual.objects[0].pwdLastSet > 0
+
+- name: remove user
+ user:
+ name: MyUser
+ state: absent
+
+- name: create user with extra info - check
+ user:
+ name: MyUser
+ state: present
+ city: Brisbane
+ company: Red Hat
+ country: au
+ delegates:
+ set:
+ - CN=krbtgt,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ description: User Description
+ display_name: User Name
+ email: user@EMAIL.COM
+ firstname: FirstName
+ groups:
+ set:
+ - Domain Admins
+ - Domain Users
+ password: Password123!
+ password_never_expires: true
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ postal_code: 4000
+ sam_account_name: MyUserSam
+ spn:
+ set:
+ - HTTP/MyUser
+ state_province: QLD
+ street: Main
+ surname: LastName
+ upn: User@{{ domain_realm }}
+ user_cannot_change_password: true
+ attributes:
+ set:
+ comment: My comment
+ register: create_user_check
+ check_mode: true
+
+- name: get result of create user with extra info - check
+ object_info:
+ identity: '{{ create_user_check.distinguished_name }}'
+ register: create_user_actual_check
+
+- name: assert create user with extra info - check
+ assert:
+ that:
+ - create_user_check is changed
+ - create_user_actual_check.objects == []
+
+- name: create user with extra info
+ user:
+ name: MyUser
+ state: present
+ city: Brisbane
+ company: Red Hat
+ country: au
+ delegates:
+ set:
+ - CN=krbtgt,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ description: User Description
+ display_name: User Name
+ email: user@EMAIL.COM
+ firstname: FirstName
+ groups:
+ set:
+ - Domain Admins
+ - Domain Users
+ password: Password123!
+ password_never_expires: true
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ postal_code: 4000
+ sam_account_name: MyUserSam
+ spn:
+ set:
+ - HTTP/MyUser
+ state_province: QLD
+ street: Main
+ surname: LastName
+ upn: User@{{ domain_realm }}
+ user_cannot_change_password: true
+ attributes:
+ set:
+ comment: My comment
+ register: create_user
+
+- set_fact:
+ object_identity: '{{ create_user.object_guid }}'
+ object_sid: '{{ create_user.sid }}'
+
+- name: get result of create user with extra info
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - c
+ - comment
+ - company
+ - Description
+ - displayName
+ - givenName
+ - l
+ - mail
+ - memberOf
+ - msDS-AllowedToActOnBehalfOfOtherIdentity
+ - objectSid
+ - postalcode
+ - primaryGroupID
+ - pwdLastSet
+ - sAMAccountName
+ - servicePrincipalName
+ - sn
+ - st
+ - streetaddress
+ - userAccountControl
+ - userPrincipalName
+ register: create_user_actual
+
+- name: convert delegate SDDL to human readable string
+ ansible.windows.win_powershell:
+ parameters:
+ SDDL: '{{ create_user_actual.objects[0]["msDS-AllowedToActOnBehalfOfOtherIdentity"] }}'
+ script: |
+ param($SDDL)
+
+ $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity
+ $sd.SetSecurityDescriptorSddlForm($SDDL, 'All')
+ $sd.GetAccessRules($true, $false, [Type][System.Security.Principal.NTAccount]
+ ).IdentityReference.Value | ForEach-Object {
+ ($_ -split '\\', 2)[-1].ToLowerInvariant()
+ } | Sort-Object
+ register: create_user_delegates
+
+- name: assert create user with extra info
+ assert:
+ that:
+ - create_user is changed
+ - create_user_actual.objects | length == 1
+ - create_user.distinguished_name == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext
+ - create_user.object_guid == create_user_actual.objects[0].ObjectGUID
+ - create_user.sid == create_user_actual.objects[0].objectSid.Sid
+ - create_user_actual.objects[0].Description == 'User Description'
+ - create_user_actual.objects[0].DisplayName == 'User Name'
+ - create_user_actual.objects[0].DistinguishedName == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext
+ - create_user_actual.objects[0].c == 'au'
+ - create_user_actual.objects[0].comment == 'My comment'
+ - create_user_actual.objects[0].company == 'Red Hat'
+ - create_user_actual.objects[0].givenName == 'FirstName'
+ - create_user_actual.objects[0].l == 'Brisbane'
+ - create_user_actual.objects[0].mail == 'user@EMAIL.COM'
+ # Domain Users is the primaryGroupID entry
+ - create_user_actual.objects[0].memberOf == 'CN=Domain Admins,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - create_user_actual.objects[0].postalcode == '4000'
+ - create_user_actual.objects[0].primaryGroupID == 513 # Domain Users
+ - create_user_actual.objects[0].pwdLastSet > 0
+ - create_user_actual.objects[0].sAMAccountName == 'MyUserSam'
+ - create_user_actual.objects[0].servicePrincipalName == 'HTTP/MyUser'
+ - create_user_actual.objects[0].sn == 'LastName'
+ - create_user_actual.objects[0].st == 'QLD'
+ - create_user_actual.objects[0].streetaddress == 'Main'
+ - create_user_actual.objects[0].userPrincipalName == 'User@' ~ domain_realm
+ - create_user_actual.objects[0].userAccountControl_AnsibleFlags == ["ADS_UF_NORMAL_ACCOUNT", "ADS_UF_DONT_EXPIRE_PASSWD"]
+ - create_user_delegates.output == ["administrator", "krbtgt"]
+
+- name: create user with extra info - idempotent
+ user:
+ name: MyUser
+ state: present
+ city: Brisbane
+ company: Red Hat
+ country: au
+ delegates:
+ set:
+ - CN=krbtgt,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ description: User Description
+ display_name: User Name
+ email: user@EMAIL.COM
+ firstname: FirstName
+ groups:
+ set:
+ - Domain Admins
+ - Domain Users
+ password: Password123!
+ password_never_expires: true
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ postal_code: 4000
+ sam_account_name: MyUserSam
+ spn:
+ set:
+ - HTTP/MyUser
+ state_province: QLD
+ street: Main
+ surname: LastName
+ upn: User@{{ domain_realm }}
+ update_password: when_changed
+ user_cannot_change_password: true
+ attributes:
+ set:
+ comment: My comment
+ register: create_user_again
+
+- name: assert create user with extra info - idempotent
+ assert:
+ that:
+ - not create_user_again is changed
+
+- name: update user settings - check
+ user:
+ name: MyUser
+ state: present
+ city: New York
+ company: Ansible
+ country: us
+ delegates:
+ set:
+ - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ description: User description
+ display_name: User name
+ email: User@EMAIL.COM
+ firstname: firstName
+ groups:
+ set:
+ - Domain Users
+ password: Password123!
+ password_never_expires: false
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ postal_code: 10001
+ sam_account_name: myUserSam
+ spn:
+ set:
+ - HTTP/myUser
+ state_province: NY
+ street: main
+ surname: lastName
+ upn: user@{{ domain_realm }}
+ update_password: when_changed
+ user_cannot_change_password: false
+ attributes:
+ set:
+ comment: My Comment
+ register: update_user_check
+ check_mode: true
+
+- name: get result of update user settings - check
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - c
+ - comment
+ - company
+ - Description
+ - displayName
+ - givenName
+ - l
+ - mail
+ - memberOf
+ - msDS-AllowedToActOnBehalfOfOtherIdentity
+ - objectSid
+ - postalcode
+ - primaryGroupID
+ - pwdLastSet
+ - sAMAccountName
+ - servicePrincipalName
+ - sn
+ - st
+ - streetaddress
+ - userAccountControl
+ - userPrincipalName
+ register: update_user_check_actual
+
+- name: assert update user settings - check
+ assert:
+ that:
+ - update_user_check is changed
+ - update_user_check.distinguished_name == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext
+ - update_user_check.object_guid == create_user_actual.objects[0].ObjectGUID
+ - update_user_check.sid == create_user_actual.objects[0].objectSid.Sid
+ - update_user_check_actual.objects[0].Description == 'User Description'
+ - update_user_check_actual.objects[0].DisplayName == 'User Name'
+ - update_user_check_actual.objects[0].DistinguishedName == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext
+ - update_user_check_actual.objects[0].c == 'au'
+ - update_user_check_actual.objects[0].comment == 'My comment'
+ - update_user_check_actual.objects[0].company == 'Red Hat'
+ - update_user_check_actual.objects[0].givenName == 'FirstName'
+ - update_user_check_actual.objects[0].l == 'Brisbane'
+ - update_user_check_actual.objects[0].mail == 'user@EMAIL.COM'
+ # Domain Users is the primaryGroupID entry
+ - update_user_check_actual.objects[0].memberOf == 'CN=Domain Admins,CN=Users,' ~ setup_domain_info.output[0].defaultNamingContext
+ - update_user_check_actual.objects[0].postalcode == '4000'
+ - update_user_check_actual.objects[0].primaryGroupID == 513 # Domain Users
+ - update_user_check_actual.objects[0].pwdLastSet > 0
+ - update_user_check_actual.objects[0].sAMAccountName == 'MyUserSam'
+ - update_user_check_actual.objects[0].servicePrincipalName == 'HTTP/MyUser'
+ - update_user_check_actual.objects[0].sn == 'LastName'
+ - update_user_check_actual.objects[0].st == 'QLD'
+ - update_user_check_actual.objects[0].streetaddress == 'Main'
+ - update_user_check_actual.objects[0].userPrincipalName == 'User@' ~ domain_realm
+ - update_user_check_actual.objects[0].userAccountControl_AnsibleFlags == ["ADS_UF_NORMAL_ACCOUNT", "ADS_UF_DONT_EXPIRE_PASSWD"]
+ - update_user_check_actual.objects[0]['msDS-AllowedToActOnBehalfOfOtherIdentity'] == create_user_actual.objects[0]['msDS-AllowedToActOnBehalfOfOtherIdentity']
+
+- name: update user settings
+ user:
+ name: MyUser
+ state: present
+ city: New York
+ company: Ansible
+ country: us
+ delegates:
+ set:
+ - CN=KRBTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ description: User description
+ display_name: User name
+ email: User@EMAIL.COM
+ firstname: firstName
+ groups:
+ set:
+ - Domain Users
+ password: Password123!
+ password_never_expires: false
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ postal_code: 10001
+ sam_account_name: myUserSam
+ spn:
+ set:
+ - HTTP/myUser
+ state_province: NY
+ street: main
+ surname: lastName
+ upn: user@{{ domain_realm }}
+ update_password: when_changed
+ user_cannot_change_password: false
+ attributes:
+ set:
+ comment: My Comment
+ register: update_user
+
+- name: get result of update user settings
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - c
+ - comment
+ - company
+ - Description
+ - displayName
+ - givenName
+ - l
+ - mail
+ - memberOf
+ - msDS-AllowedToActOnBehalfOfOtherIdentity
+ - objectSid
+ - postalcode
+ - primaryGroupID
+ - pwdLastSet
+ - sAMAccountName
+ - servicePrincipalName
+ - sn
+ - st
+ - streetaddress
+ - userAccountControl
+ - userPrincipalName
+ register: update_user_actual
+
+- name: convert delegate SDDL to human readable string
+ ansible.windows.win_powershell:
+ parameters:
+ SDDL: '{{ update_user_actual.objects[0]["msDS-AllowedToActOnBehalfOfOtherIdentity"] }}'
+ script: |
+ param($SDDL)
+
+ $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity
+ $sd.SetSecurityDescriptorSddlForm($SDDL, 'All')
+ $sd.GetAccessRules($true, $false, [Type][System.Security.Principal.NTAccount]
+ ).IdentityReference.Value | ForEach-Object {
+ ($_ -split '\\', 2)[-1].ToLowerInvariant()
+ } | Sort-Object
+ register: update_user_delegates
+
+- name: assert update user settings
+ assert:
+ that:
+ - update_user is changed
+ - update_user.distinguished_name == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext
+ - update_user.object_guid == create_user_actual.objects[0].ObjectGUID
+ - update_user.sid == create_user_actual.objects[0].objectSid.Sid
+ - update_user_actual.objects[0].Description == 'User description'
+ - update_user_actual.objects[0].DisplayName == 'User name'
+ - update_user_actual.objects[0].DistinguishedName == 'CN=MyUser,' ~ setup_domain_info.output[0].defaultNamingContext
+ - update_user_actual.objects[0].c == 'us'
+ - update_user_actual.objects[0].comment == 'My Comment'
+ - update_user_actual.objects[0].company == 'Ansible'
+ - update_user_actual.objects[0].givenName == 'firstName'
+ - update_user_actual.objects[0].l == 'New York'
+ - update_user_actual.objects[0].mail == 'User@EMAIL.COM'
+ # Domain Users is the primaryGroupID entry
+ - update_user_actual.objects[0].memberOf == None
+ - update_user_actual.objects[0].postalcode == '10001'
+ - update_user_actual.objects[0].primaryGroupID == 513 # Domain Users
+ - update_user_actual.objects[0].pwdLastSet == create_user_actual.objects[0].pwdLastSet
+ - update_user_actual.objects[0].sAMAccountName == 'myUserSam'
+ - update_user_actual.objects[0].servicePrincipalName == 'HTTP/MyUser'
+ - update_user_actual.objects[0].sn == 'lastName'
+ - update_user_actual.objects[0].st == 'NY'
+ - update_user_actual.objects[0].streetaddress == 'main'
+ - update_user_actual.objects[0].userPrincipalName == 'user@' ~ domain_realm
+ - update_user_actual.objects[0].userAccountControl_AnsibleFlags == ["ADS_UF_NORMAL_ACCOUNT"]
+ - update_user_delegates.output == ["krbtgt"]
+
+- name: update delegates case insensitive
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ delegates:
+ set:
+ - CN=KrbTGT,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ register: update_delegates_insensitive
+
+- name: assert update delegates case insensitive
+ assert:
+ that:
+ - not update_delegates_insensitive is changed
+
+- name: update delegates
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ delegates:
+ set:
+ - CN=Administrator,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ - CN=Enterprise Admins,CN=Users,{{ setup_domain_info.output[0].defaultNamingContext }}
+ register: update_delegates
+
+- name: get result of update delegates
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - msDS-AllowedToActOnBehalfOfOtherIdentity
+ register: update_delegates_actual_raw
+
+- name: convert delegate SDDL to human readable string
+ ansible.windows.win_powershell:
+ parameters:
+ SDDL: '{{ update_delegates_actual_raw.objects[0]["msDS-AllowedToActOnBehalfOfOtherIdentity"] }}'
+ script: |
+ param($SDDL)
+
+ $sd = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity
+ $sd.SetSecurityDescriptorSddlForm($SDDL, 'All')
+ $sd.GetAccessRules($true, $false, [Type][System.Security.Principal.NTAccount]
+ ).IdentityReference.Value | ForEach-Object {
+ ($_ -split '\\', 2)[-1].ToLowerInvariant()
+ } | Sort-Object
+ register: update_delegates_actual
+
+- name: assert update delegates
+ assert:
+ that:
+ - update_delegates is changed
+ - update_delegates_actual.output == ["administrator", "enterprise admins"]
+
+- name: unset string option
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ firstname: ''
+ register: unset_option
+
+- name: get result of unset string option
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - givenName
+ register: unset_option_actual
+
+- name: assert unset string option
+ assert:
+ that:
+ - unset_option is changed
+ - unset_option_actual.objects[0].givenName == None
+
+- name: set groups - check
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ groups:
+ set:
+ - Domain Admins
+ register: set_groups_check
+ check_mode: true
+
+- name: get result of set groups - check
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - memberOf
+ - primaryGroupID
+ register: set_groups_check_actual
+
+- name: assert set groups - check
+ assert:
+ that:
+ - set_groups_check is changed
+ - set_groups_check_actual.objects[0].memberOf == None
+ - set_groups_check_actual.objects[0].primaryGroupID == 513 # Domain Users
+
+- name: set groups
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ groups:
+ set:
+ - Domain Admins
+ register: set_groups
+
+- name: get result of set groups
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - memberOf
+ - primaryGroupID
+ register: set_groups_actual
+
+- name: assert set groups - check
+ assert:
+ that:
+ - set_groups is changed
+ - set_groups.warnings | length == 1
+ - '"the primary group of the user, skipping" in set_groups.warnings[0]'
+ - set_groups_actual.objects[0].memberOf == "CN=Domain Admins,CN=Users," ~ setup_domain_info.output[0].defaultNamingContext
+ - set_groups_actual.objects[0].primaryGroupID == 513 # Domain Users
+
+- name: fail to add group that is missing
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ groups:
+ add:
+ - Invalid
+ register: fail_missing_group
+ failed_when:
+ - '"Failed to locate group Invalid: Cannot find an object with identity" not in fail_missing_group.msg'
+
+- name: warn on group that is missing
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ groups:
+ add:
+ - Invalid
+ missing_behaviour: warn
+ register: warn_missing_group
+
+- name: assert warn on group that is missing
+ assert:
+ that:
+ - not warn_missing_group is changed
+ - warn_missing_group.warnings | length == 1
+ - '"Failed to locate group Invalid but continuing on" in warn_missing_group.warnings[0]'
+
+- name: ignore on group that is missing
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ groups:
+ add:
+ - Invalid
+ missing_behaviour: ignore
+ register: ignore_missing_group
+
+- name: assert ignore on group that is missing
+ assert:
+ that:
+ - not ignore_missing_group is changed
+ - ignore_missing_group.warnings | default([]) | length == 0
+
+- name: remove group
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ groups:
+ remove:
+ - domain admins
+ - Enterprise Admins
+ register: groups_remove
+
+- name: get result of remove groups
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - memberOf
+ - primaryGroupID
+ register: groups_remove_actual
+
+- name: assert remove groups
+ assert:
+ that:
+ - groups_remove is changed
+ - groups_remove_actual.objects[0].memberOf == None
+ - groups_remove_actual.objects[0].primaryGroupID == 513 # Domain Users
+
+- name: add group
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ groups:
+ add:
+ - domain users
+ - domain admins
+ register: groups_add
+
+- name: get result of add groups
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - memberOf
+ - primaryGroupID
+ register: groups_add_actual
+
+- name: assert add groups
+ assert:
+ that:
+ - groups_add is changed
+ - groups_add_actual.objects[0].memberOf == "CN=Domain Admins,CN=Users," ~ setup_domain_info.output[0].defaultNamingContext
+ - groups_add_actual.objects[0].primaryGroupID == 513 # Domain Users
+
+- name: set spns
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ spn:
+ set:
+ - HTTP/host
+ - HTTP/host.domain
+ - HTTP/host.domain:8080
+ register: spn_set
+
+- name: get result of set spns
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - servicePrincipalName
+ register: spn_set_actual
+
+- name: assert set spns
+ assert:
+ that:
+ - spn_set is changed
+ - spn_set_actual.objects[0].servicePrincipalName == ['HTTP/host.domain:8080', 'HTTP/host.domain', 'HTTP/host']
+
+- name: remove spns
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ spn:
+ remove:
+ - HTTP/fake
+ - HTTP/Host.domain
+ register: spn_remove
+
+- name: get result of remove spns
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - servicePrincipalName
+ register: spn_remove_actual
+
+- name: assert remove spns
+ assert:
+ that:
+ - spn_remove is changed
+ - spn_remove_actual.objects[0].servicePrincipalName == ['HTTP/host.domain:8080', 'HTTP/host']
+
+- name: add spns
+ user:
+ name: MyUser
+ path: '{{ setup_domain_info.output[0].defaultNamingContext }}'
+ spn:
+ add:
+ - HTTP/Host.domain:8080
+ - HTTP/fake
+ register: spn_add
+
+- name: get result of add spns
+ object_info:
+ identity: '{{ object_identity }}'
+ properties:
+ - servicePrincipalName
+ register: spn_add_actual
+
+- name: assert add spns
+ assert:
+ that:
+ - spn_add is changed
+ - spn_add_actual.objects[0].servicePrincipalName == ['HTTP/fake', 'HTTP/host.domain:8080', 'HTTP/host']
diff --git a/ansible_collections/microsoft/ad/tests/integration/windows-integration.controller.requirements.txt b/ansible_collections/microsoft/ad/tests/integration/windows-integration.controller.requirements.txt
new file mode 100644
index 000000000..1a3ddde20
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/integration/windows-integration.controller.requirements.txt
@@ -0,0 +1,3 @@
+# Needed for microsoft.ad.ldap
+pyspnego >= 0.8.0
+sansldap \ No newline at end of file
diff --git a/ansible_collections/microsoft/ad/tests/requirements.yml b/ansible_collections/microsoft/ad/tests/requirements.yml
new file mode 100644
index 000000000..f5ed6c435
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/requirements.yml
@@ -0,0 +1,2 @@
+collections:
+- name: ansible.windows
diff --git a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.12.txt b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.12.txt
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.12.txt
diff --git a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.13.txt b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.13.txt
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.13.txt
diff --git a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.14.txt b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.14.txt
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.14.txt
diff --git a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.15.txt b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.15.txt
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.15.txt
diff --git a/ansible_collections/microsoft/ad/tests/sanity/ignore-2.16.txt b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.16.txt
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/sanity/ignore-2.16.txt
diff --git a/ansible_collections/microsoft/ad/tests/unit/__init__.py b/ansible_collections/microsoft/ad/tests/unit/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/__init__.py
diff --git a/ansible_collections/microsoft/ad/tests/unit/compat/__init__.py b/ansible_collections/microsoft/ad/tests/unit/compat/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/compat/__init__.py
diff --git a/ansible_collections/microsoft/ad/tests/unit/compat/mock.py b/ansible_collections/microsoft/ad/tests/unit/compat/mock.py
new file mode 100644
index 000000000..3dcd2687f
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/compat/mock.py
@@ -0,0 +1,42 @@
+# (c) 2014, Toshio Kuratomi <tkuratomi@ansible.com>
+#
+# This file is part of Ansible
+#
+# Ansible 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.
+#
+# Ansible 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 Ansible. If not, see <http://www.gnu.org/licenses/>.
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+'''
+Compat module for Python3.x's unittest.mock module
+'''
+
+# Python 2.7
+
+# Note: Could use the pypi mock library on python3.x as well as python2.x. It
+# is the same as the python3 stdlib mock library
+
+try:
+ # Allow wildcard import because we really do want to import all of mock's
+ # symbols into this compat shim
+ # pylint: disable=wildcard-import,unused-wildcard-import
+ from unittest.mock import *
+except ImportError:
+ # Python 2
+ # pylint: disable=wildcard-import,unused-wildcard-import
+ try:
+ from mock import *
+ except ImportError:
+ print('You need the mock library installed on python2.x to run tests')
diff --git a/ansible_collections/microsoft/ad/tests/unit/conftest.py b/ansible_collections/microsoft/ad/tests/unit/conftest.py
new file mode 100644
index 000000000..e3f2ec4a0
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/conftest.py
@@ -0,0 +1,43 @@
+"""Enable unit testing of Ansible collections. PYTEST_DONT_REWRITE"""
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import os
+import os.path
+
+from ansible.utils.collection_loader._collection_finder import _AnsibleCollectionFinder
+
+
+ANSIBLE_COLLECTIONS_PATH = os.path.abspath(os.path.join(__file__, '..', '..', '..', '..', '..'))
+
+
+# this monkeypatch to _pytest.pathlib.resolve_package_path fixes PEP420 resolution for collections in pytest >= 6.0.0
+def collection_resolve_package_path(path):
+ """Configure the Python package path so that pytest can find our collections."""
+ for parent in path.parents:
+ if str(parent) == ANSIBLE_COLLECTIONS_PATH:
+ return parent
+
+ raise Exception('File "%s" not found in collection path "%s".' % (path, ANSIBLE_COLLECTIONS_PATH))
+
+
+def pytest_configure():
+ """Configure this pytest plugin."""
+
+ try:
+ if pytest_configure.executed:
+ return
+ except AttributeError:
+ pytest_configure.executed = True
+
+ # allow unit tests to import code from collections
+
+ # noinspection PyProtectedMember
+ _AnsibleCollectionFinder(paths=[os.path.dirname(ANSIBLE_COLLECTIONS_PATH)])._install() # pylint: disable=protected-access
+
+ # noinspection PyProtectedMember
+ from _pytest import pathlib as pytest_pathlib
+ pytest_pathlib.resolve_package_path = collection_resolve_package_path
+
+
+pytest_configure()
diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/__init__.py b/ansible_collections/microsoft/ad/tests/unit/mock/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/mock/__init__.py
diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/loader.py b/ansible_collections/microsoft/ad/tests/unit/mock/loader.py
new file mode 100644
index 000000000..e5dff78c1
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/mock/loader.py
@@ -0,0 +1,116 @@
+# (c) 2012-2014, Michael DeHaan <michael.dehaan@gmail.com>
+#
+# This file is part of Ansible
+#
+# Ansible 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.
+#
+# Ansible 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 Ansible. If not, see <http://www.gnu.org/licenses/>.
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import os
+
+from ansible.errors import AnsibleParserError
+from ansible.parsing.dataloader import DataLoader
+from ansible.module_utils._text import to_bytes, to_text
+
+
+class DictDataLoader(DataLoader):
+
+ def __init__(self, file_mapping=None):
+ file_mapping = {} if file_mapping is None else file_mapping
+ assert type(file_mapping) == dict
+
+ super(DictDataLoader, self).__init__()
+
+ self._file_mapping = file_mapping
+ self._build_known_directories()
+ self._vault_secrets = None
+
+ def load_from_file(self, path, cache=True, unsafe=False):
+ path = to_text(path)
+ if path in self._file_mapping:
+ return self.load(self._file_mapping[path], path)
+ return None
+
+ # TODO: the real _get_file_contents returns a bytestring, so we actually convert the
+ # unicode/text it's created with to utf-8
+ def _get_file_contents(self, file_name):
+ path = to_text(file_name)
+ if path in self._file_mapping:
+ return (to_bytes(self._file_mapping[path]), False)
+ else:
+ raise AnsibleParserError("file not found: %s" % path)
+
+ def path_exists(self, path):
+ path = to_text(path)
+ return path in self._file_mapping or path in self._known_directories
+
+ def is_file(self, path):
+ path = to_text(path)
+ return path in self._file_mapping
+
+ def is_directory(self, path):
+ path = to_text(path)
+ return path in self._known_directories
+
+ def list_directory(self, path):
+ ret = []
+ path = to_text(path)
+ for x in (list(self._file_mapping.keys()) + self._known_directories):
+ if x.startswith(path):
+ if os.path.dirname(x) == path:
+ ret.append(os.path.basename(x))
+ return ret
+
+ def is_executable(self, path):
+ # FIXME: figure out a way to make paths return true for this
+ return False
+
+ def _add_known_directory(self, directory):
+ if directory not in self._known_directories:
+ self._known_directories.append(directory)
+
+ def _build_known_directories(self):
+ self._known_directories = []
+ for path in self._file_mapping:
+ dirname = os.path.dirname(path)
+ while dirname not in ('/', ''):
+ self._add_known_directory(dirname)
+ dirname = os.path.dirname(dirname)
+
+ def push(self, path, content):
+ rebuild_dirs = False
+ if path not in self._file_mapping:
+ rebuild_dirs = True
+
+ self._file_mapping[path] = content
+
+ if rebuild_dirs:
+ self._build_known_directories()
+
+ def pop(self, path):
+ if path in self._file_mapping:
+ del self._file_mapping[path]
+ self._build_known_directories()
+
+ def clear(self):
+ self._file_mapping = dict()
+ self._known_directories = []
+
+ def get_basedir(self):
+ return os.getcwd()
+
+ def set_vault_secrets(self, vault_secrets):
+ self._vault_secrets = vault_secrets
diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/path.py b/ansible_collections/microsoft/ad/tests/unit/mock/path.py
new file mode 100644
index 000000000..4f46ed913
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/mock/path.py
@@ -0,0 +1,8 @@
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible_collections.microsoft.ad.tests.unit.compat.mock import MagicMock
+from ansible.utils.path import unfrackpath
+
+
+mock_unfrackpath_noop = MagicMock(spec_set=unfrackpath, side_effect=lambda x, *args, **kwargs: x)
diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/procenv.py b/ansible_collections/microsoft/ad/tests/unit/mock/procenv.py
new file mode 100644
index 000000000..8652d2689
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/mock/procenv.py
@@ -0,0 +1,90 @@
+# (c) 2016, Matt Davis <mdavis@ansible.com>
+# (c) 2016, Toshio Kuratomi <tkuratomi@ansible.com>
+#
+# This file is part of Ansible
+#
+# Ansible 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.
+#
+# Ansible 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 Ansible. If not, see <http://www.gnu.org/licenses/>.
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import sys
+import json
+
+from contextlib import contextmanager
+from io import BytesIO, StringIO
+from ansible_collections.microsoft.ad.tests.unit.compat import unittest
+from ansible.module_utils.six import PY3
+from ansible.module_utils._text import to_bytes
+
+
+@contextmanager
+def swap_stdin_and_argv(stdin_data='', argv_data=tuple()):
+ """
+ context manager that temporarily masks the test runner's values for stdin and argv
+ """
+ real_stdin = sys.stdin
+ real_argv = sys.argv
+
+ if PY3:
+ fake_stream = StringIO(stdin_data)
+ fake_stream.buffer = BytesIO(to_bytes(stdin_data))
+ else:
+ fake_stream = BytesIO(to_bytes(stdin_data))
+
+ try:
+ sys.stdin = fake_stream
+ sys.argv = argv_data
+
+ yield
+ finally:
+ sys.stdin = real_stdin
+ sys.argv = real_argv
+
+
+@contextmanager
+def swap_stdout():
+ """
+ context manager that temporarily replaces stdout for tests that need to verify output
+ """
+ old_stdout = sys.stdout
+
+ if PY3:
+ fake_stream = StringIO()
+ else:
+ fake_stream = BytesIO()
+
+ try:
+ sys.stdout = fake_stream
+
+ yield fake_stream
+ finally:
+ sys.stdout = old_stdout
+
+
+class ModuleTestCase(unittest.TestCase):
+ def setUp(self, module_args=None):
+ if module_args is None:
+ module_args = {'_ansible_remote_tmp': '/tmp', '_ansible_keep_remote_files': False}
+
+ args = json.dumps(dict(ANSIBLE_MODULE_ARGS=module_args))
+
+ # unittest doesn't have a clean place to use a context manager, so we have to enter/exit manually
+ self.stdin_swap = swap_stdin_and_argv(stdin_data=args)
+ self.stdin_swap.__enter__()
+
+ def tearDown(self):
+ # unittest doesn't have a clean place to use a context manager, so we have to enter/exit manually
+ self.stdin_swap.__exit__(None, None, None)
diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/vault_helper.py b/ansible_collections/microsoft/ad/tests/unit/mock/vault_helper.py
new file mode 100644
index 000000000..dcce9c784
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/mock/vault_helper.py
@@ -0,0 +1,39 @@
+# Ansible 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.
+#
+# Ansible 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 Ansible. If not, see <http://www.gnu.org/licenses/>.
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible.module_utils._text import to_bytes
+
+from ansible.parsing.vault import VaultSecret
+
+
+class TextVaultSecret(VaultSecret):
+ '''A secret piece of text. ie, a password. Tracks text encoding.
+
+ The text encoding of the text may not be the default text encoding so
+ we keep track of the encoding so we encode it to the same bytes.'''
+
+ def __init__(self, text, encoding=None, errors=None, _bytes=None):
+ super(TextVaultSecret, self).__init__()
+ self.text = text
+ self.encoding = encoding or 'utf-8'
+ self._bytes = _bytes
+ self.errors = errors or 'strict'
+
+ @property
+ def bytes(self):
+ '''The text encoded with encoding, unless we specifically set _bytes.'''
+ return self._bytes or to_bytes(self.text, encoding=self.encoding, errors=self.errors)
diff --git a/ansible_collections/microsoft/ad/tests/unit/mock/yaml_helper.py b/ansible_collections/microsoft/ad/tests/unit/mock/yaml_helper.py
new file mode 100644
index 000000000..1ef172159
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/mock/yaml_helper.py
@@ -0,0 +1,124 @@
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import io
+import yaml
+
+from ansible.module_utils.six import PY3
+from ansible.parsing.yaml.loader import AnsibleLoader
+from ansible.parsing.yaml.dumper import AnsibleDumper
+
+
+class YamlTestUtils(object):
+ """Mixin class to combine with a unittest.TestCase subclass."""
+ def _loader(self, stream):
+ """Vault related tests will want to override this.
+
+ Vault cases should setup a AnsibleLoader that has the vault password."""
+ return AnsibleLoader(stream)
+
+ def _dump_stream(self, obj, stream, dumper=None):
+ """Dump to a py2-unicode or py3-string stream."""
+ if PY3:
+ return yaml.dump(obj, stream, Dumper=dumper)
+ else:
+ return yaml.dump(obj, stream, Dumper=dumper, encoding=None)
+
+ def _dump_string(self, obj, dumper=None):
+ """Dump to a py2-unicode or py3-string"""
+ if PY3:
+ return yaml.dump(obj, Dumper=dumper)
+ else:
+ return yaml.dump(obj, Dumper=dumper, encoding=None)
+
+ def _dump_load_cycle(self, obj):
+ # Each pass though a dump or load revs the 'generation'
+ # obj to yaml string
+ string_from_object_dump = self._dump_string(obj, dumper=AnsibleDumper)
+
+ # wrap a stream/file like StringIO around that yaml
+ stream_from_object_dump = io.StringIO(string_from_object_dump)
+ loader = self._loader(stream_from_object_dump)
+ # load the yaml stream to create a new instance of the object (gen 2)
+ obj_2 = loader.get_data()
+
+ # dump the gen 2 objects directory to strings
+ string_from_object_dump_2 = self._dump_string(obj_2,
+ dumper=AnsibleDumper)
+
+ # The gen 1 and gen 2 yaml strings
+ self.assertEqual(string_from_object_dump, string_from_object_dump_2)
+ # the gen 1 (orig) and gen 2 py object
+ self.assertEqual(obj, obj_2)
+
+ # again! gen 3... load strings into py objects
+ stream_3 = io.StringIO(string_from_object_dump_2)
+ loader_3 = self._loader(stream_3)
+ obj_3 = loader_3.get_data()
+
+ string_from_object_dump_3 = self._dump_string(obj_3, dumper=AnsibleDumper)
+
+ self.assertEqual(obj, obj_3)
+ # should be transitive, but...
+ self.assertEqual(obj_2, obj_3)
+ self.assertEqual(string_from_object_dump, string_from_object_dump_3)
+
+ def _old_dump_load_cycle(self, obj):
+ '''Dump the passed in object to yaml, load it back up, dump again, compare.'''
+ stream = io.StringIO()
+
+ yaml_string = self._dump_string(obj, dumper=AnsibleDumper)
+ self._dump_stream(obj, stream, dumper=AnsibleDumper)
+
+ yaml_string_from_stream = stream.getvalue()
+
+ # reset stream
+ stream.seek(0)
+
+ loader = self._loader(stream)
+ # loader = AnsibleLoader(stream, vault_password=self.vault_password)
+ obj_from_stream = loader.get_data()
+
+ stream_from_string = io.StringIO(yaml_string)
+ loader2 = self._loader(stream_from_string)
+ # loader2 = AnsibleLoader(stream_from_string, vault_password=self.vault_password)
+ obj_from_string = loader2.get_data()
+
+ stream_obj_from_stream = io.StringIO()
+ stream_obj_from_string = io.StringIO()
+
+ if PY3:
+ yaml.dump(obj_from_stream, stream_obj_from_stream, Dumper=AnsibleDumper)
+ yaml.dump(obj_from_stream, stream_obj_from_string, Dumper=AnsibleDumper)
+ else:
+ yaml.dump(obj_from_stream, stream_obj_from_stream, Dumper=AnsibleDumper, encoding=None)
+ yaml.dump(obj_from_stream, stream_obj_from_string, Dumper=AnsibleDumper, encoding=None)
+
+ yaml_string_stream_obj_from_stream = stream_obj_from_stream.getvalue()
+ yaml_string_stream_obj_from_string = stream_obj_from_string.getvalue()
+
+ stream_obj_from_stream.seek(0)
+ stream_obj_from_string.seek(0)
+
+ if PY3:
+ yaml_string_obj_from_stream = yaml.dump(obj_from_stream, Dumper=AnsibleDumper)
+ yaml_string_obj_from_string = yaml.dump(obj_from_string, Dumper=AnsibleDumper)
+ else:
+ yaml_string_obj_from_stream = yaml.dump(obj_from_stream, Dumper=AnsibleDumper, encoding=None)
+ yaml_string_obj_from_string = yaml.dump(obj_from_string, Dumper=AnsibleDumper, encoding=None)
+
+ assert yaml_string == yaml_string_obj_from_stream
+ assert yaml_string == yaml_string_obj_from_stream == yaml_string_obj_from_string
+ assert (yaml_string == yaml_string_obj_from_stream == yaml_string_obj_from_string == yaml_string_stream_obj_from_stream ==
+ yaml_string_stream_obj_from_string)
+ assert obj == obj_from_stream
+ assert obj == obj_from_string
+ assert obj == yaml_string_obj_from_stream
+ assert obj == yaml_string_obj_from_string
+ assert obj == obj_from_stream == obj_from_string == yaml_string_obj_from_stream == yaml_string_obj_from_string
+ return {'obj': obj,
+ 'yaml_string': yaml_string,
+ 'yaml_string_from_stream': yaml_string_from_stream,
+ 'obj_from_stream': obj_from_stream,
+ 'obj_from_string': obj_from_string,
+ 'yaml_string_obj_from_string': yaml_string_obj_from_string}
diff --git a/ansible_collections/microsoft/ad/tests/unit/modules/__init__.py b/ansible_collections/microsoft/ad/tests/unit/modules/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/modules/__init__.py
diff --git a/ansible_collections/microsoft/ad/tests/unit/modules/utils.py b/ansible_collections/microsoft/ad/tests/unit/modules/utils.py
new file mode 100644
index 000000000..8c9633ea9
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/modules/utils.py
@@ -0,0 +1,50 @@
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import json
+
+from ansible_collections.microsoft.ad.tests.unit.compat import unittest
+from ansible_collections.microsoft.ad.tests.unit.compat.mock import patch
+from ansible.module_utils import basic
+from ansible.module_utils._text import to_bytes
+
+
+def set_module_args(args):
+ if '_ansible_remote_tmp' not in args:
+ args['_ansible_remote_tmp'] = '/tmp'
+ if '_ansible_keep_remote_files' not in args:
+ args['_ansible_keep_remote_files'] = False
+
+ args = json.dumps({'ANSIBLE_MODULE_ARGS': args})
+ basic._ANSIBLE_ARGS = to_bytes(args)
+
+
+class AnsibleExitJson(Exception):
+ pass
+
+
+class AnsibleFailJson(Exception):
+ pass
+
+
+def exit_json(*args, **kwargs):
+ if 'changed' not in kwargs:
+ kwargs['changed'] = False
+ raise AnsibleExitJson(kwargs)
+
+
+def fail_json(*args, **kwargs):
+ kwargs['failed'] = True
+ raise AnsibleFailJson(kwargs)
+
+
+class ModuleTestCase(unittest.TestCase):
+
+ def setUp(self):
+ self.mock_module = patch.multiple(basic.AnsibleModule, exit_json=exit_json, fail_json=fail_json)
+ self.mock_module.start()
+ self.mock_sleep = patch('time.sleep')
+ self.mock_sleep.start()
+ set_module_args({})
+ self.addCleanup(self.mock_module.stop)
+ self.addCleanup(self.mock_sleep.stop)
diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/__init__.py b/ansible_collections/microsoft/ad/tests/unit/plugins/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/plugins/__init__.py
diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/filter/__init__.py b/ansible_collections/microsoft/ad/tests/unit/plugins/filter/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/plugins/filter/__init__.py
diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/filter/test_ldap_converters.py b/ansible_collections/microsoft/ad/tests/unit/plugins/filter/test_ldap_converters.py
new file mode 100644
index 000000000..923d30b31
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/plugins/filter/test_ldap_converters.py
@@ -0,0 +1,92 @@
+# Copyright (c) 2023 Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+import base64
+import typing as t
+import uuid
+
+import pytest
+
+from ansible.errors import AnsibleFilterError
+from ansible_collections.microsoft.ad.plugins.filter.ldap_converters import as_sid, as_guid, as_datetime
+
+
+@pytest.mark.parametrize("type", ["int", "str", "bytes"])
+def test_as_datetime(type: str) -> None:
+ # Last digit is too precise for datetime so will be ignored.
+ filetime = 133220025750000011
+
+ value: t.Union[int, str, bytes]
+ if type == "int":
+ value = filetime
+ elif type == "str":
+ value = str(filetime)
+ else:
+ value = str(filetime).encode()
+
+ actual = as_datetime(value)
+ assert actual == "2023-02-27T20:16:15.000001+0000"
+
+
+def test_as_datetime_with_format() -> None:
+ filetime = 133220025750000000
+
+ actual = as_datetime(filetime, format="%Y")
+ assert actual == "2023"
+
+
+def test_as_datetime_from_list() -> None:
+ actual = as_datetime([133220025750000000, 133220025751000020])
+ assert actual == ["2023-02-27T20:16:15.000000+0000", "2023-02-27T20:16:15.100002+0000"]
+
+
+@pytest.mark.parametrize("type", ["str", "bytes"])
+def test_as_guid(type: str) -> None:
+ input_uuid = uuid.uuid4()
+
+ value: t.Union[str, bytes]
+ if type == "str":
+ value = base64.b64encode(input_uuid.bytes_le).decode()
+ else:
+ value = input_uuid.bytes_le
+
+ actual = as_guid(value)
+ assert actual == str(input_uuid)
+
+
+def test_as_guid_from_list() -> None:
+ input_uuids = [uuid.uuid4(), uuid.uuid4()]
+
+ actual = as_guid([v.bytes_le for v in input_uuids])
+ assert actual == [str(input_uuids[0]), str(input_uuids[1])]
+
+
+@pytest.mark.parametrize("type", ["str", "bytes"])
+def test_as_sid(type: str) -> None:
+ raw_sid = "AQUAAAAAAAUVAAAAMS9koSf9FmVJIPcjUAQAAA=="
+
+ value: t.Union[str, bytes]
+ if type == "str":
+ value = raw_sid
+ else:
+ value = base64.b64decode(raw_sid)
+
+ actual = as_sid(value)
+ assert actual == "S-1-5-21-2707697457-1696005415-603398217-1104"
+
+
+def test_as_sid_from_list() -> None:
+ input_sids = ["AQUAAAAAAAUVAAAAHZN390Q1esyM03upUAQAAA==", "AQEAAAAAAAUTAAAA"]
+
+ actual = as_sid(input_sids)
+ assert actual == ["S-1-5-21-4151808797-3430561092-2843464588-1104", "S-1-5-19"]
+
+
+def test_as_sid_too_little_data_auth_count() -> None:
+ with pytest.raises(AnsibleFilterError, match="Raw SID bytes must be at least 8 bytes long"):
+ as_sid(b"\x00\x00\x00\x00")
+
+
+def test_as_sid_too_little_data_sub_authorities() -> None:
+ with pytest.raises(AnsibleFilterError, match="Not enough data to unpack SID"):
+ as_sid(b"\x01\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")
diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/__init__.py b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/__init__.py
diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/__init__.py b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/__init__.py
diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_certificate.py b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_certificate.py
new file mode 100644
index 000000000..b5beb1e6a
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_certificate.py
@@ -0,0 +1,638 @@
+# Copyright (c) 2023 Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+import datetime
+import pathlib
+import ssl
+import subprocess
+import typing as t
+
+import pytest
+
+from ansible_collections.microsoft.ad.plugins.plugin_utils._ldap._certificate import (
+ get_tls_server_end_point_data,
+ load_client_certificate,
+ load_trust_certificate,
+)
+
+try:
+ from cryptography import x509
+ from cryptography.exceptions import UnsupportedAlgorithm
+ from cryptography.hazmat.primitives import hashes, serialization
+ from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates
+ from cryptography.hazmat.primitives.asymmetric import rsa
+ from cryptography.hazmat.primitives.asymmetric import ed25519
+
+except Exception:
+ pytest.skip("Cannot run certificate tests without cryptography")
+
+
+class TlsServer(t.NamedTuple):
+ ca: "x509.Certificate"
+ ca_key: "rsa.RSAPrivateKey"
+ name: str
+ context: ssl.SSLContext
+
+
+@pytest.fixture(scope="module")
+def tls_server(tmp_path_factory: pytest.TempPathFactory) -> TlsServer:
+ cn = "microsoft.ad.test"
+ now = datetime.datetime.utcnow()
+
+ ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ ca_name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "microsoft.ad")])
+ ca_cert = (
+ x509.CertificateBuilder()
+ .subject_name(ca_name)
+ .issuer_name(ca_name)
+ .public_key(ca_key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(now)
+ .not_valid_after(now + datetime.timedelta(days=365))
+ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
+ .sign(ca_key, hashes.SHA256())
+ )
+
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+
+ name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, cn)])
+
+ now = datetime.datetime.utcnow()
+ cert = (
+ x509.CertificateBuilder()
+ .subject_name(name)
+ .issuer_name(ca_name)
+ .public_key(key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(now)
+ .not_valid_after(now + datetime.timedelta(days=365))
+ .sign(ca_key, hashes.SHA256())
+ )
+ cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
+ key_pem = key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=serialization.NoEncryption(),
+ )
+
+ tmpdir = tmp_path_factory.mktemp("cert")
+ cert_path = tmpdir / "microsoft.ad.test.pem"
+ try:
+ with open(cert_path, mode="wb") as fd:
+ fd.write(cert_pem)
+ fd.write(key_pem)
+
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ context.verify_mode = ssl.VerifyMode.CERT_OPTIONAL
+ context.load_cert_chain(str(cert_path))
+ context.load_verify_locations(cadata=ca_cert.public_bytes(serialization.Encoding.PEM).decode())
+
+ finally:
+ cert_path.unlink(missing_ok=True)
+
+ return TlsServer(ca_cert, ca_key, cn, context)
+
+
+@pytest.fixture(scope="module")
+def client_certificate(tls_server: TlsServer) -> t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"]:
+ now = datetime.datetime.utcnow()
+
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "client-auth")])
+ cert = (
+ x509.CertificateBuilder()
+ .subject_name(name)
+ .issuer_name(tls_server.ca.subject)
+ .public_key(key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(now)
+ .not_valid_after(now + datetime.timedelta(days=365))
+ .add_extension(
+ x509.SubjectAlternativeName(
+ [
+ x509.OtherName(
+ x509.ObjectIdentifier("1.3.6.1.4.1.311.20.2.3"),
+ b"\x0c\x0d\x62\x6f\x62\x40\x6c\x6f\x63\x61\x6c\x68\x6f\x73\x74",
+ ),
+ ]
+ ),
+ False,
+ )
+ .add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH]), False)
+ .sign(tls_server.ca_key, hashes.SHA256())
+ )
+
+ return cert, key
+
+
+def test_get_tls_binding_data_no_data() -> None:
+ assert get_tls_server_end_point_data(None) is None
+
+
+@pytest.mark.parametrize("algorithm", ["md5", "sha1", "sha256", "sha384", "sha512"])
+def test_get_tls_binding_data_rsa(
+ algorithm: str,
+) -> None:
+ cert_algo, hash_algo = {
+ "md5": (hashes.MD5(), hashes.SHA256()),
+ "sha1": (hashes.SHA1(), hashes.SHA256()),
+ "sha256": (hashes.SHA256(), hashes.SHA256()),
+ "sha384": (hashes.SHA384(), hashes.SHA384()),
+ "sha512": (hashes.SHA512(), hashes.SHA512()),
+ }[algorithm]
+
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "test")])
+ now = datetime.datetime.utcnow()
+ try:
+ cert = (
+ x509.CertificateBuilder()
+ .subject_name(name)
+ .issuer_name(name)
+ .public_key(key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(now)
+ .not_valid_after(now + datetime.timedelta(days=365))
+ .add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), False)
+ .sign(key, cert_algo)
+ ).public_bytes(encoding=serialization.Encoding.DER)
+ except (UnsupportedAlgorithm, ValueError) as e:
+ pytest.skip(f"Hash algorithm is unavailable: {e}")
+
+ digest = hashes.Hash(hash_algo)
+ digest.update(cert)
+ expected = b"tls-server-end-point:" + digest.finalize()
+
+ actual = get_tls_server_end_point_data(cert)
+ assert actual == expected
+
+
+def test_get_tls_binding_data_ed25519() -> None:
+ key = ed25519.Ed25519PrivateKey.generate()
+ name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "test")])
+ now = datetime.datetime.utcnow()
+ cert = (
+ x509.CertificateBuilder()
+ .subject_name(name)
+ .issuer_name(name)
+ .public_key(key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(now)
+ .not_valid_after(now + datetime.timedelta(days=365))
+ .add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), False)
+ .sign(key, None)
+ ).public_bytes(encoding=serialization.Encoding.DER)
+
+ digest = hashes.Hash(hashes.SHA256())
+ digest.update(cert)
+ expected = b"tls-server-end-point:" + digest.finalize()
+
+ actual = get_tls_server_end_point_data(cert)
+ assert actual == expected
+
+
+@pytest.mark.parametrize("format", ["pem", "der"])
+def test_trust_cert_file(format: str, tls_server: TlsServer, tmp_path: pathlib.Path) -> None:
+ context = ssl.create_default_context()
+
+ if format == "pem":
+ encoding = serialization.Encoding.PEM
+ else:
+ encoding = serialization.Encoding.DER
+
+ cert_file = tmp_path / "ca.pem"
+ cert_file.write_bytes(tls_server.ca.public_bytes(encoding))
+
+ load_trust_certificate(
+ context,
+ str(cert_file),
+ )
+ cert_file.unlink(missing_ok=True)
+
+ perform_handshake(context, tls_server)
+
+
+def test_trust_cert_dir(tls_server: TlsServer, tmp_path: pathlib.Path) -> None:
+ context = ssl.create_default_context()
+
+ cert_dir = tmp_path / "ca"
+ cert_dir.mkdir()
+
+ cert_file = cert_dir / "ca.pem"
+ cert_file.write_bytes(tls_server.ca.public_bytes(serialization.Encoding.PEM))
+
+ # The c_rehash mechanism is not public and has changed in the past. Use
+ # OpenSSL to get the expected hash of the cert for this test.
+ cert_hash = (
+ subprocess.check_output(
+ ["openssl", "x509", "-hash", "-noout", "-in", str(cert_file)],
+ )
+ .decode()
+ .strip()
+ )
+ cert_file = cert_file.rename(cert_dir / f"{cert_hash}.0")
+
+ load_trust_certificate(context, str(cert_dir))
+
+ perform_handshake(context, tls_server)
+
+
+def test_trust_cert_str(tls_server: TlsServer) -> None:
+ context = ssl.create_default_context()
+ load_trust_certificate(
+ context,
+ tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(),
+ )
+
+ perform_handshake(context, tls_server)
+
+
+@pytest.mark.parametrize(
+ "cert_first, password",
+ [
+ (True, None),
+ (False, None),
+ (True, b"Password123!\xFF"),
+ (False, b"Password123!\xFF"),
+ ],
+)
+def test_client_auth_path_pem_file_combined(
+ cert_first: bool,
+ password: t.Optional[bytes],
+ tls_server: TlsServer,
+ client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"],
+ tmp_path: pathlib.Path,
+) -> None:
+ context = ssl.create_default_context()
+ load_trust_certificate(
+ context,
+ tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(),
+ )
+
+ enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
+ cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM)
+ key = client_certificate[1].private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=enc_algo,
+ )
+
+ cert_file = tmp_path / "client.pem"
+
+ if cert_first:
+ cert_file.write_bytes(cert + b"\n" + key)
+ else:
+ cert_file.write_bytes(key + b"\n" + cert)
+
+ load_client_certificate(
+ context,
+ str(cert_file),
+ password=password.decode("utf-8", errors="surrogateescape") if password else None,
+ )
+
+ cert_file.unlink()
+
+ perform_handshake(context, tls_server, client_certificate[0])
+
+
+@pytest.mark.parametrize("password", [None, b"Password123!\xFF"])
+def test_client_auth_path_pem_file_separate_key(
+ password: t.Optional[bytes],
+ tls_server: TlsServer,
+ client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"],
+ tmp_path: pathlib.Path,
+) -> None:
+ context = ssl.create_default_context()
+ load_trust_certificate(
+ context,
+ tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(),
+ )
+
+ enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
+ cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM)
+ key = client_certificate[1].private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=enc_algo,
+ )
+
+ cert_file = tmp_path / "cert.pem"
+ cert_file.write_bytes(cert)
+
+ key_file = tmp_path / "key.pem"
+ key_file.write_bytes(key)
+
+ load_client_certificate(
+ context,
+ str(cert_file),
+ key=str(key_file),
+ password=password.decode("utf-8", errors="surrogateescape") if password else None,
+ )
+
+ cert_file.unlink()
+ key_file.unlink()
+
+ perform_handshake(context, tls_server, client_certificate[0])
+
+
+@pytest.mark.parametrize("password", [None, b"Password123!\xFF"])
+def test_client_auth_pem_str_combined(
+ password: t.Optional[bytes],
+ tls_server: TlsServer,
+ client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"],
+) -> None:
+ context = ssl.create_default_context()
+ load_trust_certificate(
+ context,
+ tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(),
+ )
+
+ enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
+ cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM).decode()
+ key = (
+ client_certificate[1]
+ .private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=enc_algo,
+ )
+ .decode()
+ )
+
+ cert_data = cert + "\n" + key
+
+ load_client_certificate(
+ context,
+ cert_data,
+ password=password.decode("utf-8", errors="surrogateescape") if password else None,
+ )
+
+ perform_handshake(context, tls_server, client_certificate[0])
+
+
+@pytest.mark.parametrize("password", [None, b"Password123!\xFF"])
+def test_client_auth_pem_str_separate_key(
+ password: t.Optional[bytes],
+ tls_server: TlsServer,
+ client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"],
+) -> None:
+ context = ssl.create_default_context()
+ load_trust_certificate(
+ context,
+ tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(),
+ )
+
+ enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
+ cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM).decode()
+ key = (
+ client_certificate[1]
+ .private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=enc_algo,
+ )
+ .decode()
+ )
+
+ load_client_certificate(
+ context,
+ cert,
+ key=key,
+ password=password.decode("utf-8", errors="surrogateescape") if password else None,
+ )
+
+ perform_handshake(context, tls_server, client_certificate[0])
+
+
+@pytest.mark.parametrize(
+ "cert_is_file, password",
+ [
+ (True, None),
+ (False, None),
+ (True, b"Password123!\xFF"),
+ (False, b"Password123!\xFF"),
+ ],
+)
+def test_client_auth_pem_mixed(
+ cert_is_file: bool,
+ password: t.Optional[bytes],
+ tls_server: TlsServer,
+ client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"],
+ tmp_path: pathlib.Path,
+) -> None:
+ context = ssl.create_default_context()
+ load_trust_certificate(
+ context,
+ tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(),
+ )
+
+ enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
+ cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM).decode()
+ key = (
+ client_certificate[1]
+ .private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=enc_algo,
+ )
+ .decode()
+ )
+
+ cert_file = tmp_path / "cert.pem"
+ cert_file.write_text(cert)
+
+ key_file = tmp_path / "key.pem"
+ key_file.write_text(key)
+
+ load_client_certificate(
+ context,
+ str(cert_file) if cert_is_file else cert,
+ key=key if cert_is_file else str(key_file),
+ password=password.decode("utf-8", errors="surrogateescape") if password else None,
+ )
+
+ cert_file.unlink()
+ key_file.unlink()
+
+ perform_handshake(context, tls_server, client_certificate[0])
+
+
+def test_client_auth_path_der_cert(
+ tls_server: TlsServer,
+ client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"],
+ tmp_path: pathlib.Path,
+) -> None:
+ context = ssl.create_default_context()
+ load_trust_certificate(
+ context,
+ tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(),
+ )
+
+ cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.DER)
+ key = client_certificate[1].private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption(),
+ )
+ cert_file = tmp_path / "cert.crt"
+ cert_file.write_bytes(cert)
+
+ key_file = tmp_path / "key.pem"
+ key_file.write_bytes(key)
+
+ load_client_certificate(context, str(cert_file), key=str(key_file))
+
+ cert_file.unlink()
+ key_file.unlink()
+
+ perform_handshake(context, tls_server, client_certificate[0])
+
+
+@pytest.mark.parametrize("password", [None, b"Password123!\xFF"])
+def test_client_auth_path_der_key(
+ password: t.Optional[bytes],
+ tls_server: TlsServer,
+ client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"],
+ tmp_path: pathlib.Path,
+) -> None:
+ context = ssl.create_default_context()
+ load_trust_certificate(
+ context,
+ tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(),
+ )
+
+ enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
+ cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM)
+ key = client_certificate[1].private_bytes(
+ encoding=serialization.Encoding.DER,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=enc_algo,
+ )
+ cert_file = tmp_path / "cert.pem"
+ cert_file.write_bytes(cert)
+
+ key_file = tmp_path / "key.crt"
+ key_file.write_bytes(key)
+
+ load_client_certificate(
+ context,
+ str(cert_file),
+ key=str(key_file),
+ password=password.decode("utf-8", errors="surrogateescape") if password else None,
+ )
+
+ cert_file.unlink()
+ key_file.unlink()
+
+ perform_handshake(context, tls_server, client_certificate[0])
+
+
+@pytest.mark.parametrize("password", [None, b"Password123!\xFF"])
+def test_client_auth_path_pfx_file(
+ password: t.Optional[bytes],
+ tls_server: TlsServer,
+ client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"],
+ tmp_path: pathlib.Path,
+) -> None:
+ context = ssl.create_default_context()
+ load_trust_certificate(
+ context,
+ tls_server.ca.public_bytes(serialization.Encoding.PEM).decode(),
+ )
+
+ enc_algo = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
+ data = serialize_key_and_certificates(b"FriendlyName", client_certificate[1], client_certificate[0], None, enc_algo)
+
+ cert_file = tmp_path / "cert.pfx"
+ cert_file.write_bytes(data)
+
+ load_client_certificate(
+ context,
+ str(cert_file),
+ password=password.decode("utf-8", errors="surrogateescape") if password else None,
+ )
+
+ cert_file.unlink()
+
+ perform_handshake(context, tls_server, client_certificate[0])
+
+
+def test_client_auth_incorrect_password(
+ client_certificate: t.Tuple["x509.Certificate", "rsa.RSAPrivateKey"],
+) -> None:
+ context = ssl.create_default_context()
+
+ enc_algo = serialization.BestAvailableEncryption(b"Password01")
+ cert = client_certificate[0].public_bytes(encoding=serialization.Encoding.PEM).decode()
+ key = (
+ client_certificate[1]
+ .private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=enc_algo,
+ )
+ .decode()
+ )
+
+ with pytest.raises(ssl.SSLError):
+ load_client_certificate(context, key + cert)
+
+
+def perform_handshake(
+ client: ssl.SSLContext,
+ server: TlsServer,
+ expected_client: t.Optional["x509.Certificate"] = None,
+) -> None:
+ client_in = ssl.MemoryBIO()
+ client_out = ssl.MemoryBIO()
+ client_tls = client.wrap_bio(
+ client_in,
+ client_out,
+ server_side=False,
+ server_hostname=server.name,
+ )
+
+ server_in = ssl.MemoryBIO()
+ server_out = ssl.MemoryBIO()
+ server_tls = server.context.wrap_bio(
+ server_in,
+ server_out,
+ server_side=True,
+ )
+
+ in_token: t.Optional[bytes] = None
+ while True:
+ if in_token:
+ client_in.write(in_token)
+
+ out_token: t.Optional[bytes] = None
+ try:
+ client_tls.do_handshake()
+ except ssl.SSLWantReadError:
+ pass
+
+ out_token = client_out.read()
+ if not out_token:
+ break
+
+ server_in.write(out_token)
+ try:
+ server_tls.do_handshake()
+ except ssl.SSLWantReadError:
+ pass
+
+ in_token = server_out.read()
+ if not in_token:
+ break
+
+ assert client_tls.version() == server_tls.version()
+ assert client_tls.cipher() == server_tls.cipher()
+
+ if expected_client:
+ client_cert_bytes = server_tls.getpeercert(True)
+ assert client_cert_bytes is not None
+ client_cert = x509.load_der_x509_certificate(client_cert_bytes)
+ assert str(client_cert.subject) == str(expected_client.subject)
+ assert client_cert.public_bytes(serialization.Encoding.PEM) == expected_client.public_bytes(
+ serialization.Encoding.PEM
+ )
diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_laps.py b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_laps.py
new file mode 100644
index 000000000..662230b65
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_laps.py
@@ -0,0 +1,213 @@
+# Copyright (c) 2023 Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+import base64
+import uuid
+
+import pytest
+
+dpapi_ng = pytest.importorskip("dpapi_ng")
+
+from ansible_collections.microsoft.ad.plugins.plugin_utils._ldap.laps import LAPSDecryptor
+
+
+def test_decrypt_laps_blob() -> None:
+ root_key = (
+ b"\xDC\x24\xFF\x6D\xB1\x31\x70\x18"
+ b"\x8E\xC9\xFF\xB5\x11\xFA\x41\xA6"
+ b"\xEB\x51\xD0\x49\xFE\x8C\xFE\x27"
+ b"\xD6\x83\xD5\x1E\xD5\xA1\x0F\xAF"
+ b"\x64\x3F\x67\x2E\xE6\xED\x8F\x9F"
+ b"\x5E\x11\x18\x72\x7B\x3A\xA9\x38"
+ b"\x4F\xF9\x43\xFF\x5B\x04\xF3\x10"
+ b"\x53\x0B\x08\x3C\x34\x37\x99\x6E"
+ )
+ cache = dpapi_ng.KeyCache()
+ cache.load_key(root_key, uuid.UUID("bac64fa8-e890-917c-1090-83e7b0f85996"))
+ decryptor = LAPSDecryptor()
+ decryptor._cache = cache
+
+ laps_blob = (
+ b"\xB6\x82\xD9\x01\x64\x7B\xAE\x07"
+ b"\xE4\x04\x00\x00\x00\x00\x00\x00"
+ b"\x30\x82\x04\x4E\x06\x09\x2A\x86"
+ b"\x48\x86\xF7\x0D\x01\x07\x03\xA0"
+ b"\x82\x04\x3F\x30\x82\x04\x3B\x02"
+ b"\x01\x02\x31\x82\x04\x07\xA2\x82"
+ b"\x04\x03\x02\x01\x04\x30\x82\x03"
+ b"\xC5\x04\x82\x03\x6C\x01\x00\x00"
+ b"\x00\x4B\x44\x53\x4B\x03\x00\x00"
+ b"\x00\x69\x01\x00\x00\x11\x00\x00"
+ b"\x00\x12\x00\x00\x00\xA8\x4F\xC6"
+ b"\xBA\x90\xE8\x7C\x91\x10\x90\x83"
+ b"\xE7\xB0\xF8\x59\x96\x08\x03\x00"
+ b"\x00\x18\x00\x00\x00\x18\x00\x00"
+ b"\x00\x44\x48\x50\x42\x00\x01\x00"
+ b"\x00\x87\xA8\xE6\x1D\xB4\xB6\x66"
+ b"\x3C\xFF\xBB\xD1\x9C\x65\x19\x59"
+ b"\x99\x8C\xEE\xF6\x08\x66\x0D\xD0"
+ b"\xF2\x5D\x2C\xEE\xD4\x43\x5E\x3B"
+ b"\x00\xE0\x0D\xF8\xF1\xD6\x19\x57"
+ b"\xD4\xFA\xF7\xDF\x45\x61\xB2\xAA"
+ b"\x30\x16\xC3\xD9\x11\x34\x09\x6F"
+ b"\xAA\x3B\xF4\x29\x6D\x83\x0E\x9A"
+ b"\x7C\x20\x9E\x0C\x64\x97\x51\x7A"
+ b"\xBD\x5A\x8A\x9D\x30\x6B\xCF\x67"
+ b"\xED\x91\xF9\xE6\x72\x5B\x47\x58"
+ b"\xC0\x22\xE0\xB1\xEF\x42\x75\xBF"
+ b"\x7B\x6C\x5B\xFC\x11\xD4\x5F\x90"
+ b"\x88\xB9\x41\xF5\x4E\xB1\xE5\x9B"
+ b"\xB8\xBC\x39\xA0\xBF\x12\x30\x7F"
+ b"\x5C\x4F\xDB\x70\xC5\x81\xB2\x3F"
+ b"\x76\xB6\x3A\xCA\xE1\xCA\xA6\xB7"
+ b"\x90\x2D\x52\x52\x67\x35\x48\x8A"
+ b"\x0E\xF1\x3C\x6D\x9A\x51\xBF\xA4"
+ b"\xAB\x3A\xD8\x34\x77\x96\x52\x4D"
+ b"\x8E\xF6\xA1\x67\xB5\xA4\x18\x25"
+ b"\xD9\x67\xE1\x44\xE5\x14\x05\x64"
+ b"\x25\x1C\xCA\xCB\x83\xE6\xB4\x86"
+ b"\xF6\xB3\xCA\x3F\x79\x71\x50\x60"
+ b"\x26\xC0\xB8\x57\xF6\x89\x96\x28"
+ b"\x56\xDE\xD4\x01\x0A\xBD\x0B\xE6"
+ b"\x21\xC3\xA3\x96\x0A\x54\xE7\x10"
+ b"\xC3\x75\xF2\x63\x75\xD7\x01\x41"
+ b"\x03\xA4\xB5\x43\x30\xC1\x98\xAF"
+ b"\x12\x61\x16\xD2\x27\x6E\x11\x71"
+ b"\x5F\x69\x38\x77\xFA\xD7\xEF\x09"
+ b"\xCA\xDB\x09\x4A\xE9\x1E\x1A\x15"
+ b"\x97\x3F\xB3\x2C\x9B\x73\x13\x4D"
+ b"\x0B\x2E\x77\x50\x66\x60\xED\xBD"
+ b"\x48\x4C\xA7\xB1\x8F\x21\xEF\x20"
+ b"\x54\x07\xF4\x79\x3A\x1A\x0B\xA1"
+ b"\x25\x10\xDB\xC1\x50\x77\xBE\x46"
+ b"\x3F\xFF\x4F\xED\x4A\xAC\x0B\xB5"
+ b"\x55\xBE\x3A\x6C\x1B\x0C\x6B\x47"
+ b"\xB1\xBC\x37\x73\xBF\x7E\x8C\x6F"
+ b"\x62\x90\x12\x28\xF8\xC2\x8C\xBB"
+ b"\x18\xA5\x5A\xE3\x13\x41\x00\x0A"
+ b"\x65\x01\x96\xF9\x31\xC7\x7A\x57"
+ b"\xF2\xDD\xF4\x63\xE5\xE9\xEC\x14"
+ b"\x4B\x77\x7D\xE6\x2A\xAA\xB8\xA8"
+ b"\x62\x8A\xC3\x76\xD2\x82\xD6\xED"
+ b"\x38\x64\xE6\x79\x82\x42\x8E\xBC"
+ b"\x83\x1D\x14\x34\x8F\x6F\x2F\x91"
+ b"\x93\xB5\x04\x5A\xF2\x76\x71\x64"
+ b"\xE1\xDF\xC9\x67\xC1\xFB\x3F\x2E"
+ b"\x55\xA4\xBD\x1B\xFF\xE8\x3B\x9C"
+ b"\x80\xD0\x52\xB9\x85\xD1\x82\xEA"
+ b"\x0A\xDB\x2A\x3B\x73\x13\xD3\xFE"
+ b"\x14\xC8\x48\x4B\x1E\x05\x25\x88"
+ b"\xB9\xB7\xD2\xBB\xD2\xDF\x01\x61"
+ b"\x99\xEC\xD0\x6E\x15\x57\xCD\x09"
+ b"\x15\xB3\x35\x3B\xBB\x64\xE0\xEC"
+ b"\x37\x7F\xD0\x28\x37\x0D\xF9\x2B"
+ b"\x52\xC7\x89\x14\x28\xCD\xC6\x7E"
+ b"\xB6\x18\x4B\x52\x3D\x1D\xB2\x46"
+ b"\xC3\x2F\x63\x07\x84\x90\xF0\x0E"
+ b"\xF8\xD6\x47\xD1\x48\xD4\x79\x54"
+ b"\x51\x5E\x23\x27\xCF\xEF\x98\xC5"
+ b"\x82\x66\x4B\x4C\x0F\x6C\xC4\x16"
+ b"\x59\x13\xE2\xBB\xB1\xC4\xA8\x36"
+ b"\x61\x13\x90\xEB\xA3\xA4\x67\x40"
+ b"\xF2\x9E\x7D\xAC\x49\xA4\xBE\x93"
+ b"\xC6\xFE\xBC\xFC\x1F\x13\xAA\xAF"
+ b"\xFE\xFE\x2A\x44\x17\x0E\x5B\x2E"
+ b"\x03\x17\x9B\x42\x82\x1D\xCD\x06"
+ b"\x2C\xAD\xEF\xA6\x6F\x3C\x60\xD5"
+ b"\xC5\x3C\x2B\x96\xAC\x08\x02\xA0"
+ b"\x7C\xCA\x7F\x51\x60\xD9\xF3\x3A"
+ b"\xE2\xFA\x87\xA5\x90\x61\x91\x6B"
+ b"\xFD\x89\x3D\x20\x72\x7D\xDE\xCA"
+ b"\x47\xD4\x21\x2D\xD9\x0D\x0F\x65"
+ b"\xB2\x42\x60\xBD\x9D\xC1\xF1\x19"
+ b"\x7B\x5E\x7B\xCE\x08\x05\x00\xC1"
+ b"\xEA\x95\xA4\xAB\x60\xBE\x3C\x13"
+ b"\x0F\xB6\xB0\x76\xFC\xA0\x6F\x8E"
+ b"\xE1\x39\x7E\x58\x84\x53\x6B\xF9"
+ b"\x03\x14\xAF\x12\xCF\xB3\x1A\x1A"
+ b"\xAC\x10\x51\x72\x83\x17\xF3\xCC"
+ b"\x28\x47\xDC\x3F\xE2\x54\x3A\x7E"
+ b"\xA1\xFF\x23\xB7\xC9\xD6\x0F\x6B"
+ b"\x1E\xD9\x20\xB4\xC6\x0B\x9D\xC4"
+ b"\xDF\x08\x7D\xD0\x95\x5D\x01\xDC"
+ b"\xFC\x4E\xBC\x9F\xF5\x33\x0C\xF0"
+ b"\xF1\x1B\x46\xF9\x4D\x16\x64\x36"
+ b"\x73\x52\xCF\xAD\xEC\x72\x17\x0F"
+ b"\x4A\xA2\xC8\x5A\xAF\x73\x6C\xAE"
+ b"\xF4\x7A\x21\x65\x4B\xBF\xDD\xAB"
+ b"\x34\x8D\xDD\x9C\x22\x8E\x2C\xDB"
+ b"\xD1\xAD\x08\xE1\x87\x31\xC6\xA4"
+ b"\x6E\xF4\xCF\x4C\xF1\xE3\x5E\x0B"
+ b"\x10\x65\xFA\x51\x0D\x53\x01\x88"
+ b"\xF3\x64\x00\x6F\x00\x6D\x00\x61"
+ b"\x00\x69\x00\x6E\x00\x2E\x00\x74"
+ b"\x00\x65\x00\x73\x00\x74\x00\x00"
+ b"\x00\x64\x00\x6F\x00\x6D\x00\x61"
+ b"\x00\x69\x00\x6E\x00\x2E\x00\x74"
+ b"\x00\x65\x00\x73\x00\x74\x00\x00"
+ b"\x00\x30\x53\x06\x09\x2B\x06\x01"
+ b"\x04\x01\x82\x37\x4A\x01\x30\x46"
+ b"\x06\x0A\x2B\x06\x01\x04\x01\x82"
+ b"\x37\x4A\x01\x01\x30\x38\x30\x36"
+ b"\x30\x34\x0C\x03\x53\x49\x44\x0C"
+ b"\x2D\x53\x2D\x31\x2D\x35\x2D\x32"
+ b"\x31\x2D\x34\x31\x35\x31\x38\x30"
+ b"\x38\x37\x39\x37\x2D\x33\x34\x33"
+ b"\x30\x35\x36\x31\x30\x39\x32\x2D"
+ b"\x32\x38\x34\x33\x34\x36\x34\x35"
+ b"\x38\x38\x2D\x35\x31\x32\x30\x0B"
+ b"\x06\x09\x60\x86\x48\x01\x65\x03"
+ b"\x04\x01\x2D\x04\x28\x8F\xC9\xA4"
+ b"\x80\xB4\x86\x54\x29\x23\x70\xE5"
+ b"\x13\x2C\xC3\x71\xCE\x0A\xA2\x1B"
+ b"\x42\x0D\x6C\xBF\x59\x10\x29\x91"
+ b"\xED\xEB\x3D\x1B\x79\x08\xDD\x15"
+ b"\x84\x19\x35\xFF\xA0\x30\x2B\x06"
+ b"\x09\x2A\x86\x48\x86\xF7\x0D\x01"
+ b"\x07\x01\x30\x1E\x06\x09\x60\x86"
+ b"\x48\x01\x65\x03\x04\x01\x2E\x30"
+ b"\x11\x04\x0C\x03\xDD\xDF\x73\xE1"
+ b"\x5C\x1C\xFB\xBD\x6C\x6F\x50\x02"
+ b"\x01\x10\x75\x32\x34\x64\x9C\x6E"
+ b"\xD7\xB0\xC7\xE0\xE4\x36\x90\xB1"
+ b"\x40\x7F\x20\xB3\xB8\x45\xE2\xFD"
+ b"\x62\xBE\x7C\x1B\x17\xAE\x0C\xC4"
+ b"\x4C\xD7\xD2\x4A\x97\xD9\x4E\x05"
+ b"\x4A\x06\x96\xC0\x73\xF2\x94\xF0"
+ b"\xAF\x85\xDB\xFF\xD2\x42\x3E\xEE"
+ b"\x0B\xCB\x24\xF1\xE6\x75\x7B\xA9"
+ b"\xB9\x56\x2F\xBA\x90\x49\x2D\xE8"
+ b"\x58\xE9\xCE\x7D\x30\xD3\x46\xA3"
+ b"\xDA\x81\x7A\xFA\x18\x92\x50\x72"
+ b"\xD2\x6B\x16\xB7\x56\xBD\x9C\xDE"
+ b"\xF4\x9D\xD5\x2A\x8B\x2A\x4D\xB0"
+ b"\x0D\xFB\xAA\x76\x9F\xAB\xE8\x7A"
+ b"\x33\xDC\xE8\xD4\x3B\xF8\x21\x9B"
+ b"\x35\x30\x66\x70\x8A\xB5\xDE\xB6"
+ b"\xFC\x05\xB2\x20\x22\x2C\x09\xD7"
+ b"\x74\x32\x16\x80\x83\x0A\xEE\x42"
+ b"\x15\x5D\x56\x22"
+ )
+ actual = decryptor.decrypt(laps_blob)
+ assert actual['update_timestamp'] == 133281382308674404
+ assert actual['flags'] == 0
+ assert actual['encrypted_value'] == base64.b64encode(laps_blob[16:]).decode()
+ assert actual['value'] == '{"n":"Administrator","t":"1d982b607ae7b64","p":"6jr&}yK++{0Q}&"}'
+ assert 'debug' not in actual
+
+
+def test_decrypt_invalid_laps_blob() -> None:
+ decryptor = LAPSDecryptor()
+
+ laps_blob = (
+ b"\xB6\x82\xD9\x01\x64\x7B\xAE\x07"
+ b"\x01\x00\x00\x00\x01\x00\x00\x00"
+ b"\x01"
+ )
+
+ actual = decryptor.decrypt(laps_blob)
+ assert actual['update_timestamp'] == 133281382308674404
+ assert actual['flags'] == 1
+ assert actual['encrypted_value'] == "AQ=="
+ assert 'value' not in actual
+ assert 'Failed to decrypt value due to error - NotEnougData' in actual['debug']
diff --git a/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_schema.py b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_schema.py
new file mode 100644
index 000000000..6238a387d
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/plugins/plugin_utils/_ldap/test_schema.py
@@ -0,0 +1,156 @@
+# Copyright (c) 2023 Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+import uuid
+
+import pytest
+
+sansldap = pytest.importorskip("sansldap")
+
+from ansible_collections.microsoft.ad.plugins.plugin_utils._ldap.schema import LDAPSchema
+
+
+def test_cast_from_objectsid() -> None:
+ type_desc = sansldap.schema.AttributeTypeDescription(
+ "1.0",
+ names=["objectSid"],
+ syntax="foo",
+ single_value=True,
+ )
+ schema = LDAPSchema({"objectsid": type_desc})
+
+ actual = schema.cast_object("objectSid", [b"\x01\x01\x00\x00\x00\x00\x00\x05\x13\x00\x00\x00"])
+ assert actual == "S-1-5-19"
+
+
+def test_cast_from_objectguid() -> None:
+ type_desc = sansldap.schema.AttributeTypeDescription(
+ "1.0",
+ names=["objectGuid"],
+ syntax="bar",
+ single_value=True,
+ )
+ schema = LDAPSchema({"objectguid": type_desc})
+
+ value = uuid.uuid4()
+ actual = schema.cast_object("objectGuid", [value.bytes_le])
+ assert actual == str(value)
+
+
+@pytest.mark.parametrize("single_value", [True, False])
+def test_from_bool(single_value: bool) -> None:
+ type_desc = sansldap.schema.AttributeTypeDescription(
+ "1.0",
+ names=["myAttr"],
+ syntax="1.3.6.1.4.1.1466.115.121.1.7",
+ single_value=single_value,
+ )
+ schema = LDAPSchema({"myattr": type_desc})
+
+ actual = schema.cast_object("myAttr", [b"TRUE", b"FALSE"])
+ if single_value:
+ assert actual is True
+ else:
+ assert actual == [True, False]
+
+
+@pytest.mark.parametrize(
+ "single_value, syntax",
+ [
+ (True, "1.3.6.1.4.1.1466.115.121.1.27"),
+ (False, "1.3.6.1.4.1.1466.115.121.1.27"),
+ (True, "1.2.840.113556.1.4.906"),
+ (False, "1.2.840.113556.1.4.906"),
+ ],
+)
+def test_from_int(single_value: bool, syntax: str) -> None:
+ type_desc = sansldap.schema.AttributeTypeDescription(
+ "1.0",
+ names=["myAttr"],
+ syntax=syntax,
+ single_value=single_value,
+ )
+ schema = LDAPSchema({"myattr": type_desc})
+
+ actual = schema.cast_object("myAttr", [b"1", b"2345678910"])
+ if single_value:
+ assert actual == 1
+ else:
+ assert actual == [1, 2345678910]
+
+
+@pytest.mark.parametrize(
+ "single_value, syntax",
+ [
+ (True, "1.3.6.1.4.1.1466.115.121.1.40"),
+ (False, "1.3.6.1.4.1.1466.115.121.1.40"),
+ (True, "1.2.840.113556.1.4.907"),
+ (False, "1.2.840.113556.1.4.907"),
+ (True, "OctetString"),
+ (False, "OctetString"),
+ ],
+)
+def test_from_bytes(single_value: bool, syntax: str) -> None:
+ type_desc = sansldap.schema.AttributeTypeDescription(
+ "1.0",
+ names=["myAttr"],
+ syntax=syntax,
+ single_value=single_value,
+ )
+ schema = LDAPSchema({"myattr": type_desc})
+
+ actual = schema.cast_object("myAttr", [b"\x00", b"\x00\x01"])
+ if single_value:
+ assert actual == "AA=="
+ else:
+ assert actual == ["AA==", "AAE="]
+
+
+@pytest.mark.parametrize("single_value", [True, False])
+def test_from_string(single_value: bool) -> None:
+ type_desc = sansldap.schema.AttributeTypeDescription(
+ "1.0",
+ names=["myAttr"],
+ syntax="Something",
+ single_value=single_value,
+ )
+ schema = LDAPSchema({"myattr": type_desc})
+
+ actual = schema.cast_object("myAttr", [b"caf\xc3\xa9\xFF", b"\x00\x7E\xDF\xFF"])
+ if single_value:
+ assert actual == "café\uDCFF"
+ else:
+ assert actual == ["café\uDCFF", "\u0000~\uDCDF\uDCFF"]
+
+
+def test_from_string_no_type_desc() -> None:
+ schema = LDAPSchema({})
+
+ actual = schema.cast_object("myAttr", [b"caf\xc3\xa9\xFF", b"\x00\x7E\xDF\xFF"])
+ assert actual == ["café\uDCFF", "\u0000~\uDCDF\uDCFF"]
+
+
+def test_single_value_empty_input() -> None:
+ type_desc = sansldap.schema.AttributeTypeDescription(
+ "1.0",
+ names=["myAttr"],
+ syntax="OctetString",
+ single_value=True,
+ )
+ schema = LDAPSchema({"myattr": type_desc})
+
+ actual = schema.cast_object("myAttr", [])
+ assert actual is None
+
+
+def test_multi_value_empty_input() -> None:
+ type_desc = sansldap.schema.AttributeTypeDescription(
+ "1.0",
+ names=["myAttr"],
+ syntax="OctetString",
+ single_value=False,
+ )
+ schema = LDAPSchema({"myattr": type_desc})
+
+ actual = schema.cast_object("myAttr", [])
+ assert actual == []
diff --git a/ansible_collections/microsoft/ad/tests/unit/requirements.txt b/ansible_collections/microsoft/ad/tests/unit/requirements.txt
new file mode 100644
index 000000000..91a51907a
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/unit/requirements.txt
@@ -0,0 +1,6 @@
+setuptools > 0.6 # pytest-xdist installed via requirements does not work with very old setuptools (sanity_ok)
+unittest2 ; python_version < '2.7'
+importlib ; python_version < '2.7'
+cryptography
+dpapi-ng
+sansldap \ No newline at end of file
diff --git a/ansible_collections/microsoft/ad/tests/utils/shippable/sanity.sh b/ansible_collections/microsoft/ad/tests/utils/shippable/sanity.sh
new file mode 100755
index 000000000..f7165f06d
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/utils/shippable/sanity.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+
+set -o pipefail -eux
+
+if [ "${BASE_BRANCH:-}" ]; then
+ base_branch="origin/${BASE_BRANCH}"
+else
+ base_branch=""
+fi
+
+# shellcheck disable=SC2086
+ansible-test sanity --color -v --junit ${COVERAGE:+"$COVERAGE"} ${CHANGED:+"$CHANGED"} \
+ --docker --base-branch "${base_branch}" --allow-disabled
diff --git a/ansible_collections/microsoft/ad/tests/utils/shippable/shippable.sh b/ansible_collections/microsoft/ad/tests/utils/shippable/shippable.sh
new file mode 100755
index 000000000..cca295366
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/utils/shippable/shippable.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+
+set -o pipefail -eux
+
+declare -a args
+IFS='/:' read -ra args <<< "$1"
+
+ansible_version="${args[0]}"
+script="${args[1]}"
+
+function join {
+ local IFS="$1";
+ shift;
+ echo "$*";
+}
+
+test="$(join / "${args[@]:1}")"
+
+docker images ansible/ansible
+docker images quay.io/ansible/*
+docker ps
+
+for container in $(docker ps --format '{{.Image}} {{.ID}}' | grep -v -e '^drydock/' -e '^quay.io/ansible/azure-pipelines-test-container:' | sed 's/^.* //'); do
+ docker rm -f "${container}" || true # ignore errors
+done
+
+docker ps
+
+command -v python
+python -V
+
+function retry
+{
+ # shellcheck disable=SC2034
+ for repetition in 1 2 3; do
+ set +e
+ "$@"
+ result=$?
+ set -e
+ if [ ${result} == 0 ]; then
+ return ${result}
+ fi
+ echo "@* -> ${result}"
+ done
+ echo "Command '@*' failed 3 times!"
+ exit 1
+}
+
+command -v pip
+pip --version
+pip list --disable-pip-version-check
+if [ "${ansible_version}" == "devel" ]; then
+ retry pip install https://github.com/ansible/ansible/archive/devel.tar.gz --disable-pip-version-check
+else
+ retry pip install "https://github.com/ansible/ansible/archive/stable-${ansible_version}.tar.gz" --disable-pip-version-check
+fi
+
+sudo chown "$(whoami)" "${PWD}/../../"
+
+export PYTHONIOENCODING='utf-8'
+
+if [ -n "${COVERAGE:-}" ]; then
+ # on-demand coverage reporting triggered by setting the COVERAGE environment variable to a non-empty value
+ export COVERAGE="--coverage"
+elif [[ "${COMMIT_MESSAGE}" =~ ci_coverage ]]; then
+ # on-demand coverage reporting triggered by having 'ci_coverage' in the latest commit message
+ export COVERAGE="--coverage"
+else
+ # on-demand coverage reporting disabled (default behavior, always-on coverage reporting remains enabled)
+ export COVERAGE="--coverage-check"
+fi
+
+if [ -n "${COMPLETE:-}" ]; then
+ # disable change detection triggered by setting the COMPLETE environment variable to a non-empty value
+ export CHANGED=""
+elif [[ "${COMMIT_MESSAGE}" =~ ci_complete ]]; then
+ # disable change detection triggered by having 'ci_complete' in the latest commit message
+ export CHANGED=""
+else
+ # enable change detection (default behavior)
+ export CHANGED="--changed"
+fi
+
+if [ "${IS_PULL_REQUEST:-}" == "true" ]; then
+ # run unstable tests which are targeted by focused changes on PRs
+ export UNSTABLE="--allow-unstable-changed"
+else
+ # do not run unstable tests outside PRs
+ export UNSTABLE=""
+fi
+
+if [[ "${COVERAGE:-}" == "--coverage" ]]; then
+ timeout=60
+else
+ timeout=50
+fi
+
+ansible-test env --dump --show --timeout "${timeout}" --color -v
+
+"tests/utils/shippable/${script}.sh" "${test}"
diff --git a/ansible_collections/microsoft/ad/tests/utils/shippable/units.sh b/ansible_collections/microsoft/ad/tests/utils/shippable/units.sh
new file mode 100755
index 000000000..bcf7a771a
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/utils/shippable/units.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+set -o pipefail -eux
+
+# shellcheck disable=SC2086
+ansible-test units --color -v --docker default ${COVERAGE:+"$COVERAGE"} ${CHANGED:+"$CHANGED"}
+
diff --git a/ansible_collections/microsoft/ad/tests/utils/shippable/windows.sh b/ansible_collections/microsoft/ad/tests/utils/shippable/windows.sh
new file mode 100755
index 000000000..9b624e0b8
--- /dev/null
+++ b/ansible_collections/microsoft/ad/tests/utils/shippable/windows.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+
+set -o pipefail -eux
+
+declare -a args
+IFS='/:' read -ra args <<< "$1"
+
+version="${args[1]}"
+
+if [ "${#args[0]}" -gt 2 ]; then
+ target="shippable/windows/group${args[2]}/"
+else
+ target="shippable/windows/"
+fi
+
+stage="${S:-prod}"
+provider="${P:-default}"
+
+# shellcheck disable=SC2086
+ansible-test windows-integration --color -v --retry-on-error "${target}" ${COVERAGE:+"$COVERAGE"} ${CHANGED:+"$CHANGED"} ${UNSTABLE:+"$UNSTABLE"} \
+ --windows "${version}" --docker default --remote-terminate always --remote-stage "${stage}" --remote-provider "${provider}"