diff options
Diffstat (limited to 'ansible_collections/microsoft/ad/tests')
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}" |