diff --git a/0021-ipatests-Add-DNS-bugzilla-integration-tests.patch b/0021-ipatests-Add-DNS-bugzilla-integration-tests.patch new file mode 100644 index 0000000..b2c5e90 --- /dev/null +++ b/0021-ipatests-Add-DNS-bugzilla-integration-tests.patch @@ -0,0 +1,1627 @@ +From ee33a646ac9ad4c2d028e7633c2091239a3c0d6b Mon Sep 17 00:00:00 2001 +From: PRANAV THUBE +Date: Wed, 21 Jan 2026 13:48:05 +0530 +Subject: [PATCH] ipatests: Add DNS bugzilla integration tests. + +Added TestDNSBugs class with tests for: +- DNS record operations (A, AAAA, CNAME, DNAME, TXT, KX, NS, PTR) +- Zone management and validation +- SOA serial number updates +- PTR record synchronization +- Allow-query and allow-transfer settings +- Dynamic update policy handling +- LDAP reconnection scenarios + +Fixes: https://pagure.io/freeipa/issue/9911 +Reviewed-By: Rob Crittenden +Reviewed-By: Rafael Guterres Jeffman +Reviewed-By: David Hanina +--- + ipatests/pytest_ipa/integration/tasks.py | 65 +- + ipatests/test_integration/test_dns.py | 1142 +++++++++++++++++++++- + ipatests/test_xmlrpc/test_dns_plugin.py | 142 +++ + 3 files changed, 1289 insertions(+), 60 deletions(-) + +diff --git a/ipatests/pytest_ipa/integration/tasks.py b/ipatests/pytest_ipa/integration/tasks.py +index 47330d6d9..d9f142794 100755 +--- a/ipatests/pytest_ipa/integration/tasks.py ++++ b/ipatests/pytest_ipa/integration/tasks.py +@@ -1629,7 +1629,7 @@ def add_a_record(master, host): + + + def add_dns_record(master, zone, name, record_type, record_value, +- raiseonerr=True): ++ *extra_args, raiseonerr=True): + """Add DNS record of any type. + + :param master: The IPA master host to run command on +@@ -1639,6 +1639,7 @@ def add_dns_record(master, zone, name, record_type, record_value, + 'txt', 'srv', 'mx', 'ptr', 'naptr', 'dname', + 'cert', 'loc', 'kx', etc. + :param record_value: List of values for the record ++ :param extra_args: Additional arguments (variable positional args) + :param raiseonerr: If True, raise exception on command failure + :return: Command result object + """ +@@ -1646,6 +1647,7 @@ def add_dns_record(master, zone, name, record_type, record_value, + opt = f'--{record_type}-rec' + for val in record_value: + command.extend([opt, val]) ++ command.extend(extra_args) + return master.run_command(command, raiseonerr=raiseonerr) + + +@@ -1678,19 +1680,31 @@ def find_dns_record(master, zone, name=None, raiseonerr=True): + return master.run_command(command, raiseonerr=raiseonerr) + + +-def mod_dns_record(master, zone, name, record_type, old_value, new_value, +- raiseonerr=True): ++def show_dns_record(master, zone, name, *extra_args, raiseonerr=True): ++ """Show DNS record details. ++ ++ :param master: The IPA host to run command on ++ :param zone: DNS zone name ++ :param name: Record name ++ :param extra_args: Additional arguments (variable positional args) ++ :param raiseonerr: If True, raise exception on command failure ++ :return: Command result object ++ """ ++ command = ['ipa', 'dnsrecord-show', zone, name] ++ command.extend(extra_args) ++ return master.run_command(command, raiseonerr=raiseonerr) ++ ++ ++def mod_dns_record(master, zone, name, *extra_args, raiseonerr=True): + """Modify DNS record value. + +- :param record_type: Record type like 'a', 'aaaa', 'txt', etc. +- :param old_value: Current record value +- :param new_value: New record value ++ :param zone: DNS zone name ++ :param name: Record name ++ :param extra_args: Additional arguments for dnsrecord-mod command + """ +- return master.run_command([ +- 'ipa', 'dnsrecord-mod', zone, name, +- f'--{record_type}-rec={old_value}', +- f'--{record_type}-data={new_value}' +- ], raiseonerr=raiseonerr) ++ command = ['ipa', 'dnsrecord-mod', zone, name] ++ command.extend(extra_args) ++ return master.run_command(command, raiseonerr=raiseonerr) + + + def resolve_record(nameserver, query, rtype="SOA", retry=True, timeout=100): +@@ -2121,11 +2135,36 @@ def find_dns_zone(host, zone, all_attrs=False, raiseonerr=True): + return host.run_command(command, raiseonerr=raiseonerr) + + +-def show_dns_zone(host, zone, all_attrs=False, raiseonerr=True): +- """Show DNS zone.""" ++def show_dns_zone(host, zone, all_attrs=False, raw=False, ++ *extra_args, raiseonerr=True): ++ """Show DNS zone. ++ ++ :param host: The IPA host to run command on ++ :param zone: DNS zone name ++ :param all_attrs: If True, show all attributes ++ :param raw: If True, show raw LDAP attributes ++ :param extra_args: Additional arguments (variable positional args) ++ :param raiseonerr: If True, raise exception on command failure ++ :return: Command result object ++ """ + command = ['ipa', 'dnszone-show', zone] + if all_attrs: + command.append('--all') ++ if raw: ++ command.append('--raw') ++ command.extend(extra_args) ++ return host.run_command(command, raiseonerr=raiseonerr) ++ ++ ++def mod_dns_zone(host, zone, *extra_args, raiseonerr=True): ++ """Modify DNS zone. ++ ++ :param host: The IPA host to run command on ++ :param zone: DNS zone name ++ :param extra_args: Additional arguments (variable positional args) ++ """ ++ command = ['ipa', 'dnszone-mod', zone] ++ command.extend(extra_args) + return host.run_command(command, raiseonerr=raiseonerr) + + +diff --git a/ipatests/test_integration/test_dns.py b/ipatests/test_integration/test_dns.py +index 905227497..6840a10d1 100644 +--- a/ipatests/test_integration/test_dns.py ++++ b/ipatests/test_integration/test_dns.py +@@ -7,7 +7,9 @@ from __future__ import absolute_import + + import time + ++import dns.exception + import dns.resolver ++from ipapython.dn import DN + from ipapython.dnsutil import DNSResolver + from ipatests.pytest_ipa.integration import tasks + from ipatests.test_integration.base import IntegrationTest +@@ -188,8 +190,7 @@ class TestDNSAcceptance(IntegrationTest): + tasks.kinit_admin(self.master) + + # Single A record: add, verify, delete, verify deleted +- tasks.add_dns_record(self.master, ZONE, 'allll', +- record_type='a', record_value=[A_RECORD]) ++ tasks.add_dns_record(self.master, ZONE, 'allll', 'a', [A_RECORD]) + ans = self.resolver.resolve(f'allll.{ZONE}', 'A') + assert A_RECORD in [r.address for r in ans] + +@@ -204,8 +205,7 @@ class TestDNSAcceptance(IntegrationTest): + + # Multiple A records: add, verify, delete, verify deleted + multi_recs = [MULTI_A_RECORD1, MULTI_A_RECORD2] +- tasks.add_dns_record(self.master, ZONE, 'aa2', +- record_type='a', record_value=multi_recs) ++ tasks.add_dns_record(self.master, ZONE, 'aa2', 'a', multi_recs) + ans = self.resolver.resolve(f'aa2.{ZONE}', 'A') + assert MULTI_A_RECORD1 in [r.address for r in ans] + assert MULTI_A_RECORD2 in [r.address for r in ans] +@@ -224,12 +224,12 @@ class TestDNSAcceptance(IntegrationTest): + # ========================================================================= + + def test_aaaa_record(self): +- """Test AAAA record add, verify, delete, and invalid values.""" ++ """Test AAAA record add, verify, delete, and invalid values. ++ """ + tasks.kinit_admin(self.master) + + # AAAA record: add, verify, delete, verify deleted +- tasks.add_dns_record(self.master, ZONE, 'aaaa', +- record_type='aaaa', record_value=[AAAA]) ++ tasks.add_dns_record(self.master, ZONE, 'aaaa', 'aaaa', [AAAA]) + ans = self.resolver.resolve(f'aaaa.{ZONE}', 'AAAA') + assert AAAA in [r.address for r in ans] + +@@ -244,9 +244,7 @@ class TestDNSAcceptance(IntegrationTest): + + # Invalid AAAA record should fail and not be created + result = tasks.add_dns_record(self.master, ZONE, 'aaaab', +- record_type='aaaa', +- record_value=[AAAA_BAD1], +- raiseonerr=False) ++ 'aaaa', [AAAA_BAD1], raiseonerr=False) + assert result.returncode != 0 + try: + self.resolver.resolve(f'aaaab.{ZONE}', 'AAAA') +@@ -265,8 +263,7 @@ class TestDNSAcceptance(IntegrationTest): + + # AFSDB record: add, verify, delete, verify deleted + tasks.add_dns_record(self.master, ZONE, 'afsdb', +- record_type='afsdb', +- record_value=[f'0 {AFSDB}']) ++ 'afsdb', [f'0 {AFSDB}']) + ans = self.resolver.resolve(f'afsdb.{ZONE}', 'AFSDB') + assert AFSDB in [str(r.hostname) for r in ans] + +@@ -289,15 +286,13 @@ class TestDNSAcceptance(IntegrationTest): + tasks.kinit_admin(self.master) + + # CNAME record: add, verify, delete, verify deleted +- tasks.add_dns_record(self.master, ZONE, 'cname', +- record_type='cname', record_value=[CNAME]) ++ tasks.add_dns_record(self.master, ZONE, 'cname', 'cname', [CNAME]) + ans = self.resolver.resolve(f'cname.{ZONE}', 'CNAME') + assert CNAME in [str(r.target) for r in ans] + + # Duplicate CNAME should fail and not be created (bz915807) + result = tasks.add_dns_record(self.master, ZONE, 'cname', +- record_type='cname', +- record_value=['a.b.c'], raiseonerr=False) ++ 'cname', ['a.b.c'], raiseonerr=False) + assert result.returncode != 0 + ans = self.resolver.resolve(f'cname.{ZONE}', 'CNAME') + assert 'a.b.c' not in [str(r.target) for r in ans] +@@ -320,8 +315,7 @@ class TestDNSAcceptance(IntegrationTest): + tasks.kinit_admin(self.master) + + # TXT record: add, verify, delete, verify deleted +- tasks.add_dns_record(self.master, ZONE, 'txt', +- record_type='txt', record_value=[TXT]) ++ tasks.add_dns_record(self.master, ZONE, 'txt', 'txt', [TXT]) + ans = self.resolver.resolve(f'txt.{ZONE}', 'TXT') + assert any(TXT in str(r) for r in ans) + +@@ -344,8 +338,7 @@ class TestDNSAcceptance(IntegrationTest): + + # SRV record: add, verify, delete, verify deleted + tasks.add_dns_record(self.master, ZONE, '_srv', +- record_type='srv', +- record_value=[f'{SRV_A} {SRV}']) ++ 'srv', [f'{SRV_A} {SRV}']) + ans = self.resolver.resolve(f'_srv.{ZONE}', 'SRV') + assert SRV in [str(r.target) for r in ans] + +@@ -367,8 +360,7 @@ class TestDNSAcceptance(IntegrationTest): + + # MX record: add, verify, delete, verify deleted + tasks.add_dns_record(self.master, ZONE, '@', +- record_type='mx', +- record_value=[f'10 {self.MX}.']) ++ 'mx', [f'10 {self.MX}.']) + ans = self.resolver.resolve(ZONE, 'MX') + assert f'{self.MX}.' in [str(r.exchange) for r in ans] + +@@ -430,8 +422,7 @@ class TestDNSAcceptance(IntegrationTest): + tasks.kinit_admin(self.master) + + # PTR record: add, verify, delete, verify deleted +- tasks.add_dns_record(self.master, PTR_ZONE, PTR, +- record_type='ptr', record_value=[PTR_VALUE]) ++ tasks.add_dns_record(self.master, PTR_ZONE, PTR, 'ptr', [PTR_VALUE]) + ans = self.resolver.resolve(f'{PTR}.{PTR_ZONE}', 'PTR') + assert PTR_VALUE in [str(r.target) for r in ans] + +@@ -454,8 +445,7 @@ class TestDNSAcceptance(IntegrationTest): + tasks.kinit_admin(self.master) + + # NAPTR record: add, verify, delete, verify deleted +- tasks.add_dns_record(self.master, ZONE, 'naptr', +- record_type='naptr', record_value=[NAPTR]) ++ tasks.add_dns_record(self.master, ZONE, 'naptr', 'naptr', [NAPTR]) + ans = self.resolver.resolve(f'naptr.{ZONE}', 'NAPTR') + assert any(NAPTR_FIND in str(r.regexp) for r in ans) + +@@ -477,16 +467,13 @@ class TestDNSAcceptance(IntegrationTest): + tasks.kinit_admin(self.master) + + # DNAME record: add, verify, delete, verify deleted +- tasks.add_dns_record(self.master, ZONE, 'dname', +- record_type='dname', record_value=[DNAME]) ++ tasks.add_dns_record(self.master, ZONE, 'dname', 'dname', [DNAME]) + ans = self.resolver.resolve(f'dname.{ZONE}', 'DNAME') + assert DNAME in [str(r.target) for r in ans] + + # Duplicate DNAME should fail (bz915797) + result = tasks.add_dns_record(self.master, ZONE, 'dname', +- record_type='dname', +- record_value=[DNAME2], +- raiseonerr=False) ++ 'dname', [DNAME2], raiseonerr=False) + assert result.returncode != 0 + + tasks.del_dns_record(self.master, ZONE, 'dname', +@@ -499,8 +486,7 @@ class TestDNSAcceptance(IntegrationTest): + pass # Record deleted - expected + + # DNAME with underscore: add, verify, delete, verify deleted +- tasks.add_dns_record(self.master, ZONE, 'dname', +- record_type='dname', record_value=[DNAME2]) ++ tasks.add_dns_record(self.master, ZONE, 'dname', 'dname', [DNAME2]) + ans = self.resolver.resolve(f'dname.{ZONE}', 'DNAME') + assert DNAME2 in [str(r.target) for r in ans] + +@@ -523,8 +509,7 @@ class TestDNSAcceptance(IntegrationTest): + + # CERT record: add, verify, delete, verify deleted + tasks.add_dns_record(self.master, ZONE, 'cert', +- record_type='cert', +- record_value=[f'{CERT_B} {CERT}']) ++ 'cert', [f'{CERT_B} {CERT}']) + ans = self.resolver.resolve(f'cert.{ZONE}', 'CERT') + assert any(CERT in str(r) for r in ans) + +@@ -547,8 +532,7 @@ class TestDNSAcceptance(IntegrationTest): + tasks.kinit_admin(self.master) + + # LOC record: add, verify, delete, verify deleted +- tasks.add_dns_record(self.master, ZONE, '@', +- record_type='loc', record_value=[LOC]) ++ tasks.add_dns_record(self.master, ZONE, '@', 'loc', [LOC]) + ans = self.resolver.resolve(ZONE, 'LOC') + assert any(LOC in str(r) for r in ans) + +@@ -571,8 +555,7 @@ class TestDNSAcceptance(IntegrationTest): + + # KX record: add, verify, delete, verify deleted + kx_val = f'{KX_PREF1} {self.A_HOST}' +- tasks.add_dns_record(self.master, ZONE, '@', +- record_type='kx', record_value=[kx_val]) ++ tasks.add_dns_record(self.master, ZONE, '@', 'kx', [kx_val]) + ans = self.resolver.resolve(ZONE, 'KX') + assert int(KX_PREF1) in [r.preference for r in ans] + +@@ -592,8 +575,8 @@ class TestDNSAcceptance(IntegrationTest): + ] + for bad_pref, bad_target in bad_vals: + result = tasks.add_dns_record( +- self.master, ZONE, '@', record_type='kx', +- record_value=[f'{bad_pref} {bad_target}'], raiseonerr=False) ++ self.master, ZONE, '@', 'kx', ++ [f'{bad_pref} {bad_target}'], raiseonerr=False) + assert result.returncode != 0 + try: + self.resolver.resolve(ZONE, 'KX') +@@ -718,15 +701,14 @@ class TestDNSAcceptance(IntegrationTest): + assert len(ans) > 0 + + # Add TXT record and verify +- tasks.add_dns_record(self.master, self.ZONE_PSEARCH, 'txt', +- record_type='txt', record_value=[TXT]) ++ tasks.add_dns_record( ++ self.master, self.ZONE_PSEARCH, 'txt', 'txt', [TXT]) + ans = self.resolver.resolve(f'txt.{self.ZONE_PSEARCH}', 'TXT') + assert any(TXT in str(r) for r in ans) + + # Update TXT record and verify + tasks.mod_dns_record(self.master, self.ZONE_PSEARCH, 'txt', +- record_type='txt', old_value=TXT, +- new_value=NEW_TXT) ++ f'--txt-rec={TXT}', f'--txt-data={NEW_TXT}') + ans = self.resolver.resolve(f'txt.{self.ZONE_PSEARCH}', 'TXT') + assert any(NEW_TXT in str(r) for r in ans) + +@@ -736,8 +718,7 @@ class TestDNSAcceptance(IntegrationTest): + + # Update TXT record again + tasks.mod_dns_record(self.master, self.ZONE_PSEARCH, 'txt', +- record_type='txt', old_value=NEW_TXT, +- new_value=NEWER_TXT) ++ f'--txt-rec={NEW_TXT}', f'--txt-data={NEWER_TXT}') + + # Verify serial increased + ans = self.resolver.resolve(self.ZONE_PSEARCH, 'SOA') +@@ -745,3 +726,1070 @@ class TestDNSAcceptance(IntegrationTest): + assert new_serial > old_serial, ( + f"New serial ({new_serial}) should be higher " + f"than old ({old_serial})") ++ ++ ++class TestDNSMisc(IntegrationTest): ++ """Tests for DNS related bugzilla fixes. ++ ++ This test class covers various DNS bugzilla fixes ported from ++ the shell-based test suite (t.dns_bz.sh). ++ ++ Tests are ordered to match the sequence in the original bash test file. ++ """ ++ topology = 'line' ++ num_clients = 0 ++ ++ # Test zone constants ++ ZONE = "newbzzone" ++ EMAIL = "ipaqar.redhat.com" ++ ++ @classmethod ++ def install(cls, mh): ++ super(TestDNSMisc, cls).install(mh) ++ tasks.kinit_admin(cls.master) ++ # Create test zone for bug tests ++ tasks.add_dns_zone( ++ cls.master, cls.ZONE, ++ skip_overlap_check=True, ++ admin_email=cls.EMAIL, ++ raiseonerr=False ++ ) ++ # Setup DNS resolver for test queries ++ cls.resolver = DNSResolver() ++ cls.resolver.nameservers = [cls.master.ip] ++ cls.resolver.lifetime = 10 ++ ++ @classmethod ++ def uninstall(cls, mh): ++ tasks.kinit_admin(cls.master) ++ # Cleanup test zone ++ tasks.del_dns_zone(cls.master, cls.ZONE) ++ super(TestDNSMisc, cls).uninstall(mh) ++ ++ def test_dns_local_zone_query_no_ldap_error(self): ++ """Test DNS server handles local zone queries without LDAP errors. ++ ++ Verify that DNS queries to local zone with invalid characters ++ (like commas) do not cause LDAP connection loss or DN syntax errors. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=814495 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ ++ # Restart IPA and wait for services ++ self.master.run_command(['ipactl', 'restart']) ++ ++ # Query with invalid characters - should not cause LDAP errors ++ try: ++ self.resolver.resolve(f'abc,xyz.{domain}', 'A') ++ except dns.exception.DNSException: ++ pass ++ time.sleep(10) ++ ++ # Check recent logs for LDAP errors (use journalctl for compatibility) ++ result = self.master.run_command([ ++ 'journalctl', '-u', 'named', '-n', '40', '--no-pager' ++ ], raiseonerr=False) ++ log_tail = result.stdout_text ++ ldap_err1 = 'connection to the ldap server was lost' ++ ldap_err2 = 'LDAP error: Invalid DN syntax' ++ assert (ldap_err1 not in log_tail.lower() ++ and ldap_err2 not in log_tail), "LDAP error found in logs" ++ ++ def test_dns_special_chars_no_crash(self): ++ """Test DNS queries with special characters don't cause crash. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=841900 ++ CVE-2012-3429 bind dyndb ldap named DoS via special chars. ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ ++ # Query with various special characters ++ # Using self.resolver which targets self.master.ip ++ special_chars = ['$', '@', '"', '(', ')', '..', ';', '\\'] ++ for char in special_chars: ++ try: ++ self.resolver.resolve(f'{char}.{domain}', 'A') ++ except dns.exception.DNSException: ++ pass ++ ++ # Check recent logs for crashes (use journalctl for compatibility) ++ result = self.master.run_command([ ++ 'journalctl', '-u', 'named', '-n', '40', '--no-pager' ++ ], raiseonerr=False) ++ log_tail = result.stdout_text ++ has_crash = 'REQUIRE' in log_tail and 'failed, back trace' in log_tail ++ assert not has_crash and '/var/named/core' not in log_tail, \ ++ "Crash or core dump found in logs" ++ ++ def test_dynamic_update_flag_preserved(self): ++ """Test DNS zone dynamic flag is not changed unexpectedly. ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=766075 ++ """ ++ tasks.kinit_admin(self.master) ++ zone = "example766075.com" ++ ++ try: ++ # Create zone with dynamic update enabled ++ tasks.add_dns_zone( ++ self.master, zone, ++ skip_overlap_check=True, ++ dynamic_update=True, ++ admin_email='admin@example.com' ++ ) ++ ++ # Verify dynamic update is True ++ result = tasks.show_dns_zone(self.master, zone) ++ assert "Dynamic update: True" in result.stdout_text ++ ++ # Modify another attribute, dynamic update should remain True ++ tasks.mod_dns_zone(self.master, zone, '--retry=600') ++ result = tasks.show_dns_zone(self.master, zone, all_attrs=True) ++ assert "Dynamic update: True" in result.stdout_text ++ ++ # Explicitly disable dynamic update ++ tasks.mod_dns_zone( ++ self.master, zone, '--dynamic-update=false' ++ ) ++ result = tasks.show_dns_zone(self.master, zone, all_attrs=True) ++ assert "Dynamic update: False" in result.stdout_text ++ ++ finally: ++ tasks.del_dns_zone(self.master, zone) ++ ++ def test_skip_invalid_record_in_zone(self): ++ """Test invalid record is skipped instead of refusing entire zone. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=751776 ++ """ ++ tasks.kinit_admin(self.master) ++ zone = "example751776.com" ++ ++ try: ++ # Add zone and a valid A record ++ tasks.add_dns_zone( ++ self.master, zone, ++ skip_overlap_check=True, ++ admin_email=f'admin@{zone}' ++ ) ++ tasks.add_dns_record( ++ self.master, zone, 'foo', 'a', ['10.0.0.1'] ++ ) ++ ++ # Verify A record resolves ++ result = self.resolver.resolve(f'foo.{zone}', 'A') ++ assert '10.0.0.1' in [r.to_text() for r in result] ++ ++ # Add a valid KX record ++ tasks.add_dns_record( ++ self.master, zone, '@', 'kx', [f'1 foo.{zone}'] ++ ) ++ ++ # Corrupt the KX record via LDAP (invalid format, no preference) ++ ldap = self.master.ldap_connect() ++ dn = DN( ++ ('idnsname', f'{zone}.'), ++ ('cn', 'dns'), ++ self.master.domain.basedn ++ ) ++ entry = ldap.get_entry(dn) ++ entry['kXRecord'] = [f'foo.{zone}'] ++ ldap.update_entry(entry) ++ ++ time.sleep(5) ++ ++ # Verify A record still resolves despite invalid KX record ++ result = self.resolver.resolve(f'foo.{zone}', 'A') ++ assert '10.0.0.1' in [r.to_text() for r in result] ++ ++ finally: ++ tasks.del_dns_zone(self.master, zone, raiseonerr=False) ++ ++ def test_bool_attributes_encoded_properly(self): ++ """Test bool attributes are encoded properly in setattr/addattr. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=797561 ++ """ ++ tasks.kinit_admin(self.master) ++ zone = "example797561.com" ++ ++ try: ++ tasks.add_dns_zone( ++ self.master, zone, ++ skip_overlap_check=True, ++ admin_email='admin@example.com' ++ ) ++ ++ # Check initial state (raw output needed for attribute name) ++ result = tasks.show_dns_zone( ++ self.master, zone, all_attrs=True, raw=True ++ ) ++ assert "idnsallowdynupdate: FALSE" in result.stdout_text ++ ++ # setattr should not allow adding when value exists ++ result = tasks.mod_dns_zone( ++ self.master, zone, ++ '--addattr=idnsAllowDynUpdate=true', ++ raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ err_msg = "idnsallowdynupdate: Only one value allowed." ++ assert err_msg in result.stderr_text ++ ++ # setattr should work ++ tasks.mod_dns_zone( ++ self.master, zone, ++ '--setattr=idnsAllowDynUpdate=true' ++ ) ++ result = tasks.show_dns_zone( ++ self.master, zone, all_attrs=True, raw=True ++ ) ++ assert "idnsallowdynupdate: TRUE" in result.stdout_text ++ ++ finally: ++ tasks.del_dns_zone(self.master, zone) ++ ++ def test_admin_email_formatting(self): ++ """Test dnszone mod formats administrator's email properly. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=750806 ++ """ ++ tasks.kinit_admin(self.master) ++ zone = "example750806.com" ++ ++ try: ++ tasks.add_dns_zone( ++ self.master, zone, ++ skip_overlap_check=True, ++ admin_email='admin@example.com' ++ ) ++ ++ # Modify admin email with dots ++ tasks.mod_dns_zone( ++ self.master, zone, ++ '--admin-email=foo.bar@example.com' ++ ) ++ ++ result = tasks.show_dns_zone(self.master, zone) ++ # The dot in foo.bar should be escaped ++ assert "foo\\.bar.example.com" in result.stdout_text ++ ++ finally: ++ tasks.del_dns_zone(self.master, zone) ++ ++ def test_dns_zone_allow_query_transfer(self): ++ """Test DNS zones load when idnsAllowQuery/idnsAllowTransfer is filled. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=733371 ++ """ ++ tasks.kinit_admin(self.master) ++ zone = "example733371.com" ++ master_ip = self.master.ip ++ ++ try: ++ # Add zone ++ tasks.add_dns_zone( ++ self.master, zone, ++ skip_overlap_check=True, ++ admin_email="admin@example.com" ++ ) ++ ++ # Add a record ++ tasks.add_dns_record( ++ self.master, zone, 'foo', 'a', ['10.0.1.1'] ++ ) ++ ++ # Set allow-query to master IP ++ tasks.mod_dns_zone( ++ self.master, zone, ++ f'--allow-query={master_ip}' ++ ) ++ tasks.restart_named(self.master) ++ ++ # Query should work from allowed IP ++ result = self.master.run_command([ ++ 'dig', '+short', '-t', 'A', f'foo.{zone}', f'@{master_ip}' ++ ]) ++ assert '10.0.1.1' in result.stdout_text ++ ++ # Set allow-query to different IP (not master) ++ tasks.mod_dns_zone( ++ self.master, zone, ++ '--allow-query=10.0.1.1' ++ ) ++ tasks.restart_named(self.master) ++ ++ # Query should fail/be refused from master IP ++ result = self.master.run_command([ ++ 'dig', '+short', '-t', 'A', f'foo.{zone}', f'@{master_ip}' ++ ], raiseonerr=False) ++ # Should not return the IP when query is not allowed ++ assert '10.0.1.1' not in result.stdout_text ++ ++ finally: ++ tasks.del_dns_zone(self.master, zone, raiseonerr=False) ++ ++ def test_zone_deleted_when_removed_from_ldap(self): ++ """Test plugin deletes zone when removed from LDAP with zonerefresh. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=767492 ++ """ ++ tasks.kinit_admin(self.master) ++ zone = "unknownexample767492.com" ++ ++ try: ++ tasks.add_dns_zone( ++ self.master, zone, ++ skip_overlap_check=True, ++ admin_email='admin@unknownexample.com' ++ ) ++ tasks.mod_dns_zone(self.master, zone, '--refresh=30') ++ tasks.add_dns_record( ++ self.master, zone, 'foo', 'a', ['10.0.2.2'] ++ ) ++ ++ time.sleep(35) ++ # Verify record is resolvable ++ ans = self.resolver.resolve(f'foo.{zone}', 'A') ++ assert '10.0.2.2' in [r.address for r in ans] ++ ++ # Delete zone ++ tasks.del_dns_zone(self.master, zone, raiseonerr=True) ++ ++ # Verify zone is gone ++ result = tasks.find_dns_zone(self.master, zone, raiseonerr=False) ++ assert result.returncode != 0 ++ ++ time.sleep(35) ++ # Record should no longer resolve after zone deletion ++ try: ++ self.resolver.resolve(f'foo.{zone}', 'A') ++ raise AssertionError( ++ f"Resolving foo.{zone} should have failed") ++ except (dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, ++ dns.resolver.NoAnswer): ++ pass # Expected - zone was deleted ++ ++ finally: ++ tasks.del_dns_zone(self.master, zone) ++ ++ def test_auto_ptr_record(self): ++ """Test automatic PTR record creation for A and AAAA records. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=767494 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ ++ # IPv4 test variables ++ ipv4_rev = "1.1.10.in-addr.arpa." ++ ipv4_addr = "10.1.1.10" ++ ++ # IPv6 test variables ++ ipv6_rev = "7.4.2.2.0.0.0.0.2.5.0.0.0.2.6.2.ip6.arpa." ++ ipv6_addr = "2620:52:0:2247:221:5eff:fe86:16b4" ++ ipv6_ptr = "4.b.6.1.6.8.e.f.f.f.e.5.1.2.2.0" ++ ++ # === IPv4 Test === ++ try: ++ tasks.add_dns_zone( ++ self.master, ipv4_rev, ++ skip_overlap_check=True, admin_email=self.EMAIL ++ ) ++ tasks.add_dns_record( ++ self.master, domain, 'foo', 'a', [ipv4_addr], ++ '--a-create-reverse' ++ ) ++ result = tasks.show_dns_record(self.master, ipv4_rev, '10') ++ assert f"foo.{domain}" in result.stdout_text ++ ++ # Duplicate should fail ++ result = tasks.add_dns_record( ++ self.master, domain, 'foo', 'a', [ipv4_addr], ++ '--a-create-reverse', raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ assert "already exists" in result.stderr_text ++ ++ finally: ++ for zone, rec in [(ipv4_rev, '10'), (domain, 'foo')]: ++ tasks.del_dns_record( ++ self.master, zone, rec, del_all=True, raiseonerr=False ++ ) ++ tasks.del_dns_zone(self.master, ipv4_rev, raiseonerr=False) ++ ++ # === IPv6 Test === ++ try: ++ tasks.add_dns_zone( ++ self.master, ipv6_rev, ++ skip_overlap_check=True, admin_email=self.EMAIL ++ ) ++ tasks.add_dns_record( ++ self.master, domain, 'bar', 'aaaa', [ipv6_addr], ++ '--aaaa-create-reverse' ++ ) ++ result = tasks.show_dns_record(self.master, ipv6_rev, ipv6_ptr) ++ assert f"bar.{domain}" in result.stdout_text ++ ++ # Duplicate should fail ++ result = tasks.add_dns_record( ++ self.master, domain, 'bar', 'aaaa', [ipv6_addr], ++ '--aaaa-create-reverse', raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ assert "already exists" in result.stderr_text ++ ++ finally: ++ for zone, rec in [(ipv6_rev, ipv6_ptr), (domain, 'bar')]: ++ tasks.del_dns_record( ++ self.master, zone, rec, del_all=True, raiseonerr=False ++ ) ++ tasks.del_dns_zone(self.master, ipv6_rev, raiseonerr=False) ++ ++ def test_serial_number_updates(self): ++ """Test DNS zone serial number updates when record changes. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=804619 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ ++ try: ++ # Get initial serial number (raw output needed) ++ result = tasks.show_dns_zone( ++ self.master, domain, all_attrs=True, raw=True ++ ) ++ serial_line = [ ++ line for line in result.stdout_text.split('\n') ++ if 'idnssoaserial' in line.lower() ++ ][0] ++ initial_serial = int(serial_line.split(':')[1].strip()) ++ ++ # Add a record ++ tasks.add_dns_record( ++ self.master, domain, 'dns175', 'a', ['192.168.0.1'] ++ ) ++ ++ # Get new serial number ++ result = tasks.show_dns_zone( ++ self.master, domain, all_attrs=True, raw=True ++ ) ++ serial_line = [ ++ line for line in result.stdout_text.split('\n') ++ if 'idnssoaserial' in line.lower() ++ ][0] ++ new_serial = int(serial_line.split(':')[1].strip()) ++ ++ assert new_serial > initial_serial, ( ++ f"Serial should have increased: " ++ f"{initial_serial} -> {new_serial}" ++ ) ++ ++ finally: ++ tasks.del_dns_record( ++ self.master, domain, 'dns175', ++ record_type='a', record_value=['192.168.0.1'] ++ ) ++ ++ def test_ns_hostname_requires_a_aaaa(self): ++ """Test NS record hostname must have A or AAAA record. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=804562 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ result = tasks.add_dns_record( ++ self.master, domain, 'dns176', ++ 'ns', [f'ns1.shanks.{domain}.'], raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ assert "does not have a corresponding A/AAAA record" in \ ++ result.stderr_text ++ ++ def test_zone_forwarder_settings(self): ++ """Test zone forwarder settings can be modified. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=795414 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ ++ try: ++ # Set forwarders ++ tasks.mod_dns_zone( ++ self.master, domain, ++ '--forwarder=10.65.202.128', ++ '--forwarder=10.65.202.129', ++ '--forward-policy=first' ++ ) ++ ++ # Remove forwarders ++ tasks.mod_dns_zone( ++ self.master, domain, ++ '--forwarder=', '--forward-policy=' ++ ) ++ ++ finally: ++ # Ensure cleanup happens ++ tasks.mod_dns_zone( ++ self.master, domain, ++ '--forwarder=', '--forward-policy=', ++ raiseonerr=False ++ ) ++ ++ def test_soa_serial_length(self): ++ """Test correct SOA serial number length during installation. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=805871 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ ++ result = tasks.show_dns_zone(self.master, domain) ++ ++ # Extract serial from output ++ for line in result.stdout_text.splitlines(): ++ if 'Serial' in line: ++ serial = line.split(':')[1].strip() ++ # Serial should be 10 digits (YYYYMMDDNN format) ++ assert len(serial) == 10, \ ++ f"Serial length should be 10, got {len(serial)}" ++ break ++ ++ def test_dnsrecord_mod_error_messages(self): ++ """Test proper error message in dnsrecord-mod operations. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=804572 ++ """ ++ tasks.kinit_admin(self.master) ++ zone = "example804572.com" ++ ++ try: ++ tasks.add_dns_zone( ++ self.master, zone, ++ skip_overlap_check=True, ++ admin_email=self.EMAIL ++ ) ++ ++ # Test error when cname-hostname and cname-rec both provided ++ result = tasks.add_dns_record( ++ self.master, zone, 'bz804572', 'cname', [''], ++ f'--cname-hostname=bz804572.{zone}', raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ err_msg = ("invalid 'cname_hostname': Raw value of a DNS record " ++ 'was already set by "cname_rec" option') ++ assert err_msg in result.stderr_text ++ ++ # Test error when modifying without specifying record ++ result = tasks.mod_dns_record( ++ self.master, zone, 'testbz804572', ++ '--a-ip-address=1.2.3.4', ++ raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ assert "'a_rec' is required" in result.stderr_text ++ ++ finally: ++ tasks.del_dns_zone(self.master, zone) ++ ++ def test_reverse_dns_creation_option(self): ++ """Test option for adding Reverse DNS record upon forward creation. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=772301 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ ++ # IPv4 test variables ++ ipv4_addr = "10.1.1.10" ++ ipv4_rev = "1.1.10.in-addr.arpa." ++ ++ # IPv6 test variables ++ ipv6_addr = "2620:52:0:2247:221:5eff:fe86:16b4" ++ ipv6_rev = "7.4.2.2.0.0.0.0.2.5.0.0.0.2.6.2.ip6.arpa." ++ ipv6_ptr = "4.b.6.1.6.8.e.f.f.f.e.5.1.2.2.0" ++ ++ # === IPv4 Test === ++ try: ++ tasks.add_dns_zone( ++ self.master, ipv4_rev, ++ skip_overlap_check=True, admin_email=self.EMAIL ++ ) ++ tasks.add_dns_record( ++ self.master, domain, 'myhost', 'a', [ipv4_addr], ++ '--a-create-reverse' ++ ) ++ ++ # Verify forward record ++ result = tasks.find_dns_record(self.master, domain, 'myhost') ++ assert result.returncode == 0 ++ ++ # Verify reverse record ++ result = tasks.find_dns_record(self.master, ipv4_rev, '10') ++ assert result.returncode == 0 ++ ++ finally: ++ tasks.del_dns_record( ++ self.master, ipv4_rev, '10', del_all=True, raiseonerr=False ++ ) ++ tasks.del_dns_zone(self.master, ipv4_rev, raiseonerr=False) ++ ++ # === IPv6 Test === ++ try: ++ tasks.add_dns_zone( ++ self.master, ipv6_rev, ++ skip_overlap_check=True, admin_email=self.EMAIL ++ ) ++ tasks.add_dns_record( ++ self.master, domain, 'myhost', 'aaaa', [ipv6_addr], ++ '--aaaa-create-reverse' ++ ) ++ ++ # Verify forward record (now has AAAA) ++ result = tasks.find_dns_record(self.master, domain, 'myhost') ++ assert result.returncode == 0 ++ ++ # Verify reverse record ++ result = tasks.find_dns_record(self.master, ipv6_rev, ipv6_ptr) ++ assert result.returncode == 0 ++ ++ finally: ++ for zone, rec in [(ipv6_rev, ipv6_ptr), (domain, 'myhost')]: ++ tasks.del_dns_record( ++ self.master, zone, rec, del_all=True, raiseonerr=False ++ ) ++ tasks.del_dns_zone(self.master, ipv6_rev, raiseonerr=False) ++ ++ def test_non_ascii_chars_escaping(self): ++ """Test bind dyndb ldap escapes non ASCII characters correctly. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=818933 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ ++ # Query with comma in hostname ++ try: ++ self.resolver.resolve(f'foo,bar.{domain}', 'A') ++ except dns.exception.DNSException: ++ pass ++ ++ # Check logs for handle_connection_error bug (use journalctl) ++ result = self.master.run_command([ ++ 'journalctl', '-u', 'named', '-n', '100', '--no-pager' ++ ], raiseonerr=False) ++ assert 'bug in handle_connection_error' not in result.stdout_text, \ ++ "Bug in handle_connection_error found" ++ ++ def test_forwarder_help_text(self): ++ """Test proper help page for forwarder option. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=819635 ++ """ ++ tasks.kinit_admin(self.master) ++ ++ result = self.master.run_command([ ++ 'ipa', 'dnszone-mod', '--help' ++ ]) ++ # Should mention per-zone forwarders, not global ++ assert "global forwarders" not in result.stdout_text.lower() or \ ++ "per-zone forwarders" in result.stdout_text.lower() ++ ++ def test_delete_host_updates_dns(self): ++ """Test DNS is updated when deleting host with --updatedns. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=828687 ++ """ ++ tasks.kinit_admin(self.master) ++ ip_base = self.master.ip.rsplit('.', 1)[0] ++ test_ip = f"{ip_base}.252" ++ test_zone = "llnewzone." ++ # Reverse zone: take first 3 octets, reverse, join ++ rev = '.'.join(self.master.ip.split('.')[:3][::-1]) ++ reverse_zone = f"{rev}.in-addr.arpa." ++ ++ try: ++ tasks.add_dns_zone(self.master, test_zone, skip_overlap_check=True, ++ admin_email=self.EMAIL) ++ self.master.run_command([ ++ 'ipa', 'host-add', f'--ip-address={test_ip}', f'tt.{test_zone}' ++ ]) ++ ++ # Verify PTR record was added ++ result = self.master.run_command([ ++ 'ipa', 'dnsrecord-find', reverse_zone, '252' ++ ], raiseonerr=False) ++ if result.returncode == 0: ++ assert test_zone in result.stdout_text ++ ++ # Delete host with --updatedns ++ self.master.run_command([ ++ 'ipa', 'host-del', f'tt.{test_zone}', '--updatedns' ++ ]) ++ ++ finally: ++ self.master.run_command([ ++ 'ipa', 'host-del', f'tt.{test_zone}' ++ ], raiseonerr=False) ++ tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) ++ ++ def test_ns_record_nonfqdn_validation(self): ++ """Test NS record validation appends zone name to non-FQDN. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=813380 ++ """ ++ tasks.kinit_admin(self.master) ++ host_ip = self.master.ip.rsplit('.', 1)[0] + '.253' ++ zone = "llnewzone813380.com" ++ host = f'nsnew.{zone}' ++ ++ try: ++ tasks.add_dns_zone(self.master, zone, skip_overlap_check=True, ++ admin_email=self.EMAIL) ++ ++ # Create host for NS ++ self.master.run_command([ ++ 'ipa', 'host-add', host, f'--ip-address={host_ip}' ++ ]) ++ ++ # Add non-FQDN NS record (should work by appending zone) ++ tasks.add_dns_record(self.master, zone, '@', 'ns', ['nsnew']) ++ ++ # Verify NS record ++ result = tasks.show_dns_record(self.master, zone, '@') ++ assert 'nsnew' in result.stdout_text ++ ++ finally: ++ self.master.run_command([ ++ 'ipa', 'host-del', host, '--updatedns' ++ ], raiseonerr=False) ++ tasks.del_dns_zone(self.master, zone, raiseonerr=False) ++ ++ def test_reverse_zone_creation(self): ++ """Test reverse zones are created correctly from IP prefix. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=798493 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ ++ test_cases = [ ++ ('10.11.12.0/24', '12.11.10.in-addr.arpa.'), ++ ('10.11.12.0/20', '11.10.in-addr.arpa.'), ++ ('10.11.12.0/16', '11.10.in-addr.arpa.'), ++ ] ++ ++ for forward, reverse in test_cases: ++ try: ++ # Create reverse zone from IP (special --name-from-ip option) ++ self.master.run_command([ ++ 'ipa', 'dnszone-add', ++ f'--admin-email=admin@{domain}', ++ f'--name-from-ip={forward}', ++ '--skip-overlap-check' ++ ], stdin_text='\n') ++ ++ # Verify zone was created ++ result = tasks.find_dns_zone(self.master, reverse) ++ assert f"Zone name: {reverse}" in result.stdout_text ++ ++ finally: ++ tasks.del_dns_zone(self.master, reverse) ++ ++ def test_rndc_reload_no_crash(self): ++ """Test rndc reload does not cause crash with persistent search. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=829728 ++ """ ++ tasks.kinit_admin(self.master) ++ ++ # Verify named is running ++ assert tasks.host_service_active(self.master, 'named') ++ ++ # Run rndc reload ++ result = self.master.run_command(['rndc', 'reload']) ++ assert result.returncode == 0 ++ ++ # Verify named is still running ++ assert tasks.host_service_active(self.master, 'named') ++ ++ def test_zone_transfer_non_fqdn(self): ++ """Test zone transfers work for certain non-FQDNs. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=829388 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ host = "bz829388" ++ ++ try: ++ # Enable zone transfers ++ tasks.mod_dns_zone( ++ self.master, domain, ++ "--allow-transfer=any;" ++ ) ++ ++ # Add a CNAME record without FQDN ++ tasks.add_dns_record( ++ self.master, domain, host, 'cname', [host] ++ ) ++ ++ # Restart IPA ++ self.master.run_command(['ipactl', 'restart']) ++ ++ # Check zone transfer includes FQDN ++ result = self.master.run_command([ ++ 'dig', '-t', 'AXFR', f'@{self.master.hostname}', domain ++ ]) ++ assert f'{host}.{domain}.' in result.stdout_text ++ ++ finally: ++ tasks.del_dns_record( ++ self.master, domain, host, ++ record_type='cname', record_value=[host], ++ raiseonerr=False ++ ) ++ tasks.mod_dns_zone( ++ self.master, domain, ++ "--allow-transfer=none;", ++ raiseonerr=False ++ ) ++ ++ def test_dname_cname_conflict(self): ++ """Test DNAME record validation and conflict with CNAME. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=915805 ++ """ ++ tasks.kinit_admin(self.master) ++ zone = "newzonebz915805" ++ cname = "m.l.k." ++ dname = f"bar.{zone}." ++ dname2 = f"bar_underscore.{zone}." ++ ++ try: ++ # Create zone ++ tasks.add_dns_zone( ++ self.master, zone, ++ admin_email=self.EMAIL, ++ refresh=303, retry=101, ++ expire=1202, minimum=33, ttl=55 ++ ) ++ ++ # Add CNAME ++ tasks.add_dns_record( ++ self.master, zone, 'cname', 'cname', [cname] ++ ) ++ ++ # DNAME on same name should fail (conflicts with CNAME) ++ result = tasks.add_dns_record( ++ self.master, zone, 'cname', 'dname', [dname], raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ ++ # DNAME on different name should succeed ++ tasks.add_dns_record( ++ self.master, zone, 'dname', 'dname', [dname] ++ ) ++ ++ # Second DNAME on same name should fail ++ result = tasks.add_dns_record( ++ self.master, zone, 'dname', 'dname', [dname2], raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ ++ finally: ++ tasks.del_dns_zone(self.master, zone) ++ ++ def test_soa_serial_increments(self): ++ """Test SOA serial number increments for external changes. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=840383 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ zone = f"zone840383.{domain}" ++ txt_value = "bug test" ++ new_txt_value = "Bug Test for 840383" ++ ++ try: ++ # Add zone ++ tasks.add_dns_zone( ++ self.master, zone, ++ skip_overlap_check=True, ++ admin_email=self.EMAIL ++ ) ++ ++ # Add TXT record ++ tasks.add_dns_record( ++ self.master, zone, 'txt', 'txt', [f'"{txt_value}"'] ++ ) ++ ++ # Enable zone transfers ++ tasks.mod_dns_zone( ++ self.master, zone, ++ "--allow-transfer=any;" ++ ) ++ ++ self.master.run_command(['ipactl', 'restart']) ++ ++ # Verify TXT record is part of zone transfer ++ result = self.master.run_command([ ++ 'dig', f'@{self.master.ip}', '-t', 'AXFR', zone ++ ]) ++ assert 'TXT' in result.stdout_text ++ ++ # Get old serial ++ result = self.master.run_command([ ++ 'dig', zone, '+multiline', '-t', 'SOA' ++ ]) ++ old_serial = next( ++ int(ln.split()[0]) for ln in result.stdout_text.splitlines() ++ if 'serial' in ln.lower() ++ ) ++ ++ # Modify TXT record ++ tasks.mod_dns_record( ++ self.master, zone, 'txt', ++ f'--txt-rec="{txt_value}"', f'--txt-data="{new_txt_value}"' ++ ) ++ ++ # Get new serial - should increment after record modification ++ result = self.master.run_command([ ++ 'dig', zone, '+multiline', '-t', 'SOA' ++ ]) ++ new_serial = next( ++ int(ln.split()[0]) for ln in result.stdout_text.splitlines() ++ if 'serial' in ln.lower() ++ ) ++ assert new_serial > old_serial ++ ++ # Enable dynamic updates - serial should NOT change ++ tasks.mod_dns_zone( ++ self.master, zone, ++ "--dynamic-update=true" ++ ) ++ ++ # Get current serial - should NOT change for zone attr update ++ result = self.master.run_command([ ++ 'dig', zone, '+multiline', '-t', 'SOA' ++ ]) ++ current_serial = next( ++ int(ln.split()[0]) for ln in result.stdout_text.splitlines() ++ if 'serial' in ln.lower() ++ ) ++ assert current_serial == new_serial ++ ++ finally: ++ tasks.del_dns_zone(self.master, zone, raiseonerr=False) ++ ++ def test_allow_query_transfer_ipv6(self): ++ """Test allow-query and allow-transfer with IPv4 and IPv6. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=701677 ++ """ ++ tasks.kinit_admin(self.master) ++ zone = "example.com" ++ ipv4 = self.master.ip ++ ipv6_added = False ++ temp_ipv6 = '2001:0db8:0:f101::1/64' ++ ++ # Get default network interface ++ result = self.master.run_command([ ++ 'sh', '-c', ++ "/sbin/ip -4 route show | grep ^default | " ++ "awk '{print $5}' | head -1" ++ ]) ++ eth = result.stdout_text.strip() ++ ++ # Add temporary IPv6 if none exists ++ result = self.master.run_command( ++ ['ip', 'addr', 'show', 'scope', 'global'], raiseonerr=False ++ ) ++ if 'inet6' not in result.stdout_text: ++ self.master.run_command([ ++ '/sbin/ip', '-6', 'addr', 'add', temp_ipv6, 'dev', eth ++ ]) ++ ipv6_added = True ++ ++ # Get IPv6 address ++ result = self.master.run_command([ ++ 'sh', '-c', ++ "ip addr show scope global | " ++ "sed -e's/^.*inet6 \\([^ ]*\\)\\/.*/\\1/;t;d'" ++ ]) ++ ipv6 = result.stdout_text.strip().split('\n')[0] ++ ++ try: ++ tasks.add_dns_zone(self.master, zone, skip_overlap_check=True, ++ admin_email=self.EMAIL) ++ ++ # Test allow-query: IPv4 allowed, IPv6 denied ++ tasks.mod_dns_zone( ++ self.master, zone, ++ f"--allow-query={ipv4};!{ipv6};" ++ ) ++ result = self.master.run_command( ++ ['dig', f'@{ipv4}', '-t', 'soa', zone], raiseonerr=False ++ ) ++ assert 'ANSWER SECTION' in result.stdout_text ++ result = self.master.run_command( ++ ['dig', f'@{ipv6}', '-t', 'soa', zone], raiseonerr=False ++ ) ++ assert 'ANSWER SECTION' not in result.stdout_text ++ ++ # Test allow-query: IPv6 allowed, IPv4 denied ++ tasks.mod_dns_zone( ++ self.master, zone, ++ f"--allow-query={ipv6};!{ipv4};" ++ ) ++ result = self.master.run_command( ++ ['dig', f'@{ipv4}', '-t', 'soa', zone], raiseonerr=False ++ ) ++ assert 'ANSWER SECTION' not in result.stdout_text ++ result = self.master.run_command( ++ ['dig', f'@{ipv6}', '-t', 'soa', zone], raiseonerr=False ++ ) ++ assert 'ANSWER SECTION' in result.stdout_text ++ ++ # Reset allow-query to any ++ tasks.mod_dns_zone( ++ self.master, zone, "--allow-query=any;" ++ ) ++ ++ # Test allow-transfer: IPv4 allowed, IPv6 denied ++ tasks.mod_dns_zone( ++ self.master, zone, ++ f"--allow-transfer={ipv4};!{ipv6};" ++ ) ++ result = self.master.run_command( ++ ['dig', f'@{ipv4}', zone, 'axfr'], raiseonerr=False ++ ) ++ assert 'Transfer failed' not in result.stdout_text ++ result = self.master.run_command( ++ ['dig', f'@{ipv6}', zone, 'axfr'], raiseonerr=False ++ ) ++ assert 'Transfer failed' in result.stdout_text ++ ++ # Test allow-transfer: IPv6 allowed, IPv4 denied ++ tasks.mod_dns_zone( ++ self.master, zone, ++ f"--allow-transfer={ipv6};!{ipv4};" ++ ) ++ result = self.master.run_command( ++ ['dig', f'@{ipv4}', zone, 'axfr'], raiseonerr=False ++ ) ++ assert 'Transfer failed' in result.stdout_text ++ result = self.master.run_command( ++ ['dig', f'@{ipv6}', zone, 'axfr'], raiseonerr=False ++ ) ++ assert 'Transfer failed' not in result.stdout_text ++ ++ finally: ++ tasks.del_dns_zone(self.master, zone, raiseonerr=False) ++ if ipv6_added: ++ self.master.run_command([ ++ '/sbin/ip', '-6', 'addr', 'del', temp_ipv6, 'dev', eth ++ ], raiseonerr=False) +diff --git a/ipatests/test_xmlrpc/test_dns_plugin.py b/ipatests/test_xmlrpc/test_dns_plugin.py +index bff4b40ae..3ab54674b 100644 +--- a/ipatests/test_xmlrpc/test_dns_plugin.py ++++ b/ipatests/test_xmlrpc/test_dns_plugin.py +@@ -529,6 +529,16 @@ class test_dns(Declarative): + ), + + ++ # Test for BZ 783272: proper error for record add to nonexistent zone ++ dict( ++ desc='Try to add record to non-existent zone (BZ 783272)', ++ command=('dnsrecord_add', [u'unknowndomain.test.', u'testrecord'], ++ {'locrecord': u'49 11 42.4 N 16 36 29.6 E 227.64m'}), ++ expected=errors.NotFound( ++ reason=u'unknowndomain.test.: DNS zone not found'), ++ ), ++ ++ + dict( + desc='Create zone %r' % zone1, + command=( +@@ -1267,6 +1277,18 @@ class test_dns(Declarative): + ), + + ++ # Test for BZ 789919: IP address with three octets should be rejected ++ dict( ++ desc='Try to add A record with 3-octet IP to %r in zone %r ' ++ '(BZ 789919)' % (name1, zone1), ++ command=('dnsrecord_add', [zone1, name1], ++ {'arecord': u'1.1.1'}), ++ expected=errors.ValidationError( ++ name='ip_address', ++ error=u'invalid IP address format'), ++ ), ++ ++ + dict( + desc='Add AAAA record to %r in zone %r using dnsrecord_add' % ( + name1, zone1), +@@ -1299,6 +1321,17 @@ class test_dns(Declarative): + }, + ), + ++ # Test for BZ 789987: error when deleting non-existent AAAA value ++ dict( ++ desc='Try to delete non-existent AAAA record value from %r ' ++ '(BZ 789987)' % name1, ++ command=('dnsrecord_del', [zone1, name1], ++ {'aaaarecord': u'2620:52:0:41c9:5054:ff:fe62:65'}), ++ expected=errors.AttrValueNotFound( ++ attr='AAAA record', ++ value=u'2620:52:0:41c9:5054:ff:fe62:65'), ++ ), ++ + dict( + desc='Try to add invalid MX record to zone %r using dnsrecord_add' % (zone1), + command=('dnsrecord_add', [zone1, u'@'], {'mxrecord': zone1_ns }), +@@ -1655,6 +1688,26 @@ class test_dns(Declarative): + u' (see RFC 2230 for details)'), + ), + ++ # Test for BZ 738788: KX record with negative preference ++ dict( ++ desc='Try to add KX record with negative preference (BZ 738788)', ++ command=('dnsrecord_add', [zone1, name1], ++ {'kxrecord': u'-1 1.2.3.4'}), ++ expected=errors.ValidationError( ++ name='preference', ++ error=u'must be at least 0'), ++ ), ++ ++ # Test for BZ 738788: KX record with preference exceeding max ++ dict( ++ desc='Try to add KX record with preference > max (BZ 738788)', ++ command=('dnsrecord_add', [zone1, name1], ++ {'kxrecord': u'333383838383 1.2.3.4'}), ++ expected=errors.ValidationError( ++ name='preference', ++ error=u'can be at most 65535'), ++ ), ++ + dict( + desc='Add KX record to %r using dnsrecord_add' % (name1), + command=('dnsrecord_add', [zone1, name1], {'kxrecord': u'1 foo-1' }), +@@ -6599,6 +6652,95 @@ class test_dns_soa(Declarative): + ), + ), + ++ # BZ 817413: Test middle label longer than 63 chars ++ dict( ++ desc='Try to add zone with middle label > 63 chars (BZ 817413)', ++ command=( ++ 'dnszone_add', ++ [u'domain.sixthreemax.' ++ u'12345678901234567890123345678901234567890' ++ u'123456789012345678901234567890.com'], ++ {} ++ ), ++ expected=errors.ConversionError( ++ name='name', ++ error=u'DNS label cannot be longer than 63 characters' ++ ), ++ ), ++ ++ # BZ 817413: Test first label longer than 63 chars ++ dict( ++ desc='Try to add zone with first label > 63 chars (BZ 817413)', ++ command=( ++ 'dnszone_add', ++ [u'firstlkjhjklasghduygasiudfygvq7i6ertf78q6t4871y8347y2r8734' ++ u'y87aylfisduhcvkljasnkljnasdljdnclakj.long.com'], ++ {} ++ ), ++ expected=errors.ConversionError( ++ name='name', ++ error=u'DNS label cannot be longer than 63 characters' ++ ), ++ ), ++ ++ # BZ 817413: Test TLD longer than 63 chars ++ dict( ++ desc='Try to add zone with TLD > 63 chars (BZ 817413)', ++ command=( ++ 'dnszone_add', ++ [u'long.tld.tldlkjhjklasghduygasiudfygvq7i6ertf78q6t4871y8347' ++ u'y2r8734y87aylfisduhcvkljasnkljnasdljdnclakj'], ++ {} ++ ), ++ expected=errors.ConversionError( ++ name='name', ++ error=u'DNS label cannot be longer than 63 characters' ++ ), ++ ), ++ ++ # BZ 817413: Test numeric TLD is allowed (success case) ++ dict( ++ desc='Add zone with numeric TLD (BZ 817413)', ++ command=('dnszone_add', [u'domain.numeric.123.'], {}), ++ expected={ ++ 'value': DNSName(u'domain.numeric.123.'), ++ 'summary': None, ++ 'result': { ++ 'dn': DN(('idnsname', 'domain.numeric.123.'), ++ api.env.container_dns, api.env.basedn), ++ 'idnsname': [DNSName(u'domain.numeric.123.')], ++ 'idnszoneactive': [True], ++ 'idnssoamname': [DNSName(api.env.host)], ++ 'nsrecord': lambda x: True, ++ 'idnssoarname': lambda x: True, ++ 'idnssoaserial': [fuzzy_digits], ++ 'idnssoarefresh': [fuzzy_digits], ++ 'idnssoaretry': [fuzzy_digits], ++ 'idnssoaexpire': [fuzzy_digits], ++ 'idnssoaminimum': [fuzzy_digits], ++ 'idnsallowdynupdate': [False], ++ 'idnsupdatepolicy': [u'grant %(realm)s krb5-self * A; ' ++ u'grant %(realm)s krb5-self * AAAA; ' ++ u'grant %(realm)s krb5-self * SSHFP;' ++ % dict(realm=api.env.realm)], ++ 'idnsallowtransfer': [u'none;'], ++ 'idnsallowquery': [u'any;'], ++ 'objectclass': objectclasses.dnszone, ++ }, ++ }, ++ ), ++ ++ # BZ 817413: Delete zone with numeric TLD (cleanup) ++ dict( ++ desc='Delete zone with numeric TLD (BZ 817413)', ++ command=('dnszone_del', [u'domain.numeric.123.'], {}), ++ expected={ ++ 'value': [DNSName(u'domain.numeric.123.')], ++ 'summary': u'Deleted DNS zone "domain.numeric.123."', ++ 'result': {'failed': []}, ++ }, ++ ), ++ + dict( + desc='Adding a zone - %r - with invalid s e-mail - %r' % + (zone6, zone6_rname_invalid_dnsname), +-- +2.52.0 + diff --git a/0022-Avoid-int-overflow-with-pwpolicy-minlife.patch b/0022-Avoid-int-overflow-with-pwpolicy-minlife.patch new file mode 100644 index 0000000..45bee9e --- /dev/null +++ b/0022-Avoid-int-overflow-with-pwpolicy-minlife.patch @@ -0,0 +1,154 @@ +From 7b0ac4f3dd8febf0c22a11bf2db9b780a242e302 Mon Sep 17 00:00:00 2001 +From: Rob Crittenden +Date: Mon, 26 Jan 2026 19:25:17 -0500 +Subject: [PATCH] Avoid int overflow with pwpolicy minlife + +This converts the value to an unsigned integer instead and +sets the max value to match the maxlife (~54 years). + +A syslog log message was added to explain why the "too young" +message is returned + +Fixes: https://pagure.io/freeipa/issue/9929 + +Signed-off-by: Rob Crittenden +Reviewed-By: David Hanina +Reviewed-By: Rafael Guterres Jeffman +--- + .../ipa-slapi-plugins/ipa-pwd-extop/common.c | 2 +- + ipaserver/plugins/pwpolicy.py | 1 + + ipatests/test_integration/test_pwpolicy.py | 50 ++++++++++++++++--- + util/ipa_pwd.c | 1 + + util/ipa_pwd.h | 2 +- + 5 files changed, 48 insertions(+), 8 deletions(-) + +diff --git a/daemons/ipa-slapi-plugins/ipa-pwd-extop/common.c b/daemons/ipa-slapi-plugins/ipa-pwd-extop/common.c +index 0e69f3410..71642540a 100644 +--- a/daemons/ipa-slapi-plugins/ipa-pwd-extop/common.c ++++ b/daemons/ipa-slapi-plugins/ipa-pwd-extop/common.c +@@ -407,7 +407,7 @@ int ipapwd_getPolicy(const char *dn, + } + + /* read data out of policy object */ +- policy->min_pwd_life = slapi_entry_attr_get_int(pe, "krbMinPwdLife"); ++ policy->min_pwd_life = slapi_entry_attr_get_uint(pe, "krbMinPwdLife"); + + policy->max_pwd_life = slapi_entry_attr_get_int(pe, "krbMaxPwdLife"); + +diff --git a/ipaserver/plugins/pwpolicy.py b/ipaserver/plugins/pwpolicy.py +index e49a2e1cd..2d9907d9d 100644 +--- a/ipaserver/plugins/pwpolicy.py ++++ b/ipaserver/plugins/pwpolicy.py +@@ -338,6 +338,7 @@ class pwpolicy(LDAPObject): + label=_('Min lifetime (hours)'), + doc=_('Minimum password lifetime (in hours)'), + minvalue=0, ++ maxvalue=480000, # about 54 years, same as maxlife + ), + Int('krbpwdhistorylength?', + cli_name='history', +diff --git a/ipatests/test_integration/test_pwpolicy.py b/ipatests/test_integration/test_pwpolicy.py +index a627b66ce..f7fd40ada 100644 +--- a/ipatests/test_integration/test_pwpolicy.py ++++ b/ipatests/test_integration/test_pwpolicy.py +@@ -16,17 +16,14 @@ PASSWORD = 'Secret123' + POLICY = 'test' + + +-class TestPWPolicy(IntegrationTest): ++class BasePWpolicy(IntegrationTest): + """ +- Test password policy in action. ++ Base class for testing password policies including libpwquality + """ +- num_replicas = 1 +- +- topology = 'line' + + @classmethod + def install(cls, mh): +- super(TestPWPolicy, cls).install(mh) ++ tasks.install_master(cls.master, setup_dns=True) + + tasks.kinit_admin(cls.master) + cls.master.run_command(['ipa', 'user-add', USER, +@@ -66,6 +63,15 @@ class TestPWPolicy(IntegrationTest): + host.run_command(['ipa', 'user-unlock', user]) + tasks.kdestroy_all(host) + ++ ++class TestPWquality(BasePWpolicy): ++ """ ++ libpwquality tests ++ """ ++ num_replicas = 1 ++ ++ topology = 'line' ++ + def set_pwpolicy(self, minlength=None, maxrepeat=None, maxsequence=None, + dictcheck=None, usercheck=None, minclasses=None, + dcredit=None, ucredit=None, lcredit=None, ocredit=None): +@@ -534,3 +540,35 @@ class TestPWPolicy(IntegrationTest): + self.master, dn, ['passwordgraceusertime',], + ) + assert 'passwordgraceusertime: 0' in result.stdout_text.lower() ++ ++ ++class TestPWpolicy(BasePWpolicy): ++ """ ++ Tests for original/Kerberos password policies. Excludes libpwquality ++ """ ++ ++ # NOTE: set/reset/clear methods to be added later once there is more ++ # than a single test. ++ ++ def test_minlife_overflow(self): ++ """Test that a large minlife doesn't overflow an unsigned int.""" ++ newpassword = "Secret.1234" ++ ++ tasks.kinit_admin(self.master) ++ ++ self.master.run_command( ++ ["ipa", "pwpolicy-mod", POLICY, "--minlife", "480000", ++ "--maxlife", "20000",] ++ ) ++ ++ self.kinit_as_user(self.master, PASSWORD, PASSWORD) ++ ++ result = self.master.run_command( ++ ["ipa", "passwd", USER], ++ raiseonerr=False, ++ stdin_text='{password}\n{password}\n{newpassword}'.format( ++ password=PASSWORD, newpassword=newpassword ++ )) ++ ++ assert result.returncode == 1 ++ assert "Too soon to change password" in result.stderr_text +diff --git a/util/ipa_pwd.c b/util/ipa_pwd.c +index ba6860106..56135aaff 100644 +--- a/util/ipa_pwd.c ++++ b/util/ipa_pwd.c +@@ -561,6 +561,7 @@ int ipapwd_check_policy(struct ipapwd_policy *policy, + * policy is set */ + + if (cur_time < last_pwd_change + policy->min_pwd_life) { ++ syslog(LOG_ERR, "Password too young. %d seconds since last change, policy requires %u seconds.\n", (cur_time - last_pwd_change), policy->min_pwd_life); + return IPAPWD_POLICY_PWD_TOO_YOUNG; + } + } +diff --git a/util/ipa_pwd.h b/util/ipa_pwd.h +index aa2c6e978..d3c5f8be7 100644 +--- a/util/ipa_pwd.h ++++ b/util/ipa_pwd.h +@@ -54,7 +54,7 @@ enum ipapwd_error { + }; + + struct ipapwd_policy { +- int min_pwd_life; ++ unsigned int min_pwd_life; + int max_pwd_life; + int min_pwd_length; + int history_length; +-- +2.52.0 + diff --git a/0023-ipatests-fix-install-method-for-BasePWpolicy.patch b/0023-ipatests-fix-install-method-for-BasePWpolicy.patch new file mode 100644 index 0000000..e4a0aaa --- /dev/null +++ b/0023-ipatests-fix-install-method-for-BasePWpolicy.patch @@ -0,0 +1,42 @@ +From 5cd2639f539ce220c291b00afafa72fd35e1d07e Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Mon, 16 Feb 2026 15:47:32 +0100 +Subject: [PATCH] ipatests: fix install method for BasePWpolicy + +The test was broken by the previous commit, which creates a +new test class TestPWquality that inherits from BasePWpolicy. + +As BasePWpolicy overrides the install method with +task.install_master instead of super(TestPWPolicy, cls).install(mh), +only the master gets installed. + +Fix the BasePWpolicy class. + +Fixes: https://pagure.io/freeipa/issue/9946 +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: David Hanina +--- + ipatests/test_integration/test_pwpolicy.py | 5 ++++- + 1 file changed, 4 insertions(+), 1 deletion(-) + +diff --git a/ipatests/test_integration/test_pwpolicy.py b/ipatests/test_integration/test_pwpolicy.py +index f7fd40ada..a68252bf9 100644 +--- a/ipatests/test_integration/test_pwpolicy.py ++++ b/ipatests/test_integration/test_pwpolicy.py +@@ -21,9 +21,12 @@ class BasePWpolicy(IntegrationTest): + Base class for testing password policies including libpwquality + """ + ++ num_replicas = 0 ++ topology = 'line' ++ + @classmethod + def install(cls, mh): +- tasks.install_master(cls.master, setup_dns=True) ++ super(BasePWpolicy, cls).install(mh) + + tasks.kinit_admin(cls.master) + cls.master.run_command(['ipa', 'user-add', USER, +-- +2.52.0 + diff --git a/0024-webui-tests-update-expected-max-value-for-krbminpwdl.patch b/0024-webui-tests-update-expected-max-value-for-krbminpwdl.patch new file mode 100644 index 0000000..6c724e7 --- /dev/null +++ b/0024-webui-tests-update-expected-max-value-for-krbminpwdl.patch @@ -0,0 +1,36 @@ +From 8a7486bc980b7f9b19e6b15dee6642679198c40f Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Mon, 16 Feb 2026 16:18:06 +0100 +Subject: [PATCH] webui tests: update expected max value for + krbminpwdlife + +The Maximum allowed value for krbminpwdlife has been changed +by a recent commit to 480000. +Update the test to be consistent. + +Fixes: https://pagure.io/freeipa/issue/9947 +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Carla Martinez +Reviewed-By: David Hanina +--- + ipatests/test_webui/test_pwpolicy.py | 4 ++++ + 1 file changed, 4 insertions(+) + +diff --git a/ipatests/test_webui/test_pwpolicy.py b/ipatests/test_webui/test_pwpolicy.py +index 74484ab1b..d84bcea41 100644 +--- a/ipatests/test_webui/test_pwpolicy.py ++++ b/ipatests/test_webui/test_pwpolicy.py +@@ -151,6 +151,10 @@ class test_pwpolicy(UI_driver): + elif field == 'krbpwdmindiffchars': + self.check_expected_error(field, 'Maximum value is 5', + maximum_value) ++ # verifying if field value is more than 480000 ++ elif field == 'krbminpwdlife': ++ self.check_expected_error(field, 'Maximum value is 480000', ++ maximum_value) + # verifying if field value is more than 2147483647 + else: + self.check_expected_error(field, 'Maximum value is 2147483647', +-- +2.52.0 + diff --git a/0025-ipatests-Add-DNS-integration-tests.patch b/0025-ipatests-Add-DNS-integration-tests.patch new file mode 100644 index 0000000..25f3729 --- /dev/null +++ b/0025-ipatests-Add-DNS-integration-tests.patch @@ -0,0 +1,1123 @@ +From 69f9978dc8bfe33b0f02cfb62a7d9d6a6051657c Mon Sep 17 00:00:00 2001 +From: PRANAV THUBE +Date: Thu, 12 Feb 2026 10:54:17 +0530 +Subject: [PATCH] ipatests: Add DNS integration tests. + +Tests cover LDAP connection handling, PTR synchronization, +update policies, record validation, and zone management fixes. + +Fixes: https://pagure.io/freeipa/issue/9911 +Reviewed-By: Florence Blanc-Renaud +Reviewed-By: David Hanina +Reviewed-By: Sudhir Menon +--- + ipatests/test_integration/test_dns.py | 1078 ++++++++++++++++++++++++- + 1 file changed, 1076 insertions(+), 2 deletions(-) + +diff --git a/ipatests/test_integration/test_dns.py b/ipatests/test_integration/test_dns.py +index 6840a10d1..4b9ab1fe8 100644 +--- a/ipatests/test_integration/test_dns.py ++++ b/ipatests/test_integration/test_dns.py +@@ -6,12 +6,11 @@ + from __future__ import absolute_import + + import time +- + import dns.exception + import dns.resolver + from ipapython.dn import DN + from ipapython.dnsutil import DNSResolver +-from ipatests.pytest_ipa.integration import tasks ++from ipatests.pytest_ipa.integration import tasks, skip_if_fips + from ipatests.test_integration.base import IntegrationTest + + # ============================================================================= +@@ -71,6 +70,35 @@ PTR_TTL = 59 + NEW_TXT = "newip=5.6.7.8" + NEWER_TXT = "newip=8.7.6.5" + ++# TSIG key configuration for dynamic DNS updates (hmac-md5) ++KEY_NAME = "selfupdate" ++KEY_SECRET = "05Fu1ACKv1/1Ag==" ++KEY_CONFIG = f'''key {KEY_NAME} {{ ++ algorithm hmac-md5; ++ secret "{KEY_SECRET}"; ++}}; ++''' ++ ++# nsupdate template for deleting A record ++NSUPDATE_DELETE_A_TEMPLATE = """debug ++update delete {hostname} IN A {ip} ++send ++""" ++ ++# nsupdate template for adding AAAA record ++NSUPDATE_ADD_AAAA_TEMPLATE = """debug ++update add {hostname} {ttl} IN AAAA {ip} ++send ++""" ++ ++# nsupdate template for adding A record with TSIG key ++NSUPDATE_ADD_A_WITH_KEY_TEMPLATE = """server {server} ++zone {zone} ++key {key_name} {key_secret} ++update add {hostname} {ttl} IN A {ip} ++send ++""" ++ + + class TestDNS(IntegrationTest): + """Tests for DNS feature. +@@ -1793,3 +1821,1049 @@ class TestDNSMisc(IntegrationTest): + self.master.run_command([ + '/sbin/ip', '-6', 'addr', 'del', temp_ipv6, 'dev', eth + ], raiseonerr=False) ++ ++ @skip_if_fips(reason='hmac-md5 not supported in FIPS mode') ++ def test_updatepolicy_zonesub(self): ++ """Test BIND with bind-dyndb-ldap works with zonesub updatepolicy. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=921167 ++ ++ This test verifies that dynamic DNS updates work correctly when ++ using the 'zonesub' match type in the update policy. Note that ++ this test requires hmac-md5 which is not supported in FIPS mode. ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ realm = self.master.domain.realm ++ ++ # Get IP address components for test record ++ ip_parts = self.master.ip.split('.') ++ test_ip = f'{ip_parts[0]}.{ip_parts[1]}.{ip_parts[2]}.100' ++ ++ # Backup named.conf ++ self.master.run_command([ ++ 'cp', '/etc/named.conf', '/etc/named.conf.bak' ++ ]) ++ ++ try: ++ # Add key to named.conf for dynamic updates ++ self.master.run_command([ ++ 'sh', '-c', ++ f'echo \'{KEY_CONFIG}\' >> /etc/named.conf' ++ ]) ++ ++ # Update zone policy to include zonesub match type ++ update_policy = ( ++ f'grant {realm} krb5-self * A; ' ++ f'grant {realm} krb5-self * AAAA; ' ++ f'grant {realm} krb5-self * SSHFP; ' ++ 'grant selfupdate zonesub A;' ++ ) ++ tasks.mod_dns_zone( ++ self.master, domain, f'--update-policy={update_policy}' ++ ) ++ ++ # Restart named to pick up changes ++ tasks.restart_named(self.master) ++ ++ # Create nsupdate commands file ++ nsupdate_content = NSUPDATE_ADD_A_WITH_KEY_TEMPLATE.format( ++ server=self.master.hostname, ++ zone=domain, ++ key_name=KEY_NAME, ++ key_secret=KEY_SECRET, ++ hostname=f'foobz921167.{domain}.', ++ ttl=60, ++ ip=test_ip ++ ) ++ self.master.put_file_contents( ++ '/tmp/dnsupdate.txt', nsupdate_content ++ ) ++ ++ # Execute nsupdate with zonesub policy and verify NOERROR ++ result = self.master.run_command([ ++ 'nsupdate', '-v', '-D', '/tmp/dnsupdate.txt' ++ ]) ++ assert 'NOERROR' in result.stdout_text, \ ++ 'Dynamic update did not return NOERROR' ++ ++ finally: ++ # Restore original named.conf ++ self.master.run_command([ ++ 'cp', '/etc/named.conf.bak', '/etc/named.conf' ++ ], raiseonerr=False) ++ ++ # Restore original update policy ++ original_policy = ( ++ f'grant {realm} krb5-self * A; ' ++ f'grant {realm} krb5-self * AAAA; ' ++ f'grant {realm} krb5-self * SSHFP;' ++ ) ++ tasks.mod_dns_zone( ++ self.master, domain, f'--update-policy={original_policy}', ++ raiseonerr=False ++ ) ++ ++ # Cleanup test record if it was created ++ tasks.del_dns_record( ++ self.master, domain, 'foobz921167', ++ del_all=True, raiseonerr=False ++ ) ++ ++ # Restart named ++ tasks.restart_named(self.master) ++ ++ def test_bind_shutdown_ldap_failure(self): ++ """Test BIND shuts down correctly when psearch enabled and LDAP fails. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=802375 ++ ++ This test verifies that named service can be stopped cleanly ++ even when LDAP connection is configured with an invalid URI. ++ The test modifies named.conf to point to an invalid LDAP URI, ++ then verifies that 'rndc stop' works correctly. ++ """ ++ tasks.kinit_admin(self.master) ++ ++ # Get realm name with dashes instead of dots for socket path ++ realm_inst = self.master.domain.realm.replace('.', '-') ++ ++ try: ++ # Check named is running ++ assert tasks.host_service_active(self.master, 'named') ++ ++ # Backup named.conf ++ self.master.run_command(['cp', '/etc/named.conf', '/root/']) ++ ++ # Comment out the valid LDAP URI and add invalid one ++ # The valid URI looks like: ++ # uri "ldapi://%2fvar%2frun%2fslapd-REALM.socket"; ++ sed_pattern = ( ++ f's|uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";' ++ f'|#uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";|g' ++ ) ++ self.master.run_command([ ++ 'sed', '-i', sed_pattern, '/etc/named.conf' ++ ]) ++ ++ # Add invalid LDAP URI before the commented line ++ insert_pattern = ( ++ f'/ldapi:\\/\\/%2fvar%2frun%2fslapd-{realm_inst}.socket/i ' ++ 'uri "ldapi://127.0.0.1";' ++ ) ++ self.master.run_command([ ++ 'sed', '-i', insert_pattern, '/etc/named.conf' ++ ]) ++ ++ # Restart named with invalid LDAP config ++ tasks.restart_named(self.master) ++ ++ # Try to stop named with rndc - should work without hanging ++ self.master.run_command(['rndc', 'stop'], raiseonerr=False) ++ ++ # Wait a moment for service to stop ++ time.sleep(5) ++ ++ # Verify named is not running (exit code 3 = inactive) ++ result = self.master.run_command( ++ ['systemctl', 'is-active', 'named'], raiseonerr=False ++ ) ++ assert result.returncode != 0, "named should be stopped" ++ ++ finally: ++ # Restore original named.conf ++ # Remove the invalid URI line ++ self.master.run_command([ ++ 'sed', '-i', '/ldapi:\\/\\/127.0.0.1/d', '/etc/named.conf' ++ ], raiseonerr=False) ++ ++ # Uncomment the original valid URI ++ uncomment_pattern = ( ++ f's|#uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";' ++ f'|uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";|g' ++ ) ++ self.master.run_command([ ++ 'sed', '-i', uncomment_pattern, '/etc/named.conf' ++ ], raiseonerr=False) ++ ++ # Restart named to restore normal operation ++ tasks.restart_named(self.master) ++ ++ def test_bind_ldap_reconnect(self): ++ """Test reconnect to LDAP when the first connection fails. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=767489 ++ ++ This test verifies that named service can handle LDAP connection ++ failures gracefully. It modifies named.conf to use an invalid LDAP ++ URI while blocking LDAP ports with iptables, then restarts named ++ to verify it handles the reconnection properly when connectivity ++ is restored. ++ """ ++ tasks.kinit_admin(self.master) ++ ++ # Get realm name with dashes instead of dots for socket path ++ realm_inst = self.master.domain.realm.replace('.', '-') ++ ++ # Firewall rich rules to block LDAP ports ++ ldap_reject = 'rule family=ipv4 port port=389 protocol=tcp reject' ++ ldaps_reject = 'rule family=ipv4 port port=636 protocol=tcp reject' ++ ++ try: ++ # Check named is running ++ assert tasks.host_service_active(self.master, 'named') ++ ++ # Backup named.conf ++ self.master.run_command(['cp', '/etc/named.conf', '/root/']) ++ ++ # Comment out the valid LDAP URI and add invalid one ++ sed_pattern = ( ++ f's|uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";' ++ f'|#uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";|g' ++ ) ++ self.master.run_command([ ++ 'sed', '-i', sed_pattern, '/etc/named.conf' ++ ]) ++ ++ # Add invalid LDAP URI before the commented line ++ insert_pattern = ( ++ f'/ldapi:\\/\\/%2fvar%2frun%2fslapd-{realm_inst}.socket/i ' ++ 'uri "ldapi://127.0.0.1";' ++ ) ++ self.master.run_command([ ++ 'sed', '-i', insert_pattern, '/etc/named.conf' ++ ]) ++ ++ # Block LDAP ports with firewall-cmd to ensure connection fails ++ self.master.run_command([ ++ 'firewall-cmd', f'--add-rich-rule={ldap_reject}' ++ ]) ++ self.master.run_command([ ++ 'firewall-cmd', f'--add-rich-rule={ldaps_reject}' ++ ]) ++ ++ # Restart named - it should handle the LDAP connection failure ++ tasks.restart_named(self.master) ++ ++ # Verify named is still running (may be in degraded state) ++ result = self.master.run_command( ++ ['systemctl', 'is-active', 'named'], raiseonerr=False ++ ) ++ # Named may be active or activating depending on timing ++ assert result.returncode == 0 or 'activating' in result.stdout_text ++ ++ finally: ++ # Remove firewall rules to restore LDAP connectivity ++ self.master.run_command([ ++ 'firewall-cmd', f'--remove-rich-rule={ldap_reject}' ++ ], raiseonerr=False) ++ self.master.run_command([ ++ 'firewall-cmd', f'--remove-rich-rule={ldaps_reject}' ++ ], raiseonerr=False) ++ ++ # Restore original named.conf ++ # Remove the invalid URI line ++ self.master.run_command([ ++ 'sed', '-i', '/ldapi:\\/\\/127.0.0.1/d', '/etc/named.conf' ++ ], raiseonerr=False) ++ ++ # Uncomment the original valid URI ++ uncomment_pattern = ( ++ f's|#uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";' ++ f'|uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";|g' ++ ) ++ self.master.run_command([ ++ 'sed', '-i', uncomment_pattern, '/etc/named.conf' ++ ], raiseonerr=False) ++ ++ # Restart named to restore normal operation ++ tasks.restart_named(self.master) ++ ++ def test_dnsrecord_mod_nonexistent_error(self): ++ """Test correct error message when modifying nonexistent DNS record. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=856281 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ ++ # Try to modify a non-existent record ++ result = tasks.mod_dns_record( ++ self.master, domain, 'this.does.not.exist', ++ '--txt-rec=foo', raiseonerr=False ++ ) ++ ++ assert result.returncode != 0 ++ expected_msg = "this.does.not.exist: DNS resource record not found" ++ assert expected_msg in result.stderr_text ++ ++ def test_zone_without_update_policy(self): ++ """Test zone without idnsUpdatePolicy works during zone refresh. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=908780 ++ ++ As per bind-dyndb-ldap/NEWS, psearch, serial_autoincrement and ++ zone_refresh were deprecated and removed. This test verifies ++ that zones without idnsUpdatePolicy still work correctly after ++ service restart. ++ """ ++ tasks.kinit_admin(self.master) ++ zone = "bz908780zone" ++ ++ try: ++ # Add test zone ++ tasks.add_dns_zone( ++ self.master, zone, ++ admin_email=self.EMAIL ++ ) ++ ++ # Modify zone to remove idnsUpdatePolicy attribute ++ tasks.mod_dns_zone(self.master, zone, '--update-policy=') ++ ++ # Restart named service ++ tasks.restart_named(self.master) ++ ++ # Check logs for error message (use journalctl for compatibility) ++ result = self.master.run_command([ ++ 'journalctl', '-u', 'named', '-n', '100', '--no-pager' ++ ]) ++ ++ # Verify no error about zone failing to transfer ++ err_msg = "unchanged. zone may fail to transfer to slaves" ++ assert err_msg not in result.stdout_text ++ ++ finally: ++ tasks.del_dns_zone(self.master, zone, raiseonerr=False) ++ ++ def test_txt_record_with_comma(self): ++ """Test dnsrecord works if TXT record data contains comma. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=910460 ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ txt_value = ( ++ 'Holmes laughed. "It is quite a pretty little problem," ' ++ 'said he.' ++ ) ++ txt_mod_value = ( ++ 'Holmes laughed. "It is quite a pretty little problem," ' ++ 'said me.' ++ ) ++ ++ try: ++ # Add TXT record with comma - should not cause traceback ++ result = tasks.add_dns_record( ++ self.master, domain, '@', 'txt', [txt_value] ++ ) ++ assert result.returncode == 0 ++ ++ # Modify TXT record with comma - should not cause traceback ++ result = tasks.mod_dns_record( ++ self.master, domain, '@', ++ f'--txt-rec={txt_value}', f'--txt-data={txt_mod_value}' ++ ) ++ assert result.returncode == 0 ++ ++ finally: ++ # Delete TXT record ++ tasks.del_dns_record( ++ self.master, domain, '@', ++ record_type='txt', record_value=[txt_mod_value], ++ raiseonerr=False ++ ) ++ ++ def test_dname_single_value(self): ++ """Test DNAME record attribute allows single value only. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=915797 ++ ++ Verify that adding a second DNAME record to the same name fails ++ with appropriate error message. ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ dname1 = f"foo.{domain}" ++ dname2 = f"bar.{domain}" ++ ++ try: ++ # Add first DNAME record ++ tasks.add_dns_record( ++ self.master, domain, 'dnamebz915797', 'dname', [dname1] ++ ) ++ ++ # Try to add second DNAME record - should fail ++ result = tasks.add_dns_record( ++ self.master, domain, 'dnamebz915797', 'dname', [dname2], ++ raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ expected_msg = "only one DNAME record is allowed per name" ++ assert expected_msg in result.stderr_text ++ ++ finally: ++ tasks.del_dns_record( ++ self.master, domain, 'dnamebz915797', ++ del_all=True, raiseonerr=False ++ ) ++ ++ def test_cname_single_value(self): ++ """Test CNAME record allows single value only. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=915807 ++ ++ Verify that adding a second CNAME record to the same name fails ++ with appropriate error message. ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ cname1 = f"foo.{domain}" ++ cname2 = f"bar.{domain}" ++ ++ try: ++ # Add first CNAME record ++ tasks.add_dns_record( ++ self.master, domain, 'cnamebz915807', 'cname', [cname1] ++ ) ++ ++ # Try to add second CNAME record - should fail ++ result = tasks.add_dns_record( ++ self.master, domain, 'cnamebz915807', 'cname', [cname2], ++ raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ expected_msg = "only one CNAME record is allowed per name" ++ assert expected_msg in result.stderr_text ++ ++ finally: ++ tasks.del_dns_record( ++ self.master, domain, 'cnamebz915807', ++ del_all=True, raiseonerr=False ++ ) ++ ++ def test_idnszone_schema_no_cn(self): ++ """Test cn attribute is not present in idnsZone objectClasses. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=947911 ++ ++ Verify that 'cn' attribute is removed from idnsZone objectClasses ++ in LDAP schema. Note: 'cn' was returned back to idnsRecord to fix ++ bug 1167964, so we only check idnsZone. ++ """ ++ tasks.kinit_admin(self.master) ++ ++ # Check that cn attribute is not in idnsZone objectClass ++ # Use ldif-wrap=no to keep each objectClass on a single line ++ result = self.master.run_command([ ++ 'ldapsearch', '-x', '-D', ++ 'cn=Directory Manager', ++ '-w', self.master.config.dirman_password, ++ '-b', 'cn=schema', 'objectClasses', ++ '-o', 'ldif-wrap=no' ++ ]) ++ ++ # Verify idnsZone objectClass exists and doesn't contain cn attribute ++ lines = result.stdout_text.split('\n') ++ is_valid = any( ++ "NAME 'idnsZone'" in line and ' cn ' not in line ++ for line in lines ++ ) ++ assert is_valid, "cn attribute should not be in idnsZone objectClass" ++ ++ def test_ptr_sync_preserves_txt_record(self): ++ """Test PTR sync deletes only PTR record, preserves TXT record. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=958140 ++ ++ When PTR record synchronization is enabled and an A record is ++ deleted via dynamic update, only the corresponding PTR record ++ should be deleted. Other records (like TXT) on the same name ++ in the reverse zone should be preserved. ++ """ ++ tasks.kinit_admin(self.master) ++ test_zone = "examplebz958140.com" ++ reverse_zone = "3.2.1.in-addr.arpa." ++ realm = self.master.domain.realm ++ hostname = f"testbz958140.{test_zone}" ++ ++ try: ++ # Add test zones ++ tasks.add_dns_zone( ++ self.master, test_zone, ++ skip_overlap_check=True, ++ admin_email=f"hostmaster.{test_zone}" ++ ) ++ tasks.add_dns_zone( ++ self.master, reverse_zone, ++ skip_overlap_check=True, ++ admin_email=f"hostmaster.{reverse_zone}" ++ ) ++ ++ # Reload named so it can serve the new zones ++ self.master.run_command(['rndc', 'reload']) ++ ++ # Add A record with reverse PTR ++ tasks.add_dns_record( ++ self.master, test_zone, 'testbz958140', 'a', ['1.2.3.4'], ++ '--a-create-reverse' ++ ) ++ ++ # Add TXT record to reverse zone on same name as PTR ++ tasks.add_dns_record( ++ self.master, reverse_zone, '4', 'txt', ['text'] ++ ) ++ ++ # Enable PTR record synchronization ++ tasks.mod_dns_zone( ++ self.master, test_zone, '--dynamic-update=True' ++ ) ++ tasks.mod_dns_zone( ++ self.master, test_zone, '--allow-sync-ptr=True' ++ ) ++ tasks.mod_dns_zone( ++ self.master, reverse_zone, '--dynamic-update=True' ++ ) ++ ++ # Add host and get keytab for dynamic updates ++ self.master.run_command([ ++ 'ipa', 'host-add', hostname, '--force' ++ ]) ++ self.master.run_command([ ++ 'ipa-getkeytab', '-s', self.master.hostname, ++ '-p', f'host/{hostname}@{realm}', ++ '-k', '/tmp/bz958140.keytab' ++ ]) ++ ++ self.master.run_command(['rndc', 'reload']) ++ self.master.run_command(['ipactl', 'restart']) ++ ++ # Use nsupdate to delete A record (should trigger PTR sync) ++ nsupdate_content = NSUPDATE_DELETE_A_TEMPLATE.format( ++ hostname=hostname, ip='1.2.3.4' ++ ) ++ self.master.put_file_contents('/tmp/nsupdate958140.txt', ++ nsupdate_content) ++ self.master.run_command([ ++ 'kinit', '-k', '-t', '/tmp/bz958140.keytab', ++ f'host/{hostname}' ++ ]) ++ self.master.run_command([ ++ 'nsupdate', '-g', '-v', '/tmp/nsupdate958140.txt' ++ ], raiseonerr=False) ++ ++ tasks.kinit_admin(self.master) ++ ++ # Verify A record is deleted ++ result = tasks.find_dns_record( ++ self.master, test_zone, 'testbz958140', raiseonerr=False ++ ) ++ assert 'A record' not in result.stdout_text ++ ++ # Verify PTR record is deleted ++ result = self.master.run_command([ ++ 'ipa', 'dnsrecord-find', reverse_zone, '4' ++ ], raiseonerr=False) ++ assert 'PTR record' not in result.stdout_text ++ ++ # Verify TXT record is preserved ++ assert 'TXT record' in result.stdout_text ++ ++ finally: ++ tasks.kinit_admin(self.master) ++ self.master.run_command([ ++ 'ipa', 'host-del', hostname ++ ], raiseonerr=False) ++ tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) ++ tasks.del_dns_zone(self.master, reverse_zone, raiseonerr=False) ++ self.master.run_command(['rm', '-f', '/tmp/bz958140.keytab', ++ '/tmp/nsupdate958140.txt'], ++ raiseonerr=False) ++ ++ def test_invalid_policy_disables_operations(self): ++ """Test invalid policy disables updates, transfers, and queries. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=958141 ++ ++ When zone policy (update, transfer, or query) contains invalid ++ values, the corresponding operation should be disabled/refused. ++ """ ++ tasks.kinit_admin(self.master) ++ test_zone = 'example.test' ++ realm = self.master.domain.realm ++ hostname = f'test.{test_zone}' ++ ++ try: ++ # Add test zone and get original update policy ++ tasks.add_dns_zone( ++ self.master, test_zone, ++ skip_overlap_check=True, ++ admin_email=f'hostmaster.{test_zone}' ++ ) ++ ++ # Get original update policy ++ result = self.master.run_command([ ++ 'ipa', 'dnszone-show', test_zone, '--all' ++ ]) ++ original_policy = None ++ for line in result.stdout_text.split('\n'): ++ if 'BIND update policy:' in line: ++ original_policy = line.split(':', 1)[1].strip() ++ break ++ ++ tasks.add_dns_record( ++ self.master, test_zone, 'test', 'a', ['1.2.3.4'] ++ ) ++ ++ # Test 1: Verify dynamic update works with valid policy ++ tasks.mod_dns_zone( ++ self.master, test_zone, '--dynamic-update=True' ++ ) ++ ++ self.master.run_command([ ++ 'ipa', 'host-add', hostname ++ ]) ++ self.master.run_command([ ++ 'ipa-getkeytab', '-s', self.master.hostname, ++ '-p', f'host/{hostname}@{realm}', ++ '-k', '/tmp/bz958141.keytab' ++ ]) ++ ++ self.master.run_command(['rndc', 'reload']) ++ ++ # Create nsupdate file to delete A record ++ nsupdate_content = NSUPDATE_DELETE_A_TEMPLATE.format( ++ hostname=hostname, ip='1.2.3.4' ++ ) ++ self.master.put_file_contents('/tmp/nsupdate958141.txt', ++ nsupdate_content) ++ ++ self.master.run_command([ ++ 'kinit', '-k', '-t', '/tmp/bz958141.keytab', ++ f'host/{hostname}' ++ ]) ++ ++ # Dynamic update should succeed with valid policy ++ result = self.master.run_command([ ++ 'nsupdate', '-g', '-v', '/tmp/nsupdate958141.txt' ++ ], raiseonerr=False) ++ assert result.returncode == 0, \ ++ 'Dynamic update should succeed with valid policy' ++ ++ # Set invalid update policy ++ tasks.kinit_admin(self.master) ++ tasks.mod_dns_zone( ++ self.master, test_zone, '--update-policy=invalid value' ++ ) ++ ++ # Re-add A record for next test ++ tasks.add_dns_record( ++ self.master, test_zone, 'test', 'a', ['1.2.3.4'] ++ ) ++ ++ # Dynamic update should fail with invalid policy ++ self.master.run_command([ ++ 'kinit', '-k', '-t', '/tmp/bz958141.keytab', ++ f'host/{hostname}' ++ ]) ++ result = self.master.run_command([ ++ 'nsupdate', '-g', '/tmp/nsupdate958141.txt' ++ ], raiseonerr=False) ++ assert result.returncode == 2, \ ++ 'Dynamic update should fail with invalid policy' ++ ++ # Restore original update policy ++ tasks.kinit_admin(self.master) ++ tasks.mod_dns_zone( ++ self.master, test_zone, ++ f'--update-policy={original_policy}' ++ ) ++ ++ # Test 2: Invalid transfer policy disables zone transfers ++ tasks.mod_dns_zone( ++ self.master, test_zone, '--allow-transfer=any' ++ ) ++ ++ # Verify zone transfer works with valid policy ++ result = self.master.run_command([ ++ 'dig', '@127.0.0.1', '-t', 'AXFR', test_zone ++ ], raiseonerr=False) ++ assert 'Transfer failed' not in result.stdout_text ++ ++ # Set invalid transfer policy via LDAP ++ ldap = self.master.ldap_connect() ++ zone_dn = DN( ++ ('idnsname', f'{test_zone}.'), ++ ('cn', 'dns'), ++ self.master.domain.basedn ++ ) ++ entry = ldap.get_entry(zone_dn) ++ entry['idnsAllowTransfer'] = ['192.0.2..0/24'] # Invalid ++ ldap.update_entry(entry) ++ ++ # Verify zone transfer fails with invalid policy ++ result = self.master.run_command([ ++ 'dig', '@127.0.0.1', '-t', 'AXFR', test_zone ++ ], raiseonerr=False) ++ assert 'Transfer failed' in result.stdout_text ++ ++ # Restore valid transfer policy ++ tasks.mod_dns_zone( ++ self.master, test_zone, '--allow-transfer=none' ++ ) ++ ++ # Test 3: Invalid query policy disables queries ++ # First verify query works with valid policy ++ result = self.master.run_command([ ++ 'dig', f'test.{test_zone}' ++ ], raiseonerr=False) ++ assert 'NOERROR' in result.stdout_text ++ ++ # Set invalid query policy via LDAP ++ entry = ldap.get_entry(zone_dn) ++ entry['idnsAllowQuery'] = ['192.0.2..0/24'] # Invalid ++ ldap.update_entry(entry) ++ ++ # Verify query is refused ++ result = self.master.run_command([ ++ 'dig', f'test.{test_zone}' ++ ], raiseonerr=False) ++ assert 'REFUSED' in result.stdout_text ++ ++ # Restore valid query policy ++ tasks.mod_dns_zone( ++ self.master, test_zone, '--allow-query=any' ++ ) ++ ++ finally: ++ tasks.kinit_admin(self.master) ++ self.master.run_command([ ++ 'ipa', 'host-del', hostname ++ ], raiseonerr=False) ++ tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) ++ self.master.run_command([ ++ 'rm', '-f', '/tmp/bz958141.keytab', '/tmp/nsupdate958141.txt' ++ ], raiseonerr=False) ++ ++ def test_ptr_sync_ipv6(self): ++ """Test PTR record synchronization works with IPv6 addresses. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=962814 ++ ++ Verify that when AAAA record is added via dynamic update with ++ PTR sync enabled, the corresponding PTR record is created in ++ the IPv6 reverse zone. ++ """ ++ tasks.kinit_admin(self.master) ++ test_zone = "examplebz962814.com" ++ # Reverse zone for 1:2:3:4:5:6::/96 ++ reverse_zone = ( ++ "6.0.0.0.5.0.0.0.4.0.0.0.3.0.0.0.2.0.0.0.1.0.0.0.ip6.arpa." ++ ) ++ realm = self.master.domain.realm ++ hostname = f"test.{test_zone}" ++ ++ try: ++ # Add test zones ++ tasks.add_dns_zone( ++ self.master, test_zone, ++ skip_overlap_check=True, ++ admin_email=f"hostmaster.{test_zone}" ++ ) ++ tasks.add_dns_zone( ++ self.master, reverse_zone, ++ skip_overlap_check=True, ++ admin_email=f"hostmaster.{reverse_zone}" ++ ) ++ ++ # Enable PTR record synchronization ++ tasks.mod_dns_zone( ++ self.master, test_zone, '--dynamic-update=True' ++ ) ++ tasks.mod_dns_zone( ++ self.master, test_zone, '--allow-sync-ptr=True' ++ ) ++ tasks.mod_dns_zone( ++ self.master, reverse_zone, '--dynamic-update=True' ++ ) ++ ++ # Add host and get keytab ++ self.master.run_command([ ++ 'ipa', 'host-add', hostname, '--force' ++ ]) ++ self.master.run_command([ ++ 'ipa-getkeytab', '-s', self.master.hostname, ++ '-p', f'host/{hostname}@{realm}', ++ '-k', '/tmp/bz962814.keytab' ++ ]) ++ ++ self.master.run_command(['rndc', 'reload']) ++ ++ # Use nsupdate to add AAAA record ++ nsupdate_content = NSUPDATE_ADD_AAAA_TEMPLATE.format( ++ hostname=hostname, ttl=3600, ip='1:2:3:4:5:6:7:8' ++ ) ++ self.master.put_file_contents('/tmp/nsupdate962814.txt', ++ nsupdate_content) ++ self.master.run_command([ ++ 'kinit', '-k', '-t', '/tmp/bz962814.keytab', ++ f'host/{hostname}' ++ ]) ++ ++ time.sleep(60) ++ self.master.run_command([ ++ 'nsupdate', '-g', '/tmp/nsupdate962814.txt' ++ ]) ++ ++ tasks.kinit_admin(self.master) ++ ++ # Verify AAAA record was added ++ result = tasks.find_dns_record( ++ self.master, test_zone, 'test', raiseonerr=False ++ ) ++ assert 'AAAA record' in result.stdout_text ++ ++ # Verify PTR record was created in reverse zone ++ # PTR for 1:2:3:4:5:6:7:8 should be at 8.0.0.0.7.0.0.0 ++ result = self.master.run_command([ ++ 'ipa', 'dnsrecord-find', reverse_zone, '8.0.0.0.7.0.0.0' ++ ], raiseonerr=False) ++ assert 'PTR record' in result.stdout_text ++ ++ finally: ++ tasks.kinit_admin(self.master) ++ self.master.run_command([ ++ 'ipa', 'host-del', hostname ++ ], raiseonerr=False) ++ tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) ++ tasks.del_dns_zone(self.master, reverse_zone, raiseonerr=False) ++ self.master.run_command(['rm', '-f', '/tmp/bz962814.keytab', ++ '/tmp/nsupdate962814.txt'], ++ raiseonerr=False) ++ ++ def test_ipv6_private_reverse_zone(self): ++ """Test serving reverse zones for IPv6 private ranges. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=962815 ++ ++ Verify that reverse zones for IPv6 private/documentation ranges ++ (like 2001:db8::/32) can be served without manual changes to ++ named.conf. These are in the automatic empty zones list. ++ """ ++ tasks.kinit_admin(self.master) ++ # 2001:db8::/32 documentation prefix reverse zone ++ reverse_zone = "8.b.d.0.1.0.0.2.ip6.arpa" ++ test_record = "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0" ++ test_ptr = "test.example.com" ++ test_ipv6 = "2001:0db8::1" ++ ++ try: ++ # Add reverse zone for IPv6 documentation prefix ++ tasks.add_dns_zone( ++ self.master, reverse_zone, ++ admin_email=f"hostmaster.{reverse_zone}" ++ ) ++ ++ # Add PTR record ++ tasks.add_dns_record( ++ self.master, f"{reverse_zone}.", test_record, ++ 'ptr', [test_ptr] ++ ) ++ ++ self.master.run_command(['ipactl', 'restart']) ++ ++ # Verify reverse lookup works ++ result = self.master.run_command([ ++ 'dig', '-x', test_ipv6 ++ ]) ++ assert test_record in result.stdout_text ++ assert test_ptr in result.stdout_text ++ ++ # Restart named and verify zone loads ++ tasks.restart_named(self.master) ++ time.sleep(5) ++ ++ # Check logs that zone was loaded without errors ++ result = self.master.run_command([ ++ 'journalctl', '-u', 'named', '-n', '100', '--no-pager' ++ ]) ++ # Verify zone is mentioned in logs (loaded) ++ assert reverse_zone in result.stdout_text, \ ++ f'Zone {reverse_zone} not found in named logs' ++ ++ finally: ++ tasks.del_dns_zone(self.master, reverse_zone, raiseonerr=False) ++ ++ def test_tld_with_numbers(self): ++ """Test DNS record allows top-level domains with numbers. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=907913 ++ ++ Verify that DNS zones and records can be created with TLDs ++ that contain numbers, and NS records can reference them. ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ test_zone = "TBADTEST0" # TLD with number ++ test_record = "TBADTEST" ++ ++ try: ++ # Add zone with numeric TLD ++ tasks.add_dns_zone( ++ self.master, test_zone, ++ admin_email=f"hostmaster.{test_zone}" ++ ) ++ ++ # Add A record in the zone ++ tasks.add_dns_record( ++ self.master, test_zone, test_record, 'a', ['1.2.3.4'] ++ ) ++ ++ self.master.run_command(['ipactl', 'restart']) ++ ++ # Verify NS hostname with numeric TLD is valid ++ result = tasks.add_dns_record( ++ self.master, domain, test_record, 'ns', ++ [f'{test_record}.{test_zone}.'] ++ ) ++ assert result.returncode == 0 ++ ++ # Cleanup NS record ++ tasks.del_dns_record( ++ self.master, domain, test_record, ++ record_type='ns', ++ record_value=[f'{test_record}.{test_zone}.'] ++ ) ++ ++ finally: ++ tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) ++ ++ def test_rfc2317_classless_arpa(self): ++ """Test dnszone-add supports RFC 2317 classless in-addr.arpa. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=1058688 ++ ++ Verify that DNS zones with RFC 2317 classless reverse delegation ++ format (like 0/27.10.10.in-addr.arpa) can be created. ++ """ ++ tasks.kinit_admin(self.master) ++ # RFC 2317 classless delegation format ++ test_zone = "0/27.10.10.in-addr.arpa." ++ ++ try: ++ # Add zone with RFC 2317 format ++ tasks.add_dns_zone( ++ self.master, test_zone, ++ skip_overlap_check=True, ++ admin_email="hostmaster.0.0.10.10.in-addr.arpa." ++ ) ++ ++ # Verify zone was created ++ result = tasks.find_dns_zone(self.master, test_zone) ++ assert result.returncode == 0 ++ assert test_zone in result.stdout_text ++ ++ finally: ++ tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) ++ ++ def test_dnsrecord_mod_no_warning(self): ++ """Test dnsrecord-mod doesn't display API version warning. ++ ++ Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=1054869 ++ ++ Verify that modifying DNS records doesn't show warning message ++ about API version. Also tests that setting txt-rec to empty ++ deletes the record. ++ """ ++ tasks.kinit_admin(self.master) ++ domain = self.master.domain.name ++ record_name = "bz1054869" ++ ++ try: ++ # Add TXT record ++ tasks.add_dns_record( ++ self.master, domain, record_name, 'txt', ['1054869'] ++ ) ++ ++ # Verify record was added ++ result = tasks.show_dns_record( ++ self.master, domain, record_name, '--all', '--raw' ++ ) ++ assert 'txtrecord: 1054869' in result.stdout_text.lower() ++ ++ # Modify record with empty txt-rec (deletes the record) ++ result = tasks.mod_dns_record( ++ self.master, domain, record_name, ++ '--txt-rec=' ++ ) ++ # Verify no API version warning ++ assert 'WARNING: API Version' not in result.stdout_text ++ ++ # Verify record was deleted ++ result = tasks.find_dns_record( ++ self.master, domain, record_name, raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ ++ finally: ++ tasks.del_dns_record( ++ self.master, domain, record_name, ++ del_all=True, raiseonerr=False ++ ) ++ ++ def test_dnszone_disable_enable_and_system_records(self): ++ """Test dnszone-disable, dnszone-enable and dns-update-system-records. ++ ++ This test verifies that: ++ 1. A DNS zone can be disabled and enabled ++ 2. dns-update-system-records works correctly (dry-run and actual) ++ """ ++ tasks.kinit_admin(self.master) ++ test_zone = 'disabletest.test' ++ ++ try: ++ # Add test zone ++ tasks.add_dns_zone( ++ self.master, test_zone, ++ admin_email=f'hostmaster.{test_zone}' ++ ) ++ ++ result = self.master.run_command([ ++ 'ipa', 'dnszone-show', test_zone ++ ]) ++ assert 'Active zone: True' in result.stdout_text ++ ++ # Disable the zone ++ result = self.master.run_command([ ++ 'ipa', 'dnszone-disable', test_zone ++ ]) ++ assert f'Disabled DNS zone "{test_zone}."' in result.stdout_text ++ ++ result = self.master.run_command([ ++ 'ipa', 'dnszone-show', test_zone ++ ]) ++ assert 'Active zone: False' in result.stdout_text ++ ++ # Enable the zone ++ result = self.master.run_command([ ++ 'ipa', 'dnszone-enable', test_zone ++ ]) ++ assert f'Enabled DNS zone "{test_zone}."' in result.stdout_text ++ ++ result = self.master.run_command([ ++ 'ipa', 'dnszone-show', test_zone ++ ]) ++ assert 'Active zone: True' in result.stdout_text ++ ++ # Test dns-update-system-records --dry-run ++ result = self.master.run_command([ ++ 'ipa', 'dns-update-system-records', '--dry-run' ++ ]) ++ assert result.returncode == 0 ++ ++ result = self.master.run_command([ ++ 'ipa', 'dns-update-system-records' ++ ]) ++ assert result.returncode == 0 ++ ++ finally: ++ tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) +-- +2.52.0 + diff --git a/0026-ipatest-make-tests-compatible-with-Pytest-9.patch b/0026-ipatest-make-tests-compatible-with-Pytest-9.patch new file mode 100644 index 0000000..1dae595 --- /dev/null +++ b/0026-ipatest-make-tests-compatible-with-Pytest-9.patch @@ -0,0 +1,42 @@ +From 0bf2a549a8a858c393ef59487bc1d395e5535c07 Mon Sep 17 00:00:00 2001 +From: Stanislav Levin +Date: Tue, 24 Feb 2026 19:44:51 +0300 +Subject: [PATCH] ipatest: make tests compatible with Pytest 9 + +https://docs.pytest.org/en/stable/deprecations.html#applying-a-mark-to-a-fixture-function + +> Applying a mark to a fixture function never had any effect, but it is a common user error. + +Move marks from the fixture to test class. + +Fixes: https://pagure.io/freeipa/issue/9950 +Signed-off-by: Stanislav Levin +Reviewed-By: Florence Blanc-Renaud +--- + ipatests/test_ipapython/test_ldap_cache.py | 3 +-- + 1 file changed, 1 insertion(+), 2 deletions(-) + +diff --git a/ipatests/test_ipapython/test_ldap_cache.py b/ipatests/test_ipapython/test_ldap_cache.py +index c960db027..49724ad2f 100644 +--- a/ipatests/test_ipapython/test_ldap_cache.py ++++ b/ipatests/test_ipapython/test_ldap_cache.py +@@ -20,8 +20,6 @@ def hits_and_misses(cache, hits, misses): + + + @pytest.fixture(scope='class') +-@pytest.mark.tier1 +-@pytest.mark.needs_ipaapi + def class_cache(request): + cache = ipaldap.LDAPCache(api.env.ldap_uri) + hits_and_misses(cache, 0, 0) +@@ -56,6 +54,7 @@ def class_cache(request): + @pytest.mark.usefixtures('class_cache') + @pytest.mark.skip_ipaclient_unittest + @pytest.mark.needs_ipaapi ++@pytest.mark.tier1 + class TestLDAPCache: + + def test_one(self): +-- +2.52.0 + diff --git a/0027-ipatests-Add-ipa-selfservice-BZ-tests-to-xmlrpc.patch b/0027-ipatests-Add-ipa-selfservice-BZ-tests-to-xmlrpc.patch new file mode 100644 index 0000000..7b18408 --- /dev/null +++ b/0027-ipatests-Add-ipa-selfservice-BZ-tests-to-xmlrpc.patch @@ -0,0 +1,154 @@ +From f60df430602db1e3949fa67f3744d40bd9b1d971 Mon Sep 17 00:00:00 2001 +From: Jay Gondaliya +Date: Fri, 27 Feb 2026 15:38:54 +0530 +Subject: [PATCH] ipatests: Add ipa-selfservice BZ tests to xmlrpc +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +-Convert Bugzilla regression tests (BZ 772106, 772675, 747730, 747741, 747720, 747722) from bash to Python and add them to the existing selfservice test file as the TestSelfserviceMisc Declarative class. +-Tests verify that --raw output, empty permissions/attrs, and invalid attrs do not cause internal errors or accidental ACI deletion. +-Use a single selfservice rule (selfservice1) across all BZ tests instead of creating and deleting a separate rule per test case, reducing churn and keeping the tests fast. +-Drop BZ 747693 (selfservice-find --raw) as it is already covered by the existing "Search for 'testself' with --raw" test in the main test_selfservice CRUD class (test 0011). + +Signed-off-by: Jay Gondaliya jgondali@redhat.com +Fixes: https://pagure.io/freeipa/issue/9945 +Assisted-by: Claude noreply@anthropic.com + +Continuation of PR #8190 + +Fixes made: +-Fixed lambda expected checkers — replaced defensive .get("result", {}) chains with direct output["result"] key access. +-Removed redundant delete test case — dropped explicit selfservice_del test, relying solely on cleanup_commands. +-Renamed class TestSelfserviceMisc → test_selfservice_misc. + +Reviewed-By: Rob Crittenden +Reviewed-By: David Hanina +--- + .../test_xmlrpc/test_selfservice_plugin.py | 106 +++++++++++++++++- + 1 file changed, 105 insertions(+), 1 deletion(-) + +diff --git a/ipatests/test_xmlrpc/test_selfservice_plugin.py b/ipatests/test_xmlrpc/test_selfservice_plugin.py +index 02aea35e1..4c5059519 100644 +--- a/ipatests/test_xmlrpc/test_selfservice_plugin.py ++++ b/ipatests/test_xmlrpc/test_selfservice_plugin.py +@@ -28,7 +28,6 @@ import pytest + selfservice1 = u'testself' + invalid_selfservice1 = u'bad+name' + +- + @pytest.mark.tier1 + class test_selfservice(Declarative): + +@@ -290,3 +289,108 @@ class test_selfservice(Declarative): + ), + + ] ++ ++ ++@pytest.mark.tier1 ++class test_selfservice_misc(Declarative): ++ """Bugzilla regression tests for selfservice plugin.""" ++ ++ cleanup_commands = [ ++ ("selfservice_del", [selfservice1], {}), ++ ] ++ ++ tests = [ ++ # BZ 772106: selfservice-add with --raw must not return internal error ++ dict( ++ desc="Create %r with --raw for BZ 772106" % selfservice1, ++ command=( ++ "selfservice_add", ++ [selfservice1], ++ dict(attrs=["l"], raw=True), ++ ), ++ expected=dict( ++ value=selfservice1, ++ summary='Added selfservice "%s"' % selfservice1, ++ result={ ++ "aci": '(targetattr = "l")(version 3.0;acl ' ++ '"selfservice:%s";allow (write) ' ++ 'userdn = "ldap:///self";)' % selfservice1, ++ }, ++ ), ++ ), ++ # BZ 772675: selfservice-mod with --raw must not return internal error ++ dict( ++ desc="Modify %r with --raw for BZ 772675" % selfservice1, ++ command=( ++ "selfservice_mod", ++ [selfservice1], ++ dict(attrs=["mobile"], raw=True), ++ ), ++ expected=dict( ++ value=selfservice1, ++ summary='Modified selfservice "%s"' % selfservice1, ++ result={ ++ "aci": '(targetattr = "mobile")(version 3.0;acl ' ++ '"selfservice:%s";allow (write) ' ++ 'userdn = "ldap:///self";)' % selfservice1, ++ }, ++ ), ++ ), ++ # BZ 747730: selfservice-mod --permissions="" must not delete the entry ++ dict( ++ desc=( ++ "Modify %r with empty permissions for BZ 747730" ++ % selfservice1 ++ ), ++ command=( ++ "selfservice_mod", ++ [selfservice1], ++ dict(permissions=""), ++ ), ++ expected=lambda got, output: True, ++ ), ++ dict( ++ desc="Verify %r still exists after BZ 747730" % selfservice1, ++ command=("selfservice_show", [selfservice1], {}), ++ expected=lambda got, output: ( ++ got is None ++ and output["result"]["aciname"] == selfservice1 ++ ), ++ ), ++ # BZ 747741: selfservice-mod --attrs=badattrs must not delete the entry ++ dict( ++ desc="Modify %r with bad attrs for BZ 747741" % selfservice1, ++ command=( ++ "selfservice_mod", ++ [selfservice1], ++ dict(attrs=["badattrs"]), ++ ), ++ expected=lambda got, output: True, ++ ), ++ dict( ++ desc="Verify %r still exists after BZ 747741" % selfservice1, ++ command=("selfservice_show", [selfservice1], {}), ++ expected=lambda got, output: ( ++ got is None ++ and output["result"]["aciname"] == selfservice1 ++ ), ++ ), ++ # BZ 747720: selfservice-find --permissions="" must not return ++ # internal error ++ dict( ++ desc="BZ 747720: selfservice-find with empty permissions", ++ command=("selfservice_find", [], dict(permissions="")), ++ expected=lambda got, output: ( ++ got is None and isinstance(output["result"], (list, tuple)) ++ ), ++ ), ++ # BZ 747722: selfservice-find --attrs="" must not return ++ # internal error ++ dict( ++ desc="BZ 747722: selfservice-find with empty attrs", ++ command=("selfservice_find", [], dict(attrs="")), ++ expected=lambda got, output: ( ++ got is None and isinstance(output["result"], (list, tuple)) ++ ), ++ ), ++ ] +-- +2.52.0 + diff --git a/0028-Allow-32bit-gid.patch b/0028-Allow-32bit-gid.patch new file mode 100644 index 0000000..67495f4 --- /dev/null +++ b/0028-Allow-32bit-gid.patch @@ -0,0 +1,37 @@ +From 58239f9fbe33408b5cb5c52ba5132f6deb6b8f40 Mon Sep 17 00:00:00 2001 +From: David Hanina +Date: Tue, 10 Mar 2026 10:26:33 +0100 +Subject: [PATCH] Allow 32bit gid + +We should allow 32bit groups, by setting maxvalue we allow that. + +Fixes: https://pagure.io/freeipa/issue/9953 +Signed-off-by: David Hanina +Reviewed-By: Florence Blanc-Renaud +--- + ipaserver/plugins/group.py | 2 ++ + 1 file changed, 2 insertions(+) + +diff --git a/ipaserver/plugins/group.py b/ipaserver/plugins/group.py +index f05a39f69..308e5458c 100644 +--- a/ipaserver/plugins/group.py ++++ b/ipaserver/plugins/group.py +@@ -26,6 +26,7 @@ import re + from ipalib import api + from ipalib import Int, Str, Flag + from ipalib.constants import PATTERN_GROUPUSER_NAME, ERRMSG_GROUPUSER_NAME ++from ipalib.parameters import MAX_UINT32 + from ipalib.plugable import Registry + from .baseldap import ( + add_external_post_callback, +@@ -354,6 +355,7 @@ class group(LDAPObject): + label=_('GID'), + doc=_('GID (use this option to set it manually)'), + minvalue=1, ++ maxvalue=MAX_UINT32, + ), + ipaexternalmember_param, + ) +-- +2.52.0 + diff --git a/0029-ipatests-Add-ipa-selfservice-users-tests-to-xmlrpc.patch b/0029-ipatests-Add-ipa-selfservice-users-tests-to-xmlrpc.patch new file mode 100644 index 0000000..aa25ae3 --- /dev/null +++ b/0029-ipatests-Add-ipa-selfservice-users-tests-to-xmlrpc.patch @@ -0,0 +1,400 @@ +From ffae7d109b9cc968bb9942da78ea4238eee5497b Mon Sep 17 00:00:00 2001 +From: Jay Gondaliya +Date: Thu, 5 Mar 2026 19:07:06 +0530 +Subject: [PATCH] ipatests: Add ipa-selfservice users tests to xmlrpc + +Convert self-service users tests from bash to Python and add them to the existing selfservice test file. + +Tests verify that users can modify their own allowed attributes under default and custom selfservice rules, +that disallowed attributes are rejected with ACIError, and that cross-user modification is blocked. +Also covers atomic failure on mixed permissions, self-password-change, and user-find by phone, fax, and manager +(BZ 1188195, 781208, 985016, 967509, 985013). + +Signed-off-by: Jay Gondaliya jgondali@redhat.com +Fixes: https://pagure.io/freeipa/issue/9945 +Assisted-by: Claude noreply@anthropic.com +Reviewed-By: Florence Blanc-Renaud +Reviewed-By: David Hanina +Reviewed-By: PRANAV THUBE +--- + .../test_xmlrpc/test_selfservice_plugin.py | 360 +++++++++++++++++- + 1 file changed, 358 insertions(+), 2 deletions(-) + +diff --git a/ipatests/test_xmlrpc/test_selfservice_plugin.py b/ipatests/test_xmlrpc/test_selfservice_plugin.py +index 4c5059519..e55502a2d 100644 +--- a/ipatests/test_xmlrpc/test_selfservice_plugin.py ++++ b/ipatests/test_xmlrpc/test_selfservice_plugin.py +@@ -21,8 +21,12 @@ + Test the `ipaserver/plugins/selfservice.py` module. + """ + +-from ipalib import errors +-from ipatests.test_xmlrpc.xmlrpc_test import Declarative ++from ipalib import api, errors ++from ipatests.test_xmlrpc.xmlrpc_test import ( ++ Declarative, XMLRPC_test, assert_attr_equal, ++) ++from ipatests.test_xmlrpc.tracker.user_plugin import UserTracker ++from ipatests.util import change_principal, unlock_principal_password + import pytest + + selfservice1 = u'testself' +@@ -394,3 +398,355 @@ class test_selfservice_misc(Declarative): + ), + ), + ] ++ ++ ++SS_USER1 = 'ssuser0001' ++SS_USER1_PASSWORD = 'Passw0rd1' ++SS_USER2 = 'ssuser0002' ++SS_USER2_PASSWORD = 'Passw0rd2' ++SS_GOOD_MANAGER = 'ss_good_manager' ++SS_GOOD_MANAGER_PASSWORD = 'Passw0rd3' ++ ++SS_DEFAULT_SELFSERVICE = 'User Self service' ++SS_CUSTOM_RULE = 'ss_test_rule0001' ++ ++SS_DEFAULT_SELFSERVICE_ATTRS = [ ++ 'givenname', 'sn', 'cn', 'displayname', 'title', 'initials', ++ 'loginshell', 'gecos', 'homephone', 'mobile', 'pager', ++ 'facsimiletelephonenumber', 'telephonenumber', 'street', ++ 'roomnumber', 'l', 'st', 'postalcode', 'manager', 'secretary', ++ 'description', 'carlicense', 'labeleduri', 'inetuserhttpurl', ++ 'seealso', 'employeetype', 'businesscategory', 'ou', ++] ++ ++SS_CUSTOM_RULE_ATTRS = [ ++ 'mobile', 'pager', ++ 'facsimiletelephonenumber', 'telephonenumber', ++] ++ ++ ++def _safe_del_selfservice(name): ++ """Delete a selfservice rule, ignoring NotFound.""" ++ try: ++ api.Command['selfservice_del'](name) ++ except errors.NotFound: ++ pass ++ ++ ++@pytest.fixture ++def custom_selfservice_rule(xmlrpc_setup): ++ """Replace the default selfservice rule with the narrow custom rule.""" ++ api.Command['selfservice_del'](SS_DEFAULT_SELFSERVICE) ++ api.Command['selfservice_add']( ++ SS_CUSTOM_RULE, attrs=SS_CUSTOM_RULE_ATTRS, ++ ) ++ yield ++ _safe_del_selfservice(SS_CUSTOM_RULE) ++ api.Command['selfservice_add']( ++ SS_DEFAULT_SELFSERVICE, attrs=SS_DEFAULT_SELFSERVICE_ATTRS, ++ ) ++ ++ ++@pytest.fixture(scope='class') ++def ss_user1(request, xmlrpc_setup): ++ tracker = UserTracker( ++ name=SS_USER1, givenname='Test', sn='User0001', ++ userpassword=SS_USER1_PASSWORD, ++ ) ++ tracker.make_fixture(request) ++ tracker.make_create_command()() ++ tracker.exists = True ++ unlock_principal_password( ++ SS_USER1, SS_USER1_PASSWORD, SS_USER1_PASSWORD, ++ ) ++ return tracker ++ ++ ++@pytest.fixture(scope='class') ++def ss_user2(request, xmlrpc_setup): ++ tracker = UserTracker( ++ name=SS_USER2, givenname='Test', sn='User0002', ++ userpassword=SS_USER2_PASSWORD, ++ ) ++ tracker.make_fixture(request) ++ tracker.make_create_command()() ++ tracker.exists = True ++ unlock_principal_password( ++ SS_USER2, SS_USER2_PASSWORD, SS_USER2_PASSWORD, ++ ) ++ return tracker ++ ++ ++@pytest.fixture(scope='class') ++def ss_good_manager(request, xmlrpc_setup): ++ tracker = UserTracker( ++ name=SS_GOOD_MANAGER, givenname='Good', sn='Manager', ++ userpassword=SS_GOOD_MANAGER_PASSWORD, ++ ) ++ tracker.make_fixture(request) ++ tracker.make_create_command()() ++ tracker.exists = True ++ unlock_principal_password( ++ SS_GOOD_MANAGER, SS_GOOD_MANAGER_PASSWORD, SS_GOOD_MANAGER_PASSWORD, ++ ) ++ return tracker ++ ++ ++@pytest.mark.tier1 ++@pytest.mark.usefixtures('ss_user1', 'ss_user2', 'ss_good_manager') ++class test_selfservice_users(XMLRPC_test): ++ """Test self-service user attribute modification permissions.""" ++ ++ # usertest_1001: Set all attrs allowed by default self-service rule. ++ def test_set_all_default_selfservice_attrs(self): ++ """Set all attrs allowed by the default self-service rule.""" ++ attrs = { ++ 'givenname': 'Good', ++ 'sn': 'User', ++ 'cn': 'gooduser', ++ 'displayname': 'gooduser', ++ 'initials': 'GU', ++ 'gecos': 'gooduser@good.example.com', ++ 'loginshell': '/bin/bash', ++ 'street': 'Good_Street_Rd', ++ 'l': 'Good_City', ++ 'st': 'Goodstate', ++ 'postalcode': '33333', ++ 'telephonenumber': '333-333-3333', ++ 'mobile': '333-333-3333', ++ 'pager': '333-333-3333', ++ 'facsimiletelephonenumber': '333-333-3333', ++ 'ou': 'good-org', ++ 'title': 'good_admin', ++ 'manager': SS_GOOD_MANAGER, ++ 'carlicense': 'good-3333', ++ } ++ ++ with change_principal(SS_USER1, SS_USER1_PASSWORD): ++ for attr, value in attrs.items(): ++ api.Command['user_mod'](SS_USER1, **{attr: value}) ++ ++ entry = api.Command['user_show'](SS_USER1, all=True)['result'] ++ for attr, value in attrs.items(): ++ assert_attr_equal(entry, attr, value) ++ ++ # usertest_1002: Test that default disallowed attributes are rejected. ++ def test_reject_uidnumber_by_default(self): ++ """uidnumber change is rejected by default.""" ++ with change_principal(SS_USER1, SS_USER1_PASSWORD): ++ with pytest.raises(errors.ACIError): ++ api.Command['user_mod'](SS_USER1, uidnumber=9999) ++ ++ def test_reject_gidnumber_by_default(self): ++ """gidnumber change is rejected by default.""" ++ with change_principal(SS_USER1, SS_USER1_PASSWORD): ++ with pytest.raises(errors.ACIError): ++ api.Command['user_mod'](SS_USER1, gidnumber=9999) ++ ++ def test_reject_homedirectory_by_default(self): ++ """homedirectory change is rejected by default.""" ++ with change_principal(SS_USER1, SS_USER1_PASSWORD): ++ with pytest.raises(errors.ACIError): ++ api.Command['user_mod']( ++ SS_USER1, homedirectory='/home/gooduser') ++ ++ def test_reject_email_by_default(self): ++ """email change is rejected by default.""" ++ with change_principal(SS_USER1, SS_USER1_PASSWORD): ++ with pytest.raises(errors.ACIError): ++ api.Command['user_mod']( ++ SS_USER1, mail='gooduser@good.example.com') ++ ++ # usertest_1003: All attrs rejected when the default rule is deleted. ++ def test_all_attrs_rejected_without_default_rule(self): ++ """All attrs are rejected when the default rule is deleted.""" ++ attrs = { ++ 'givenname': 'Bad', ++ 'sn': 'LUser', ++ 'cn': 'badluser', ++ 'displayname': 'badluser', ++ 'initials': 'BL', ++ 'gecos': 'badluser@bad.example.com', ++ 'loginshell': '/bin/tcsh', ++ 'street': 'Bad_Street_Av', ++ 'l': 'Bad_City', ++ 'st': 'Badstate', ++ 'postalcode': '99999', ++ 'telephonenumber': '999-999-9999', ++ 'mobile': '999-999-9999', ++ 'pager': '999-999-9999', ++ 'facsimiletelephonenumber': '999-999-9999', ++ 'ou': 'bad-org', ++ 'title': 'bad_admin', ++ 'manager': 'admin', ++ 'carlicense': 'bad-9999', ++ } ++ ++ api.Command['selfservice_del'](SS_DEFAULT_SELFSERVICE) ++ try: ++ with change_principal(SS_USER1, SS_USER1_PASSWORD): ++ for attr, value in attrs.items(): ++ with pytest.raises(errors.ACIError): ++ api.Command['user_mod'](SS_USER1, **{attr: value}) ++ finally: ++ api.Command['selfservice_add']( ++ SS_DEFAULT_SELFSERVICE, ++ attrs=SS_DEFAULT_SELFSERVICE_ATTRS, ++ ) ++ ++ # usertest_1004: Custom rule grants write access to its specified attrs. ++ def test_custom_rule_grants_write_access( ++ self, custom_selfservice_rule): ++ """Custom rule grants write access to its specified attrs.""" ++ with change_principal(SS_USER1, SS_USER1_PASSWORD): ++ api.Command['user_mod']( ++ SS_USER1, telephonenumber='777-777-7777') ++ api.Command['user_mod'](SS_USER1, mobile='777-777-7777') ++ api.Command['user_mod'](SS_USER1, pager='777-777-7777') ++ api.Command['user_mod']( ++ SS_USER1, ++ facsimiletelephonenumber='777-777-7777') ++ ++ # usertest_1005: Persisted attrs and user-find by phone, fax, manager. ++ def test_verify_persisted_attrs(self): ++ """Verify attrs set by previous tests are persisted.""" ++ expected = { ++ 'givenname': 'Good', ++ 'sn': 'User', ++ 'cn': 'gooduser', ++ 'displayname': 'gooduser', ++ 'initials': 'GU', ++ 'gecos': 'gooduser@good.example.com', ++ 'loginshell': '/bin/bash', ++ 'street': 'Good_Street_Rd', ++ 'l': 'Good_City', ++ 'st': 'Goodstate', ++ 'postalcode': '33333', ++ 'telephonenumber': '777-777-7777', ++ 'mobile': '777-777-7777', ++ 'pager': '777-777-7777', ++ 'facsimiletelephonenumber': '777-777-7777', ++ 'ou': 'good-org', ++ 'title': 'good_admin', ++ 'carlicense': 'good-3333', ++ } ++ ++ entry = api.Command['user_show'](SS_USER1, all=True)['result'] ++ for attr, value in expected.items(): ++ assert_attr_equal(entry, attr, value) ++ assert_attr_equal(entry, 'manager', SS_GOOD_MANAGER) ++ ++ def test_user_find_by_phone(self): ++ """BZ 1188195: user-find by phone number returns results.""" ++ result = api.Command['user_find']( ++ telephonenumber='777-777-7777') ++ assert result['count'] >= 1 ++ uids = [e['uid'][0] for e in result['result']] ++ assert SS_USER1 in uids ++ ++ def test_user_find_by_fax(self): ++ """BZ 1188195: user-find by fax number returns results.""" ++ result = api.Command['user_find']( ++ facsimiletelephonenumber='777-777-7777') ++ assert result['count'] >= 1 ++ uids = [e['uid'][0] for e in result['result']] ++ assert SS_USER1 in uids ++ ++ def test_user_find_by_manager(self): ++ """BZ 781208: user-find by manager returns matches.""" ++ result = api.Command['user_find']( ++ SS_USER1, manager=SS_GOOD_MANAGER) ++ assert result['count'] >= 1, ( ++ 'BZ 781208: user-find --manager did not find matches' ++ ) ++ uids = [e['uid'][0] for e in result['result']] ++ assert SS_USER1 in uids ++ ++ # usertest_1006: BZ 985016, 967509: user can modify an allowed attr. ++ def test_user_can_modify_allowed_attr(self): ++ """BZ 985016, 967509: user can modify an allowed attr.""" ++ with change_principal(SS_USER1, SS_USER1_PASSWORD): ++ api.Command['user_mod'](SS_USER1, mobile='888-888-8888') ++ entry = api.Command['user_show'](SS_USER1, all=True)['result'] ++ assert_attr_equal(entry, 'mobile', '888-888-8888') ++ ++ # usertest_1007: BZ 985016, 967509: disallowed attribute is rejected. ++ def test_disallowed_attr_rejected_with_custom_rule( ++ self, custom_selfservice_rule): ++ """BZ 985016, 967509: disallowed attribute is rejected.""" ++ with change_principal(SS_USER1, SS_USER1_PASSWORD): ++ with pytest.raises(errors.ACIError): ++ api.Command['user_mod'](SS_USER1, title='Dr') ++ ++ # usertest_1008: user-mod fails atomically on mixed attr permissions. ++ def test_user_mod_atomic_failure_mixed_perms( ++ self, custom_selfservice_rule): ++ """user-mod fails atomically when one attr is disallowed.""" ++ original_title = api.Command['user_show']( ++ SS_USER1)['result'].get('title') ++ with change_principal(SS_USER1, SS_USER1_PASSWORD): ++ with pytest.raises(errors.ACIError): ++ api.Command['user_mod']( ++ SS_USER1, ++ title='notgonnawork', ++ telephonenumber='999-999-9990', ++ ) ++ result = api.Command['user_find']( ++ SS_USER1, telephonenumber='999-999-9990') ++ assert result['count'] == 0, ( ++ 'Phone was changed despite disallowed title in same call' ++ ) ++ after = api.Command['user_show'](SS_USER1)['result'] ++ assert after.get('title') == original_title, ( ++ 'Title was modified despite being disallowed' ++ ) ++ ++ # usertest_1009: BZ 985013: user can change their own password. ++ def test_self_password_change_via_passwd(self): ++ """BZ 985013: user can change their own password via passwd.""" ++ policy = api.Command['pwpolicy_show']()['result'] ++ orig_minlife = policy.get('krbminpwdlife', ('1',))[0] ++ ++ api.Command['pwpolicy_mod'](krbminpwdlife=0) ++ try: ++ with change_principal(SS_USER1, SS_USER1_PASSWORD): ++ api.Command['passwd']( ++ SS_USER1, ++ password='MyN3wP@55', ++ current_password=SS_USER1_PASSWORD, ++ ) ++ # Reset password so the next test can authenticate ++ unlock_principal_password( ++ SS_USER1, 'MyN3wP@55', SS_USER1_PASSWORD, ++ ) ++ finally: ++ api.Command['pwpolicy_mod'](krbminpwdlife=int(orig_minlife)) ++ ++ def test_self_password_change_via_user_mod(self): ++ """BZ 985013: user can change their own password via user_mod.""" ++ policy = api.Command['pwpolicy_show']()['result'] ++ orig_minlife = policy.get('krbminpwdlife', ('1',))[0] ++ ++ api.Command['pwpolicy_mod'](krbminpwdlife=0) ++ try: ++ with change_principal(SS_USER1, SS_USER1_PASSWORD): ++ api.Command['user_mod']( ++ SS_USER1, ++ userpassword='MyN3wP@55', ++ ) ++ finally: ++ api.Command['pwpolicy_mod'](krbminpwdlife=int(orig_minlife)) ++ ++ # usertest_1010: User cannot modify another user's attributes. ++ def test_cross_user_modification_rejected(self): ++ """User cannot modify another user's attributes.""" ++ with change_principal(SS_USER2, SS_USER2_PASSWORD): ++ with pytest.raises(errors.ACIError): ++ api.Command['user_mod'](SS_USER1, mobile='867-5309') ++ ++ def test_verify_cross_user_modification_rejected(self): ++ """Verify attrs did not change after cross-user modification.""" ++ result = api.Command['user_find'](SS_USER1, mobile='867-5309') ++ assert result['count'] == 0, ( ++ 'Mobile was changed by a different user' ++ ) +-- +2.52.0 + diff --git a/0030-ipatests-Fix-test_allow_query_transfer_ipv6-when-IPv.patch b/0030-ipatests-Fix-test_allow_query_transfer_ipv6-when-IPv.patch new file mode 100644 index 0000000..5370afd --- /dev/null +++ b/0030-ipatests-Fix-test_allow_query_transfer_ipv6-when-IPv.patch @@ -0,0 +1,138 @@ +From 313bd8ff118a79dca5aad0b19ec8f69519258f89 Mon Sep 17 00:00:00 2001 +From: PRANAV THUBE +Date: Wed, 11 Mar 2026 20:13:31 +0530 +Subject: [PATCH] ipatests: Fix test_allow_query_transfer_ipv6 when + IPv6 is disabled + +The test was failing in environments where IPv6 is disabled at the +kernel level because it attempted to add a temporary IPv6 address +without first checking if IPv6 is enabled on the interface. + +This fix restructures the test to: +- Check if IPv6 is disabled via sysctl before attempting IPv6 setup +- Always run IPv4 allow-query and allow-transfer tests +- Only run IPv6-related tests when IPv6 is available + +This ensures the test passes in IPv4-only environments while still +providing full coverage when IPv6 is enabled. + +Fixes: https://pagure.io/freeipa/issue/9944 +Signed-off-by: Pranav Thube pthube@redhat.com +Reviewed-By: David Hanina +Reviewed-By: Florence Blanc-Renaud +--- + ipatests/test_integration/test_dns.py | 62 ++++++--------------------- + 1 file changed, 14 insertions(+), 48 deletions(-) + +diff --git a/ipatests/test_integration/test_dns.py b/ipatests/test_integration/test_dns.py +index 4b9ab1fe8..947cff5c0 100644 +--- a/ipatests/test_integration/test_dns.py ++++ b/ipatests/test_integration/test_dns.py +@@ -5,6 +5,7 @@ + + from __future__ import absolute_import + ++import pytest + import time + import dns.exception + import dns.resolver +@@ -1714,13 +1715,12 @@ class TestDNSMisc(IntegrationTest): + tasks.del_dns_zone(self.master, zone, raiseonerr=False) + + def test_allow_query_transfer_ipv6(self): +- """Test allow-query and allow-transfer with IPv4 and IPv6. ++ """Test allow-query and allow-transfer with IPv6. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=701677 + """ + tasks.kinit_admin(self.master) +- zone = "example.com" +- ipv4 = self.master.ip ++ zone = "example6.com" + ipv6_added = False + temp_ipv6 = '2001:0db8:0:f101::1/64' + +@@ -1732,6 +1732,13 @@ class TestDNSMisc(IntegrationTest): + ]) + eth = result.stdout_text.strip() + ++ # Check if IPv6 is disabled on the interface ++ result = self.master.run_command([ ++ 'sysctl', '-n', f'net.ipv6.conf.{eth}.disable_ipv6' ++ ]) ++ if result.stdout_text.strip() == '1': ++ pytest.skip(f"IPv6 is disabled on interface {eth}") ++ + # Add temporary IPv6 if none exists + result = self.master.run_command( + ['ip', 'addr', 'show', 'scope', 'global'], raiseonerr=False +@@ -1754,62 +1761,21 @@ class TestDNSMisc(IntegrationTest): + tasks.add_dns_zone(self.master, zone, skip_overlap_check=True, + admin_email=self.EMAIL) + +- # Test allow-query: IPv4 allowed, IPv6 denied +- tasks.mod_dns_zone( +- self.master, zone, +- f"--allow-query={ipv4};!{ipv6};" +- ) +- result = self.master.run_command( +- ['dig', f'@{ipv4}', '-t', 'soa', zone], raiseonerr=False +- ) +- assert 'ANSWER SECTION' in result.stdout_text +- result = self.master.run_command( +- ['dig', f'@{ipv6}', '-t', 'soa', zone], raiseonerr=False +- ) +- assert 'ANSWER SECTION' not in result.stdout_text +- +- # Test allow-query: IPv6 allowed, IPv4 denied ++ # Test allow-query: IPv6 allowed + tasks.mod_dns_zone( + self.master, zone, +- f"--allow-query={ipv6};!{ipv4};" +- ) +- result = self.master.run_command( +- ['dig', f'@{ipv4}', '-t', 'soa', zone], raiseonerr=False ++ f"--allow-query={ipv6};" + ) +- assert 'ANSWER SECTION' not in result.stdout_text + result = self.master.run_command( + ['dig', f'@{ipv6}', '-t', 'soa', zone], raiseonerr=False + ) + assert 'ANSWER SECTION' in result.stdout_text + +- # Reset allow-query to any +- tasks.mod_dns_zone( +- self.master, zone, "--allow-query=any;" +- ) +- +- # Test allow-transfer: IPv4 allowed, IPv6 denied +- tasks.mod_dns_zone( +- self.master, zone, +- f"--allow-transfer={ipv4};!{ipv6};" +- ) +- result = self.master.run_command( +- ['dig', f'@{ipv4}', zone, 'axfr'], raiseonerr=False +- ) +- assert 'Transfer failed' not in result.stdout_text +- result = self.master.run_command( +- ['dig', f'@{ipv6}', zone, 'axfr'], raiseonerr=False +- ) +- assert 'Transfer failed' in result.stdout_text +- +- # Test allow-transfer: IPv6 allowed, IPv4 denied ++ # Test allow-transfer: IPv6 allowed + tasks.mod_dns_zone( + self.master, zone, +- f"--allow-transfer={ipv6};!{ipv4};" +- ) +- result = self.master.run_command( +- ['dig', f'@{ipv4}', zone, 'axfr'], raiseonerr=False ++ f"--allow-transfer={ipv6};" + ) +- assert 'Transfer failed' in result.stdout_text + result = self.master.run_command( + ['dig', f'@{ipv6}', zone, 'axfr'], raiseonerr=False + ) +-- +2.52.0 + diff --git a/0031-ipatests-Add-XML-RPC-tests-for-i18n-user-attributes.patch b/0031-ipatests-Add-XML-RPC-tests-for-i18n-user-attributes.patch new file mode 100644 index 0000000..3035409 --- /dev/null +++ b/0031-ipatests-Add-XML-RPC-tests-for-i18n-user-attributes.patch @@ -0,0 +1,217 @@ +From c8a832e19699a7bb6ff486055015f033a3137e5f Mon Sep 17 00:00:00 2001 +From: PRANAV THUBE +Date: Mon, 16 Mar 2026 14:29:12 +0530 +Subject: [PATCH] ipatests: Add XML-RPC tests for i18n user attributes +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Add tests for internationalization support in user plugin: +- User creation/deletion with i18n givenname and sn +- Lastname modification with Swedish/European names (13 values) +- Firstname modification with European accented names (4 values) +- Firstname modification with single i18n characters (67 values) + +Test data includes characters like Çándide, Örjan, Éric, ß, ü, etc. + +Related: https://pagure.io/freeipa/issue/9959 +Signed-off-by: Pranav Thube pthube@redhat.com +Reviewed-By: Florence Blanc-Renaud +Reviewed-By: David Hanina +Reviewed-By: Carla Martinez +--- + ipatests/test_xmlrpc/test_i18n_user_plugin.py | 182 ++++++++++++++++++ + 1 file changed, 182 insertions(+) + create mode 100644 ipatests/test_xmlrpc/test_i18n_user_plugin.py + +diff --git a/ipatests/test_xmlrpc/test_i18n_user_plugin.py b/ipatests/test_xmlrpc/test_i18n_user_plugin.py +new file mode 100644 +index 000000000..146ffdc51 +--- /dev/null ++++ b/ipatests/test_xmlrpc/test_i18n_user_plugin.py +@@ -0,0 +1,182 @@ ++# Authors: ++# Pranav Thube ++# ++# Copyright (C) 2026 Red Hat ++# see file 'COPYING' for use and warranty information ++# ++# This program is free software; you can redistribute it and/or modify ++# it under the terms of the GNU General Public License as published by ++# the Free Software Foundation, either version 3 of the License, or ++# (at your option) any later version. ++# ++# This program is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# You should have received a copy of the GNU General Public License ++# along with this program. If not, see . ++ ++""" ++Test the i18n (internationalization) support for user plugin. ++ ++This module tests that IPA correctly handles international characters ++in user attributes such as first name (givenname) and last name (sn). ++""" ++ ++import pytest ++ ++from ipatests.test_xmlrpc.xmlrpc_test import XMLRPC_test ++from ipatests.test_xmlrpc.tracker.user_plugin import UserTracker ++ ++ ++# Test data Users with i18n names ++I18N_USERS = { ++ 'user1': { ++ 'name': 'i18nuser1', ++ 'givenname': 'Çándide', ++ 'sn': 'Rùiz', ++ }, ++ 'user2': { ++ 'name': 'i18nuser2', ++ 'givenname': 'Rôséñe', ++ 'sn': 'zackr', ++ }, ++ 'user3': { ++ 'name': 'i18nuser3', ++ 'givenname': 'Älka', ++ 'sn': 'Màrzella', ++ }, ++ 'user4': { ++ 'name': 'i18nuser4', ++ 'givenname': 'Feâtlëss', ++ 'sn': 'Watérmân', ++ }, ++} ++ ++# CNS test data - Swedish/European last names ++CNS_LASTNAMES = [ ++ 'Oskar', ++ 'Anders', ++ 'Örjan', ++ 'Jonas', ++ 'Ulf', ++ 'Äke', ++ 'Bertold', ++ 'Bruno', ++ 'Didier', ++ 'Éric', ++ 'Jean-Luc', ++ 'Laurent', ++ 'Têko', ++] ++ ++# European names with mixed accents for firstname tests ++EUROPEAN_FIRSTNAMES = [ ++ 'Rôséñel', ++ 'Tàrqùinio', ++ 'PASSWÖRD', ++ 'Nomeuropéen', ++ # Names with special characters (apostrophe, space) ++ "O'Brian", ++ 'Maria José', ++] ++ ++# Firstname test data - Single characters including accented ++# 73 characters total: 26 ASCII A-Z + 47 accented/special characters ++FIRSTNAME_SINGLE_CHARS = [ ++ # ASCII uppercase letters A-Z (26 characters) ++ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', ++ 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', ++ 'U', 'V', 'W', 'X', 'Y', 'Z', ++ # Extended Latin uppercase characters (20 characters) ++ 'À', 'Á', 'Â', 'Ä', 'Ç', 'È', 'É', 'Ê', 'Ë', ++ 'Í', 'Î', 'Ï', 'Ñ', 'Ó', 'Ô', 'Ö', 'Ù', 'Ú', 'Û', 'Ü', ++ # German eszett (1 character) ++ 'ß', ++ # Extended Latin lowercase characters (20 characters) ++ 'à', 'á', 'â', 'ä', 'ç', 'è', 'é', 'ê', 'ë', ++ 'í', 'î', 'ï', 'ñ', 'ó', 'ô', 'ö', 'ù', 'ú', 'û', 'ü', ++ # Nordic characters (4 characters) ++ 'Ø', 'ø', 'Å', 'å', ++ # Polish character (2 characters) ++ 'Ł', 'ł', ++] ++ ++ ++@pytest.fixture(scope='class') ++def i18n_users(request, xmlrpc_setup): ++ """Single fixture providing all i18n test users as a dictionary""" ++ users = {} ++ for user_key, user_data in I18N_USERS.items(): ++ tracker = UserTracker( ++ name=user_data['name'], ++ givenname=user_data['givenname'], ++ sn=user_data['sn'] ++ ) ++ users[user_key] = tracker.make_fixture(request) ++ return users ++ ++ ++@pytest.mark.tier1 ++class TestI18nUser(XMLRPC_test): ++ """ ++ Test i18n (internationalization) support for user plugin. ++ ++ Tests that IPA correctly handles international characters in user ++ attributes such as first name (givenname) and last name (sn). ++ """ ++ ++ ########################################################################## ++ # User Creation Tests ++ ########################################################################## ++ ++ @pytest.mark.parametrize('user_key', I18N_USERS.keys()) ++ def test_add_i18n_user(self, i18n_users, user_key): ++ """Adding i18n user""" ++ i18n_users[user_key].create() ++ ++ @pytest.mark.parametrize('user_key', I18N_USERS.keys()) ++ def test_verify_i18n_user(self, i18n_users, user_key): ++ """Verify i18n user has correct full name""" ++ user = i18n_users[user_key] ++ user.ensure_exists() ++ command = user.make_find_command(uid=user.uid, all=True) ++ result = command() ++ assert result['count'] == 1 ++ entry = result['result'][0] ++ assert I18N_USERS[user_key]['givenname'] in entry['givenname'] ++ assert I18N_USERS[user_key]['sn'] in entry['sn'] ++ ++ ########################################################################## ++ # CNS Tests - Lastname modification with Swedish/European names ++ ########################################################################## ++ ++ @pytest.mark.parametrize('lastname', CNS_LASTNAMES) ++ def test_cns_modify_lastname(self, i18n_users, lastname): ++ """Modify lastname to Swedish/European name""" ++ user = i18n_users['user1'] ++ user.ensure_exists() ++ user.update(dict(sn=lastname)) ++ ++ ########################################################################## ++ # European accented firstname tests ++ ########################################################################## ++ ++ @pytest.mark.parametrize('firstname', EUROPEAN_FIRSTNAMES) ++ def test_european_modify_firstname(self, i18n_users, firstname): ++ """Modify firstname to European accented name""" ++ user = i18n_users['user2'] ++ user.ensure_exists() ++ user.update(dict(givenname=firstname)) ++ ++ ########################################################################## ++ # Firstname Tests - Single character modification ++ ########################################################################## ++ ++ @pytest.mark.parametrize('char', FIRSTNAME_SINGLE_CHARS) ++ def test_firstname_modify_single_char(self, i18n_users, char): ++ """Modify firstname to single character""" ++ user = i18n_users['user3'] ++ user.ensure_exists() ++ user.update(dict(givenname=char)) +-- +2.52.0 + diff --git a/0032-ipatests-Add-selfservice-add-and-selfservice-del-cli.patch b/0032-ipatests-Add-selfservice-add-and-selfservice-del-cli.patch new file mode 100644 index 0000000..638bf40 --- /dev/null +++ b/0032-ipatests-Add-selfservice-add-and-selfservice-del-cli.patch @@ -0,0 +1,229 @@ +From 3056bf8fb27732213591f4c86044ce6980054ec9 Mon Sep 17 00:00:00 2001 +From: Jay Gondaliya +Date: Tue, 10 Mar 2026 19:14:48 +0530 +Subject: [PATCH] ipatests: Add selfservice-add and selfservice-del cli + tests + +Add a new Declarative test class `test_selfservice_cli_add_del` covering CLI-level behaviour of the selfservice-add and selfservice-del commands: + +- add_1002: bad attrs with valid permissions rejects with InvalidSyntax +- add_1003: valid attrs with invalid permissions rejects with ValidationError +- add_1004: valid attrs and permissions with --all --raw succeeds and + returns the raw ACI string (BZ 772106) +- add_1005: bad attrs only rejects with InvalidSyntax +- add_1006: valid attrs only succeeds with default write permission +- del_1001: deleting an existing selfservice rule succeeds +- del_1002: deleting a non-existent rule raises NotFound + +Signed-off-by: Jay Gondaliya +Fixes: https://pagure.io/freeipa/issue/9945 +Assisted-by: Claude noreply@anthropic.com +Reviewed-By: Florence Blanc-Renaud +Reviewed-By: David Hanina +--- + .../test_xmlrpc/test_selfservice_plugin.py | 192 ++++++++++++++++++ + 1 file changed, 192 insertions(+) + +diff --git a/ipatests/test_xmlrpc/test_selfservice_plugin.py b/ipatests/test_xmlrpc/test_selfservice_plugin.py +index e55502a2d..8f2307a20 100644 +--- a/ipatests/test_xmlrpc/test_selfservice_plugin.py ++++ b/ipatests/test_xmlrpc/test_selfservice_plugin.py +@@ -750,3 +750,195 @@ class test_selfservice_users(XMLRPC_test): + assert result['count'] == 0, ( + 'Mobile was changed by a different user' + ) ++ ++ ++# Module-level constants for CLI test classes ++# selfservice-add / selfservice-del CLI tests ++SS_CLI_ADD_1004 = 'selfservice_add_1004' ++SS_CLI_ADD_1006 = 'selfservice_add_1006' ++SS_CLI_DEL_1001 = 'selfservice_del_1001' ++ ++ ++@pytest.mark.tier1 ++class test_selfservice_cli_add_del(Declarative): ++ """CLI tests for selfservice-add and selfservice-del commands.""" ++ ++ cleanup_commands = [ ++ ('selfservice_del', [SS_CLI_ADD_1004], {}), ++ ('selfservice_del', [SS_CLI_ADD_1006], {}), ++ ] ++ ++ tests = [ ++ ++ # add_1002: bad attrs + valid permissions + --all --raw ++ dict( ++ desc='add_1002: selfservice-add with bad attrs, valid permissions,' ++ ' --all --raw', ++ command=( ++ 'selfservice_add', ++ ['selfservice_add_1002'], ++ dict( ++ attrs=['badattr'], ++ permissions='write', ++ all=True, ++ raw=True, ++ ), ++ ), ++ expected=errors.InvalidSyntax( ++ attr=r'targetattr "badattr" does not exist in schema. ' ++ r'Please add attributeTypes "badattr" to ' ++ r'schema if necessary. ' ++ r'ACL Syntax Error(-5):' ++ r'(targetattr = \22badattr\22)' ++ r'(version 3.0;acl ' ++ r'\22selfservice:selfservice_add_1002\22;' ++ r'allow (write) userdn = \22ldap:///self\22;)', ++ ), ++ ), ++ ++ # add_1003: valid attrs + bad permissions + --all --raw ++ dict( ++ desc='add_1003: selfservice-add with valid attrs, bad permissions,' ++ ' --all --raw', ++ command=( ++ 'selfservice_add', ++ ['selfservice_add_1003'], ++ dict( ++ attrs=[ ++ 'telephonenumber', 'mobile', ++ 'pager', 'facsimiletelephonenumber', ++ ], ++ permissions='badperm', ++ all=True, ++ raw=True, ++ ), ++ ), ++ expected=errors.ValidationError( ++ name='permissions', ++ error='"badperm" is not a valid permission', ++ ), ++ ), ++ ++ # add_1004: valid attrs + valid permissions + --all --raw (BZ 772106) ++ # selfservice-add with --raw must not return "internal error" message. ++ dict( ++ desc='add_1004: selfservice-add with valid attrs and permissions,' ++ ' --all --raw (BZ 772106)', ++ command=( ++ 'selfservice_add', ++ [SS_CLI_ADD_1004], ++ dict( ++ attrs=[ ++ 'telephonenumber', 'mobile', ++ 'pager', 'facsimiletelephonenumber', ++ ], ++ permissions='write', ++ all=True, ++ raw=True, ++ ), ++ ), ++ expected=dict( ++ value=SS_CLI_ADD_1004, ++ summary='Added selfservice "%s"' % SS_CLI_ADD_1004, ++ result={ ++ 'aci': ( ++ '(targetattr = "telephonenumber || mobile || pager' ++ ' || facsimiletelephonenumber")' ++ '(version 3.0;acl "selfservice:%s";' ++ 'allow (write) userdn = "ldap:///self";)' ++ % SS_CLI_ADD_1004 ++ ), ++ }, ++ ), ++ ), ++ ++ # add_1005: bad attrs only ++ dict( ++ desc='add_1005: selfservice-add with bad attrs only', ++ command=( ++ 'selfservice_add', ++ ['selfservice_add_1005'], ++ dict(attrs=['badattrs']), ++ ), ++ expected=errors.InvalidSyntax( ++ attr=r'targetattr "badattrs" does not exist in schema. ' ++ r'Please add attributeTypes "badattrs" to ' ++ r'schema if necessary. ' ++ r'ACL Syntax Error(-5):' ++ r'(targetattr = \22badattrs\22)' ++ r'(version 3.0;acl ' ++ r'\22selfservice:selfservice_add_1005\22;' ++ r'allow (write) userdn = \22ldap:///self\22;)', ++ ), ++ ), ++ ++ # add_1006: valid attrs only ++ dict( ++ desc='add_1006: selfservice-add with valid attrs only', ++ command=( ++ 'selfservice_add', ++ [SS_CLI_ADD_1006], ++ dict(attrs=[ ++ 'telephonenumber', 'mobile', ++ 'pager', 'facsimiletelephonenumber', ++ ]), ++ ), ++ expected=dict( ++ value=SS_CLI_ADD_1006, ++ summary='Added selfservice "%s"' % SS_CLI_ADD_1006, ++ result=dict( ++ attrs=[ ++ 'telephonenumber', 'mobile', ++ 'pager', 'facsimiletelephonenumber', ++ ], ++ permissions=['write'], ++ selfaci=True, ++ aciname=SS_CLI_ADD_1006, ++ ), ++ ), ++ ), ++ ++ # Setup for del tests: create the rule that del_1001 will delete. ++ dict( ++ desc=( ++ 'Setup: create %r for selfservice-del tests' ++ % SS_CLI_DEL_1001 ++ ), ++ command=( ++ 'selfservice_add', ++ [SS_CLI_DEL_1001], ++ dict(attrs=['l'], permissions='write'), ++ ), ++ expected=dict( ++ value=SS_CLI_DEL_1001, ++ summary='Added selfservice "%s"' % SS_CLI_DEL_1001, ++ result=dict( ++ attrs=['l'], ++ permissions=['write'], ++ selfaci=True, ++ aciname=SS_CLI_DEL_1001, ++ ), ++ ), ++ ), ++ ++ # del_1001: delete an existing rule ++ dict( ++ desc='del_1001: selfservice-del of an existing rule', ++ command=('selfservice_del', [SS_CLI_DEL_1001], {}), ++ expected=dict( ++ result=True, ++ value=SS_CLI_DEL_1001, ++ summary='Deleted selfservice "%s"' % SS_CLI_DEL_1001, ++ ), ++ ), ++ ++ # del_1002: delete a non-existent rule ++ dict( ++ desc='del_1002: selfservice-del of a non-existent rule', ++ command=('selfservice_del', ['badname'], {}), ++ expected=errors.NotFound( ++ reason='ACI with name "badname" not found', ++ ), ++ ), ++ ++ ] +-- +2.52.0 + diff --git a/0033-ipatests-Additional-tests-for-32BitIdranges.patch b/0033-ipatests-Additional-tests-for-32BitIdranges.patch new file mode 100644 index 0000000..43bdb97 --- /dev/null +++ b/0033-ipatests-Additional-tests-for-32BitIdranges.patch @@ -0,0 +1,421 @@ +From b05586c2a6a81c7121dd40f8d627cd8a2c5908d8 Mon Sep 17 00:00:00 2001 +From: Sudhir Menon +Date: Wed, 18 Mar 2026 16:36:06 +0530 +Subject: [PATCH] ipatests: Additional tests for 32BitIdranges + +Below tests are added + +1. Create ipauser with 32bit id. +2. Create ipagroup with 32Bit id. +3. Create ipauser with 32Bit groupid range. +4. Test ssh login with 32Bit id user. +5. Test that ipauser with 32Bit is replicated. +6. Test that 32Bit idrange is created in IPA-AD trust enviornment. + +Signed-off-by: Sudhir Menon +Reviewed-By: Rob Crittenden +Reviewed-By: Florence Blanc-Renaud +Reviewed-By: David Hanina +--- + .../test_integration/test_32bit_idranges.py | 333 +++++++++++++++--- + 1 file changed, 284 insertions(+), 49 deletions(-) + +diff --git a/ipatests/test_integration/test_32bit_idranges.py b/ipatests/test_integration/test_32bit_idranges.py +index a928628d3..9b91fc618 100644 +--- a/ipatests/test_integration/test_32bit_idranges.py ++++ b/ipatests/test_integration/test_32bit_idranges.py +@@ -4,17 +4,85 @@ + + from __future__ import absolute_import + ++import re ++ + from ipatests.pytest_ipa.integration import tasks + from ipatests.test_integration.base import IntegrationTest + from ipatests.test_integration.test_trust import BaseTestTrust + ++# The tests focus on 32-bit UID/GID creation and replication, ++# SID behavior is not covered in the tests. ++ ++# Range with First Posix ID >= 2^31 is considered a 32-bit range. ++IDRANGE_32BIT_NAME = "{realm}_upper_32bit_range" ++IDRANGE_32BIT_BASE_ID = 1 << 31 # 2147483648 ++ ++ ++def _32bit_idrange_exists(master): ++ """ ++ Return True if an ipa-local range with base ID >= 2^31 already exists. ++ """ ++ result = master.run_command( ++ ["ipa", "idrange-find", "--type", "ipa-local"] ++ ) ++ # Parse all "First Posix ID of the range: in the output' ++ for match in re.finditer( ++ r"First Posix ID of the range:\s*(\d+)", ++ result.stdout_text ++ ): ++ if int(match.group(1)) >= IDRANGE_32BIT_BASE_ID: ++ return True ++ return False ++ ++ ++def _add_32bit_idrange_if_missing(master): ++ """ ++ Create the 32-bit ID range only if it does not already exist. ++ Returns True if the range was added, False if it already existed. ++ """ ++ if _32bit_idrange_exists(master): ++ return False ++ idrange = IDRANGE_32BIT_NAME.format(realm=master.domain.realm) ++ id_length = 10000 ++ rid_base = 300_000_000 ++ secondary_rid_base = 500_000_000 ++ master.run_command( ++ [ ++ "ipa", ++ "idrange-add", ++ idrange, ++ "--base-id", str(IDRANGE_32BIT_BASE_ID), ++ "--range-size", str(id_length), ++ "--rid-base", str(rid_base), ++ "--secondary-rid-base", str(secondary_rid_base), ++ "--type=ipa-local" ++ ] ++ ) ++ # Restart dirsrv instance after the new idrange is added. ++ tasks.restart_ipa_server(master) ++ # Clear SSSD cache ++ tasks.clear_sssd_cache(master) ++ return True ++ + + class Test32BitIdRanges(IntegrationTest): + topology = "line" ++ num_replicas = 1 ++ num_clients = 1 ++ # Counter for 32-bit UID/GID allocation; reset in install() so each ++ # test run starts from 0 (install/uninstall gives a fresh environment). ++ id_counter = 0 ++ ++ def get_next_32bit_id(self): ++ """ ++ Generate unique 32-bit IDs for testing ++ """ ++ self.id_counter += 1 ++ return IDRANGE_32BIT_BASE_ID + self.__class__.id_counter + + def test_remove_subid_range(self): + """ +- Test that allocating subid will fail after disabling global option ++ Test that allocating subids will fail after disabling the attribute + """ + master = self.master + tasks.kinit_admin(master) +@@ -23,19 +91,28 @@ class Test32BitIdRanges(IntegrationTest): + master.run_command( + ["ipa", "config-mod", "--addattr", "ipaconfigstring=SubID:Disable"] + ) +- master.run_command(["ipa", "idrange-del", idrange]) ++ master.run_command( ++ ["ipa", "idrange-del", idrange] ++ ) ++ master.run_command(["systemctl", "restart", "sssd"]) + + tasks.user_add(master, 'subiduser') +- result = master.run_command( +- ["ipa", "subid-generate", "--owner", "subiduser"], raiseonerr=False +- ) +- assert result.returncode > 0 +- assert "Support for subordinate IDs is disabled" in result.stderr_text +- tasks.user_del(master, 'subiduser') ++ try: ++ result = master.run_command( ++ ["ipa", "subid-generate", "--owner", "subiduser"], ++ raiseonerr=False ++ ) ++ assert result.returncode > 0 ++ assert "Support for subordinate IDs is disabled" in \ ++ result.stderr_text ++ finally: ++ # Cleanup: Remove test user ++ tasks.user_del(master, 'subiduser') + + def test_invoke_upgrader(self): +- """Test that ipa-server-upgrade does not add subid ranges back""" +- ++ """ ++ Test that ipa-server-upgrade does not add subid ranges back. ++ """ + master = self.master + master.run_command(['ipa-server-upgrade'], raiseonerr=True) + idrange = f"{master.domain.realm}_subid_range" +@@ -58,69 +135,227 @@ class Test32BitIdRanges(IntegrationTest): + assert "dnatype: " not in output + + def test_create_user_with_32bit_id(self): +- """Test that ID range above 2^31 can be used to assign IDs +- to users and groups. Also check that SIDs generated properly. + """ ++ Test checks that 32Bit idrange is assigned to the user ++ and getent passwd returns the output. ++ """ ++ master = self.master ++ _add_32bit_idrange_if_missing(master) ++ ++ uid = self.get_next_32bit_id() ++ gid = self.get_next_32bit_id() ++ ++ tasks.clear_sssd_cache(master) ++ username = "user" ++ tasks.create_active_user( ++ master, username, "Secret123", ++ extra_args=["--uid", str(uid), "--gid", str(gid)] ++ ) ++ tasks.kinit_admin(master) ++ try: ++ result = master.run_command( ++ ["ipa", "user-show", username, "--all", "--raw"] ++ ) ++ assert result.returncode == 0, ( ++ f"User not found: {result.stderr_text}" ++ ) ++ assert "ipantsecurityidentifier" in \ ++ result.stdout_text.lower(), ( ++ "SID not found in user entry" ++ ) ++ if hasattr(self, 'clients') and self.clients: ++ client = self.clients[0] ++ tasks.clear_sssd_cache(client) ++ result = client.run_command( ++ ["getent", "passwd", username], raiseonerr=False ++ ) ++ assert result.returncode == 0, ( ++ f"getent passwd failed: {result.stderr_text}" ++ ) ++ assert str(uid) in result.stdout_text ++ assert str(gid) in result.stdout_text ++ finally: ++ tasks.user_del(master, username) ++ ++ def test_create_group_with_32bit_gid(self): ++ """ ++ Test that a group can be created with a GID from the 32-bit range. ++ """ ++ master = self.master ++ groupname = 'grp32bit' ++ gid = self.get_next_32bit_id() ++ tasks.group_add(master, groupname, extra_args=["--gid", str(gid)]) ++ try: ++ result = master.run_command( ++ ["ipa", "group-show", groupname, "--all", "--raw"] ++ ) ++ assert result.returncode == 0 ++ assert str(gid) in result.stdout_text, ( ++ f"GID {gid} not in group entry" ++ ) ++ finally: ++ tasks.group_del(master, groupname) + ++ def test_user_in_group_with_32bit_ids(self): ++ """ ++ Test user with 32-bit UID in a group with 32-bit GID. ++ """ + master = self.master +- idrange = f"{master.domain.realm}_upper_32bit_range" +- id_base = 1 << 31 +- id_length = (1 << 31) - 2 +- uid = id_base + 1 +- gid = id_base + 1 +- master.run_command( +- [ +- "ipa", +- "idrange-add", +- idrange, +- "--base-id", str(id_base), +- "--range-size", str(id_length), +- "--rid-base", str(int(id_base >> 3)), +- "--secondary-rid-base", str(int(id_base >> 3) + id_length), +- "--type=ipa-local" +- ] ++ groupname = 'grp32bit2' ++ username = 'user32bit' ++ uid = self.get_next_32bit_id() ++ gid = self.get_next_32bit_id() ++ tasks.group_add(master, groupname, extra_args=["--gid", str(gid)]) ++ tasks.create_active_user( ++ master, username, "Secret123", ++ extra_args=["--uid", str(uid), "--gid", str(gid)] + ) ++ tasks.kinit_admin(master) ++ try: ++ tasks.group_add_member(master, groupname, users=username) ++ result = master.run_command( ++ ["ipa", "group-show", groupname, "--all", "--raw"] ++ ) ++ assert result.returncode == 0 ++ assert username in result.stdout_text ++ assert f"gidnumber: {gid}" in result.stdout_text, ( ++ f"GID {gid} not found in group entry" ++ ) ++ assert "ipaNTSecurityIdentifier:" in result.stdout_text, ( ++ "Group does not contain a SID" ++ ) ++ result = master.run_command( ++ ["ipa", "user-show", username, "--all", "--raw"] ++ ) ++ assert result.returncode == 0 ++ finally: ++ master.run_command( ++ ["ipa", "group-remove-member", groupname, ++ "--users", username], ++ raiseonerr=False ++ ) ++ tasks.user_del(master, username) ++ tasks.group_del(master, groupname) + +- # We added new ID range, SIDGEN will only take it after +- # restarting a directory server instance. +- tasks.restart_ipa_server(master) ++ def test_ssh_login_with_32bit_id(self): ++ """ ++ Test that a user with 32-bit UID/GID can kinit and log in via SSH ++ from the client to the master using GSSAPI (Kerberos). ++ """ ++ client = self.clients[0] ++ master = self.master ++ testuser = 'sshuser32bit' ++ password = 'Secret123' ++ uid = self.get_next_32bit_id() ++ gid = self.get_next_32bit_id() + +- # Clear SSSD cache to pick up new ID range + tasks.clear_sssd_cache(master) ++ tasks.create_active_user( ++ master, testuser, password, ++ extra_args=["--uid", str(uid), "--gid", str(gid)] ++ ) ++ tasks.kinit_admin(master) ++ hbac_rule = "allow_ssh_32bit_test" ++ tasks.hbacrule_add(master, hbac_rule, extra_args=["--hostcat=all"]) ++ tasks.hbacrule_add_user(master, hbac_rule, users=testuser) ++ tasks.hbacrule_add_service(master, hbac_rule, services="sshd") ++ try: ++ result = master.run_command( ++ ["ipa", "user-show", testuser, "--all", "--raw"] ++ ) ++ assert result.returncode == 0, ( ++ f"User {testuser} not found: {result.stderr_text}" ++ ) + +- tasks.user_add(master, "user", extra_args=[ +- "--uid", str(uid), "--gid", str(gid) +- ]) ++ tasks.clear_sssd_cache(client) ++ tasks.clear_sssd_cache(master) ++ tasks.kdestroy_all(client) ++ tasks.kinit_as_user(client, testuser, password) ++ result = client.run_command([ ++ 'ssh', '-o', 'StrictHostKeyChecking=no', '-K', ++ '-l', testuser, master.hostname, 'echo login successful' ++ ], raiseonerr=False) ++ assert result.returncode == 0, ( ++ "SSH (GSSAPI) from client to master failed: " ++ f"{result.stderr_text}" ++ ) ++ assert 'login successful' in result.stdout_text, ( ++ "SSH succeeded but expected output missing: " ++ f"{result.stdout_text}" ++ ) ++ finally: ++ tasks.kdestroy_all(client) ++ master.run_command( ++ ["ipa", "hbacrule-del", hbac_rule], raiseonerr=False ++ ) ++ tasks.kinit_admin(master) ++ tasks.user_del(master, testuser) + +- result = master.run_command( +- ["ipa", "user-show", "user", "--all", "--raw"], raiseonerr=False +- ) +- assert result.returncode == 0 +- assert "ipaNTSecurityIdentifier:" in result.stdout_text ++ def test_32bit_id_replication(self): ++ """ ++ Test that users with 32-bit IDs replicate correctly ++ """ ++ master = self.master ++ replica = self.replicas[0] ++ tasks.kinit_admin(master) ++ testuser = 'repluser32bit' ++ uid = self.get_next_32bit_id() ++ gid = self.get_next_32bit_id() + +- result = master.run_command( +- ["id", "user"], raiseonerr=False ++ tasks.clear_sssd_cache(master) ++ ++ # Create user on master ++ tasks.create_active_user( ++ master, testuser, "Secret123", ++ extra_args=["--uid", str(uid), "--gid", str(gid)] + ) +- assert result.returncode == 0 +- assert str(uid) in result.stdout_text ++ tasks.kinit_admin(master) ++ try: ++ tasks.wait_for_replication(master.ldap_connect()) ++ ++ result = master.run_command( ++ ["ipa", "user-show", testuser, "--all", "--raw"], ++ raiseonerr=False ++ ) ++ assert result.returncode == 0, ( ++ f"User {testuser} not found on master" ++ ) ++ assert str(uid) in result.stdout_text, ( ++ f"UID {uid} not on master" ++ ) ++ ++ tasks.kinit_admin(replica) ++ result = replica.run_command( ++ ["ipa", "user-show", testuser, "--all", "--raw"], ++ raiseonerr=False ++ ) ++ assert result.returncode == 0, ( ++ f"User {testuser} not replicated to replica" ++ ) ++ assert str(uid) in result.stdout_text, ( ++ f"UID {uid} not on replica" ++ ) ++ finally: ++ # Cleanup: Remove test user from master ++ tasks.kinit_admin(master) ++ tasks.user_del(master, testuser) + + + class Test32BitIdrangeInTrustEnv(Test32BitIdRanges, BaseTestTrust): + """ + Tests to check 32BitIdrange functionality +- in IPA-AD trust enviornment ++ in IPA-AD trust environment + """ + topology = 'line' ++ num_replicas = 1 + num_ad_domains = 1 + num_ad_subdomains = 0 + num_ad_treedomains = 0 +- num_clients = 0 ++ num_clients = 1 + + @classmethod + def install(cls, mh): +- super(BaseTestTrust, cls).install(mh) ++ super(Test32BitIdrangeInTrustEnv, cls).install(mh) + cls.ad = cls.ads[0] +- cls.ad_domain = cls.ad.domain.name + tasks.configure_dns_for_trust(cls.master, cls.ad) +- tasks.install_adtrust(cls.master) + tasks.establish_trust_with_ad(cls.master, cls.ad.domain.name) +-- +2.52.0 + diff --git a/0034-ipatests-add-HTTP-GSSAPI-Kerberos-authentication-tes.patch b/0034-ipatests-add-HTTP-GSSAPI-Kerberos-authentication-tes.patch new file mode 100644 index 0000000..a97efd1 --- /dev/null +++ b/0034-ipatests-add-HTTP-GSSAPI-Kerberos-authentication-tes.patch @@ -0,0 +1,260 @@ +From 3acf55ed7fcf9a3b38deb0efb98d222d57bafbbc Mon Sep 17 00:00:00 2001 +From: Anuja More +Date: Thu, 26 Feb 2026 12:04:50 +0530 +Subject: [PATCH] ipatests: add HTTP GSSAPI Kerberos authentication + tests with AD trust + +Add TestTrustFunctionalHttp integration test class covering GSSAPI-protected +HTTP access in an AD trust environment: + +- test_ipa_trust_func_http_krb_ipauser: IPA user with a valid Kerberos ticket + can access the GSSAPI-protected webapp; AD root and subdomain users are + denied when only the IPA user is in the Allow list +- test_ipa_trust_func_http_krb_aduser: AD root domain and subdomain users with + valid Kerberos tickets can access the webapp; IPA users are denied when the + Allow list is configured for an AD user +- test_ipa_trust_func_http_krb_nouser: a user without a Kerberos ticket + receives a 401 Unauthorized response + +The class sets up an Apache httpd instance with mod_auth_gssapi on the IPA +client, obtains a service keytab via ipa-getkeytab, and uses curl with +GSSAPI negotiate to drive each scenario. + +Related: https://pagure.io/freeipa/issue/9845 +Assisted-by: Claude noreply@anthropic.com +Signed-off-by: Anuja More +Reviewed-By: Rafael Guterres Jeffman +Reviewed-By: David Hanina +Reviewed-By: Florence Blanc-Renaud +--- + .../test_integration/test_trust_functional.py | 209 +++++++++++++++++- + 1 file changed, 208 insertions(+), 1 deletion(-) + +diff --git a/ipatests/test_integration/test_trust_functional.py b/ipatests/test_integration/test_trust_functional.py +index a85f21e96463757b9a446df666d5361e65ba686c..5a9eae8cebfdae23bf37d3298e455bab8e3304ef 100644 +--- a/ipatests/test_integration/test_trust_functional.py ++++ b/ipatests/test_integration/test_trust_functional.py +@@ -2,8 +2,9 @@ + + from __future__ import absolute_import + ++import re + import time +- ++import textwrap + from ipaplatform.paths import paths + from ipatests.pytest_ipa.integration import tasks + from ipatests.test_integration.test_trust import BaseTestTrust +@@ -658,3 +659,209 @@ class TestTrustFunctionalSudo(BaseTestTrust): + raiseonerr=False) + finally: + self._cleanup_srule(srule) ++ ++ ++class TestTrustFunctionalHttp(BaseTestTrust): ++ topology = 'line' ++ num_ad_treedomains = 0 ++ ++ ad_user_password = 'Secret123' ++ ++ # Apache configuration for GSSAPI-protected webapp. The /mywebapp ++ # location requires Kerberos authentication and restricts access by ++ # domain: IPA users (@IPA_REALM) or AD users (@AD_DOMAIN). ++ apache_conf = textwrap.dedent(''' ++ Alias /mywebapp "/var/www/html/mywebapp" ++ ++ Allow from all ++ ++ ++ LogLevel debug ++ AuthType GSSAPI ++ AuthName "IPA Kerberos authentication" ++ GssapiNegotiateOnce on ++ GssapiBasicAuthMech krb5 ++ GssapiCredStore keytab:{keytab_path} ++ ++ Require valid-user ++ # Require expr: restrict access by domain. REMOTE_USER is set by ++ # mod_auth_gssapi after GSSAPI authentication. Allow users whose ++ # principal ends with the domain (IPA realm or AD domain). ++ Require expr %{{REMOTE_USER}} =~ /{allowed_domain_regex}$/ ++ ++ ++ ''') ++ ++ def _configure_webapp(self, allowed_domain): ++ """Write the GSSAPI vhost config and restart httpd on the client. ++ ++ allowed_domain: realm/domain for access control (e.g. IPA.TEST for ++ IPA users, AD.DOMAIN for AD users). Users whose principal ends with ++ @allowed_domain are granted access. ++ """ ++ # Escape dots for regex (e.g. IPA.TEST -> IPA\\.TEST) ++ escaped = re.escape(allowed_domain) ++ allowed_domain_regex = '.*@' + escaped ++ keytab_path = f"/etc/httpd/conf/{self.clients[0].hostname}.keytab" ++ self.clients[0].put_file_contents( ++ '/etc/httpd/conf.d/mywebapp.conf', ++ self.apache_conf.format( ++ keytab_path=keytab_path, ++ allowed_domain_regex=allowed_domain_regex, ++ ) ++ ) ++ self.clients[0].run_command(['systemctl', 'restart', 'httpd']) ++ ++ def _assert_curl_ok(self, msg=None): ++ """Run curl with GSSAPI negotiate and assert the webapp responds.""" ++ url = f"http://{self.clients[0].hostname}/mywebapp/index.html" ++ result = self.clients[0].run_command([ ++ paths.BIN_CURL, '-v', '--negotiate', '-u:', url ++ ]) ++ assert "TEST_MY_WEB_APP" in result.stdout_text, ( ++ msg or f"Expected webapp content at {url}" ++ ) ++ ++ def _assert_curl_GSSAPI_access_denied(self, msg=None): ++ """Run curl with GSSAPI negotiate and assert a 401 is returned.""" ++ url = f"http://{self.clients[0].hostname}/mywebapp/index.html" ++ result = self.clients[0].run_command([ ++ paths.BIN_CURL, '-v', '--negotiate', '-u:', url ++ ], raiseonerr=False) ++ output = f"{result.stdout_text}{result.stderr_text}" ++ assert ("401" in output ++ or "Unauthorized" in output ++ or "Authorization Required" in output), ( ++ msg or f"Expected 401/Unauthorized at {url}, got: {output[:200]}" ++ ) ++ ++ @classmethod ++ def install(cls, mh): ++ """Extend base install to configure Apache/GSSAPI for HTTP tests. ++ ++ Runs once before any test in this class. Sets up the AD trust, ++ creates the HTTP service principal and IPA test user, installs ++ mod_auth_gssapi, retrieves the service keytab, and provisions the ++ static webapp content used by all HTTP tests. ++ """ ++ super().install(mh) ++ tasks.configure_dns_for_trust(cls.master, cls.ad) ++ tasks.establish_trust_with_ad( ++ cls.master, cls.ad_domain, ++ extra_args=['--range-type', 'ipa-ad-trust']) ++ ++ # Create HTTP service principal on master ++ service_principal = f"HTTP/{cls.clients[0].hostname}" ++ cls.master.run_command( ++ ["ipa", "service-add", service_principal] ++ ) ++ ++ # Create IPA user for HTTP tests ++ tasks.create_active_user( ++ cls.master, "ipahttpuser1", password="Passw0rd1", ++ first="f", last="l" ++ ) ++ ++ # Clear SSSD cache on master ++ tasks.clear_sssd_cache(cls.master) ++ tasks.wait_for_sssd_domain_status_online(cls.master) ++ ++ # Install Apache and the GSSAPI module on the IPA client ++ tasks.install_packages( ++ cls.clients[0], ['mod_auth_gssapi', 'httpd'] ++ ) ++ ++ # Retrieve and protect the HTTP service keytab ++ keytab_path = f"/etc/httpd/conf/{cls.clients[0].hostname}.keytab" ++ cls.clients[0].run_command([ ++ 'ipa-getkeytab', '-s', cls.master.hostname, ++ '-k', keytab_path, ++ '-p', service_principal ++ ]) ++ cls.clients[0].run_command( ++ ['chown', 'apache:apache', keytab_path] ++ ) ++ ++ # Create webapp directory and static content ++ cls.clients[0].run_command( ++ ['mkdir', '-p', '/var/www/html/mywebapp'] ++ ) ++ cls.clients[0].put_file_contents( ++ '/var/www/html/mywebapp/index.html', ++ 'TEST_MY_WEB_APP\n' ++ ) ++ ++ def test_ipa_trust_func_http_krb_ipauser(self): ++ """ ++ Test IPA User access http with kerberos ticket via valid user. ++ ++ This test verifies that an IPA user with a valid Kerberos ticket ++ can successfully access an HTTP resource protected by GSSAPI ++ authentication and restricted to IPA users. ++ """ ++ ipa_realm = self.clients[0].domain.realm ++ self._configure_webapp(ipa_realm) ++ ++ tasks.kdestroy_all(self.clients[0]) ++ tasks.kinit_as_user( ++ self.clients[0], f'ipahttpuser1@{ipa_realm}', "Passw0rd1" ++ ) ++ ++ self._assert_curl_ok() ++ ++ users = [ ++ (self.aduser, self.ad_domain), ++ (self.subaduser, self.ad_subdomain), ++ ] ++ for aduser, domain in users: ++ tasks.kdestroy_all(self.clients[0]) ++ # pylint: disable=use-maxsplit-arg ++ principal = f"{aduser.split('@')[0]}@{domain.upper()}" ++ tasks.kinit_as_user( ++ self.clients[0], principal, self.ad_user_password ++ ) ++ self._assert_curl_GSSAPI_access_denied( ++ msg=f"Expected 401 for AD user {aduser}" ++ ) ++ ++ def test_ipa_trust_func_http_krb_aduser(self): ++ """ ++ Test AD root and subdomain users access http with kerberos ticket. ++ ++ This test verifies that both a root AD domain user and a child ++ subdomain user with valid Kerberos tickets can successfully access ++ an HTTP resource protected by GSSAPI authentication and restricted ++ to AD domain / AD subdomain users. ++ """ ++ users = [ ++ (self.aduser, self.ad_domain), ++ (self.subaduser, self.ad_subdomain), ++ ] ++ for aduser, domain in users: ++ tasks.kdestroy_all(self.clients[0]) ++ # pylint: disable=use-maxsplit-arg ++ principal = f"{aduser.split('@')[0]}@{domain.upper()}" ++ self._configure_webapp(domain.upper()) ++ tasks.kinit_as_user( ++ self.clients[0], principal, self.ad_user_password ++ ) ++ self._assert_curl_ok( ++ msg=f"Expected webapp content for AD user {aduser}" ++ ) ++ tasks.kdestroy_all(self.clients[0]) ++ tasks.kinit_as_user(self.clients[0], "ipahttpuser1", "Passw0rd1") ++ self._assert_curl_GSSAPI_access_denied( ++ msg=f"Expected 401 for IPA user after AD user {aduser}" ++ ) ++ ++ def test_ipa_trust_func_http_krb_nouser(self): ++ """ ++ Test User cannot access http without kerberos ticket via valid user. ++ ++ This test verifies that an user without a valid Kerberos ticket ++ is denied access to an HTTP resource protected by GSSAPI ++ authentication, receiving a 401 Unauthorized error. ++ """ ++ tasks.kdestroy_all(self.clients[0]) ++ ++ self._assert_curl_GSSAPI_access_denied() +-- +2.52.0 + diff --git a/0035-ipatests-Extend-netgroup-test-coverage.patch b/0035-ipatests-Extend-netgroup-test-coverage.patch new file mode 100644 index 0000000..caf5e5e --- /dev/null +++ b/0035-ipatests-Extend-netgroup-test-coverage.patch @@ -0,0 +1,2595 @@ +From 0390076bfdeb122b33437a55a08ade9880127deb Mon Sep 17 00:00:00 2001 +From: PRANAV THUBE +Date: Wed, 4 Mar 2026 20:06:07 +0530 +Subject: [PATCH] ipatests: Extend netgroup test coverage. + +Add new test cases for netgroup plugin: + +netgroup_add: +- Negative tests for space in nisdomain/description +- Invalid usercategory/hostcategory values +- Invalid addattr/setattr attributes +- Positive tests for custom nisdomain, usercat=all, hostcat=all, + combined usercat+hostcat, and externalHost via addattr + +netgroup_mod: +- Modify nisdomain, usercategory, hostcategory, description +- setattr/addattr/delattr for description and externalHost +- Negative tests for invalid values and disallowed attributes + +netgroup_add_member: +- Negative tests for non-existent user, group, hostgroup members +- Add member to non-existent netgroup + +netgroup_remove_member: +- Remove member from non-existent netgroup + +netgroup_find: +- Search by description and nisdomain +- Negative searches returning zero results + +Integration tests: +- Managed netgroups plugin (ipa-managed-entries) +- Find with --in-netgroups/--not-in-netgroups across entities +- CLI-specific options (--pkey-only, --sizelimit, --timelimit) +- Complex multi-host/DNS setup scenarios + +Related: https://pagure.io/freeipa/issue/9952 +Signed-off-by: Pranav Thube pthube@redhat.com +Reviewed-By: Florence Blanc-Renaud +Reviewed-By: David Hanina +Reviewed-By: Carla Martinez +--- + ipatests/pytest_ipa/integration/tasks.py | 8 +- + ipatests/test_integration/test_netgroup.py | 211 +- + ipatests/test_xmlrpc/test_netgroup_plugin.py | 2091 ++++++++++++++++-- + 3 files changed, 2059 insertions(+), 251 deletions(-) + +diff --git a/ipatests/pytest_ipa/integration/tasks.py b/ipatests/pytest_ipa/integration/tasks.py +index d9f142794..ee2befd69 100755 +--- a/ipatests/pytest_ipa/integration/tasks.py ++++ b/ipatests/pytest_ipa/integration/tasks.py +@@ -2272,9 +2272,9 @@ def user_add(host, login, first='test', last='user', extra_args=(), + return host.run_command(cmd, stdin_text=stdin_text) + + +-def user_del(host, login): ++def user_del(host, login, raiseonerr=True): + cmd = ["ipa", "user-del", login] +- return host.run_command(cmd) ++ return host.run_command(cmd, raiseonerr=raiseonerr) + + + def group_add(host, groupname, extra_args=()): +@@ -2285,11 +2285,11 @@ def group_add(host, groupname, extra_args=()): + return host.run_command(cmd) + + +-def group_del(host, groupname): ++def group_del(host, groupname, raiseonerr=True): + cmd = [ + "ipa", "group-del", groupname, + ] +- return host.run_command(cmd) ++ return host.run_command(cmd, raiseonerr=raiseonerr) + + + def group_add_member(host, groupname, users=None, +diff --git a/ipatests/test_integration/test_netgroup.py b/ipatests/test_integration/test_netgroup.py +index 5fae9e03f..86b156a83 100644 +--- a/ipatests/test_integration/test_netgroup.py ++++ b/ipatests/test_integration/test_netgroup.py +@@ -5,7 +5,20 @@ + import pytest + + from ipatests.test_integration.base import IntegrationTest +-from ipatests.pytest_ipa.integration.tasks import clear_sssd_cache ++from ipatests.pytest_ipa.integration import tasks ++ ++# In-netgroups test entities ++# *_MEMBER entities are added to INNG_NG_PARENT netgroup ++# *_NONMEMBER entities are NOT added to any netgroup ++INNG_USER_MEMBER = 'inng_user_member' ++INNG_USER_NONMEMBER = 'inng_user_nonmember' ++INNG_GRP_MEMBER = 'inng_grp_member' ++INNG_GRP_NONMEMBER = 'inng_grp_nonmember' ++INNG_HG_MEMBER = 'inng_hg_member' ++INNG_HG_NONMEMBER = 'inng_hg_nonmember' ++INNG_NG_PARENT = 'inng_ng_parent' ++INNG_NG_MEMBER = 'inng_ng_member' ++INNG_NG_NONMEMBER = 'inng_ng_nonmember' + + + test_data = [] +@@ -56,16 +69,14 @@ def three_netgroups(request): + + + class TestNetgroups(IntegrationTest): +- """ +- Test Netgroups +- """ ++ """Test Netgroups - nested, managed, find, show, delete operations.""" + + topology = 'line' + + def check_users_in_netgroups(self): + """Check if users are in groups, no nested things""" + master = self.master +- clear_sssd_cache(master) ++ tasks.clear_sssd_cache(master) + + for d in test_data: + result = master.run_command(['getent', 'passwd', +@@ -86,7 +97,7 @@ class TestNetgroups(IntegrationTest): + def check_nested_netgroup_hierarchy(self): + """Check if nested netgroups hierarchy is complete""" + master = self.master +- clear_sssd_cache(master) ++ tasks.clear_sssd_cache(master) + + for d in test_data: + result = master.run_command(['getent', 'netgroup', d['netgroup']], +@@ -135,7 +146,7 @@ class TestNetgroups(IntegrationTest): + test_data[1]['netgroup']], + raiseonerr=False) + assert result.returncode == 0 +- clear_sssd_cache(master) ++ tasks.clear_sssd_cache(master) + + result = master.run_command(['getent', 'netgroup', + test_data[1]['netgroup']], +@@ -158,7 +169,7 @@ class TestNetgroups(IntegrationTest): + test_data[2]['netgroup']], + raiseonerr=False) + assert result.returncode == 0 +- clear_sssd_cache(master) ++ tasks.clear_sssd_cache(master) + + result = master.run_command(['getent', 'netgroup', + test_data[2]['netgroup']], +@@ -167,3 +178,187 @@ class TestNetgroups(IntegrationTest): + assert trinity[0] not in result.stdout_text + assert trinity[1] not in result.stdout_text + assert trinity[2] in result.stdout_text ++ ++ # --- Managed Netgroups Tests --- ++ ++ def test_managed_netgroup_lifecycle(self): ++ """Test managed netgroup created/deleted with hostgroup.""" ++ master = self.master ++ # Enable NGP plugin ++ master.run_command( ++ ['ipa-managed-entries', '--entry=NGP Definition', 'enable'], ++ raiseonerr=False ++ ) ++ # Add hostgroup - managed netgroup should be created ++ tasks.hostgroup_add(master, 'mng_testgrp', ('--desc=test',)) ++ master.run_command( ++ ['ipa', 'netgroup-find', '--managed', 'mng_testgrp'] ++ ) ++ ++ # Delete hostgroup - managed netgroup should be deleted ++ tasks.hostgroup_del(master, 'mng_testgrp') ++ # ipa *-find returns exit code 1 when 0 results found ++ result = master.run_command( ++ ['ipa', 'netgroup-find', '--managed', 'mng_testgrp'], ++ raiseonerr=False ++ ) ++ assert '0 netgroups matched' in result.stdout_text ++ ++ def test_cannot_delete_managed_netgroup(self): ++ """Deleting managed netgroup directly should fail.""" ++ master = self.master ++ master.run_command( ++ ['ipa-managed-entries', '--entry=NGP Definition', 'enable'], ++ raiseonerr=False ++ ) ++ tasks.hostgroup_add(master, 'mng_nodelete', ('--desc=test',)) ++ try: ++ result = master.run_command( ++ ['ipa', 'netgroup-del', 'mng_nodelete'], raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ assert 'Deleting a managed entry is not allowed' in \ ++ result.stderr_text ++ finally: ++ tasks.hostgroup_del(master, 'mng_nodelete', raiseonerr=False) ++ ++ # --- In-Netgroups Tests for Various Entity Types --- ++ ++ @pytest.fixture(scope='class') ++ def in_netgroups_setup(self, request): ++ """Setup for --in-netgroups tests across entity types.""" ++ master = self.master ++ domain = master.domain.name ++ innghost1 = f'innghost1.{domain}' ++ innghost2 = f'innghost2.{domain}' ++ ++ # Register cleanup FIRST so it runs even if setup fails ++ def cleanup(): ++ for ng in [INNG_NG_PARENT, INNG_NG_MEMBER, INNG_NG_NONMEMBER]: ++ master.run_command( ++ ['ipa', 'netgroup-del', ng], raiseonerr=False ++ ) ++ tasks.hostgroup_del(master, INNG_HG_MEMBER, raiseonerr=False) ++ tasks.hostgroup_del(master, INNG_HG_NONMEMBER, raiseonerr=False) ++ tasks.host_del(master, innghost1, raiseonerr=False) ++ tasks.host_del(master, innghost2, raiseonerr=False) ++ tasks.group_del(master, INNG_GRP_MEMBER, raiseonerr=False) ++ tasks.group_del(master, INNG_GRP_NONMEMBER, raiseonerr=False) ++ tasks.user_del(master, INNG_USER_MEMBER, raiseonerr=False) ++ tasks.user_del(master, INNG_USER_NONMEMBER, raiseonerr=False) ++ request.addfinalizer(cleanup) ++ ++ # Create entities ++ tasks.user_add(master, INNG_USER_MEMBER) ++ tasks.user_add(master, INNG_USER_NONMEMBER) ++ tasks.group_add(master, INNG_GRP_MEMBER, ('--desc=test',)) ++ tasks.group_add(master, INNG_GRP_NONMEMBER, ('--desc=test',)) ++ tasks.host_add(master, innghost1, '--force') ++ tasks.host_add(master, innghost2, '--force') ++ tasks.hostgroup_add(master, INNG_HG_MEMBER, ('--desc=test',)) ++ tasks.hostgroup_add(master, INNG_HG_NONMEMBER, ('--desc=test',)) ++ ++ for ng in [INNG_NG_PARENT, INNG_NG_MEMBER, INNG_NG_NONMEMBER]: ++ master.run_command(['ipa', 'netgroup-add', ng, '--desc=test']) ++ ++ # Add members to parent netgroup only ++ master.run_command(['ipa', 'netgroup-add-member', INNG_NG_PARENT, ++ f'--users={INNG_USER_MEMBER}', ++ f'--groups={INNG_GRP_MEMBER}', ++ f'--hosts={innghost1}', ++ f'--hostgroups={INNG_HG_MEMBER}', ++ f'--netgroups={INNG_NG_MEMBER}']) ++ ++ def test_in_netgroups_across_entities(self, in_netgroups_setup): ++ """Test --in-netgroups and --not-in-netgroups for all entity types.""" ++ master = self.master ++ domain = master.domain.name ++ ++ entity_tests = [ ++ ('user-find', INNG_USER_MEMBER, INNG_USER_NONMEMBER), ++ ('group-find', INNG_GRP_MEMBER, INNG_GRP_NONMEMBER), ++ ('host-find', f'innghost1.{domain}', f'innghost2.{domain}'), ++ ('hostgroup-find', INNG_HG_MEMBER, INNG_HG_NONMEMBER), ++ ('netgroup-find', INNG_NG_MEMBER, INNG_NG_NONMEMBER), ++ ] ++ ++ for cmd, member, non_member in entity_tests: ++ # Test --in-netgroups ++ result = master.run_command( ++ ['ipa', cmd, f'--in-netgroups={INNG_NG_PARENT}'] ++ ) ++ assert member in result.stdout_text, \ ++ f"--in-netgroups failed for {cmd}" ++ assert non_member not in result.stdout_text ++ ++ # Test --not-in-netgroups ++ result = master.run_command( ++ ['ipa', cmd, f'--not-in-netgroups={INNG_NG_PARENT}'] ++ ) ++ assert non_member in result.stdout_text, \ ++ f"--not-in-netgroups failed for {cmd}" ++ ++ def test_removed_member_not_in_netgroups(self): ++ """Verify removed member no longer appears in --in-netgroups.""" ++ master = self.master ++ user = 'removal_test_user' ++ netgroup = 'removal_test_ng' ++ ++ try: ++ tasks.user_add(master, user) ++ master.run_command(['ipa', 'netgroup-add', netgroup]) ++ master.run_command([ ++ 'ipa', 'netgroup-add-member', netgroup, f'--users={user}' ++ ]) ++ ++ # Verify user is in netgroup ++ result = master.run_command( ++ ['ipa', 'user-find', f'--in-netgroups={netgroup}'] ++ ) ++ assert user in result.stdout_text ++ ++ # Remove user and verify not in netgroup ++ master.run_command([ ++ 'ipa', 'netgroup-remove-member', netgroup, f'--users={user}' ++ ]) ++ result = master.run_command( ++ ['ipa', 'user-find', f'--in-netgroups={netgroup}'], ++ raiseonerr=False ++ ) ++ assert user not in result.stdout_text ++ ++ finally: ++ master.run_command( ++ ['ipa', 'netgroup-del', netgroup], raiseonerr=False) ++ tasks.user_del(master, user, raiseonerr=False) ++ ++ def test_delete_multiple_netgroups(self): ++ """Delete multiple netgroups and verify they're deleted.""" ++ master = self.master ++ ++ try: ++ # Create multiple netgroups ++ for i in range(1, 4): ++ master.run_command( ++ ['ipa', 'netgroup-add', f'del_ng{i}', '--desc=test'] ++ ) ++ ++ # Delete all at once ++ master.run_command( ++ ['ipa', 'netgroup-del', 'del_ng1', 'del_ng2', 'del_ng3'] ++ ) ++ ++ # Verify all deleted ++ for i in range(1, 4): ++ result = master.run_command( ++ ['ipa', 'netgroup-show', f'del_ng{i}'], raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ assert 'netgroup not found' in result.stderr_text ++ ++ finally: ++ # Cleanup: ensure netgroups are deleted even if test fails ++ for i in range(1, 4): ++ master.run_command( ++ ['ipa', 'netgroup-del', f'del_ng{i}'], raiseonerr=False ++ ) +diff --git a/ipatests/test_xmlrpc/test_netgroup_plugin.py b/ipatests/test_xmlrpc/test_netgroup_plugin.py +index 3f1fc0bc1..bee3e3f8e 100644 +--- a/ipatests/test_xmlrpc/test_netgroup_plugin.py ++++ b/ipatests/test_xmlrpc/test_netgroup_plugin.py +@@ -62,6 +62,27 @@ invalidnisdomain1=u'domain1,domain2' + invalidnisdomain2=u'+invalidnisdomain' + invalidhost=u'+invalid&host' + ++netgroup_nisdomain = 'netgroup_nisdomain' ++netgroup_usercat = 'netgroup_usercat' ++netgroup_hostcat = 'netgroup_hostcat' ++netgroup_usercat_hostcat = 'netgroup_usercat_hostcat' ++netgroup_exthost = 'netgroup_exthost' ++netgroup_usercat_mod = 'netgroup_usercat_mod' ++netgroup_hostcat_mod = 'netgroup_hostcat_mod' ++custom_nisdomain = 'testnis.dom' ++external_host = 'ipaqatesthost' ++ ++# Entities for netgroup-find tests ++fnd_ng1 = 'fndng1' ++fnd_ng2 = 'fndng2' ++fnd_ng3 = 'fndng3' ++fnd_user1 = 'fnduser1' ++fnd_user2 = 'fnduser2' ++fnd_group = 'fndgroup' ++fnd_host = 'fndhost1.%s' % api.env.domain ++fnd_hostgroup = 'fndhostgroup' ++fnd_nisdomain = 'testdomain' ++ + + @pytest.mark.tier1 + class test_netgroup(Declarative): +@@ -72,11 +93,28 @@ class test_netgroup(Declarative): + cleanup_commands = [ + ('netgroup_del', [netgroup1], {}), + ('netgroup_del', [netgroup2], {}), ++ ('netgroup_del', [netgroup_single], {}), ++ ('netgroup_del', [netgroup_nisdomain], {}), ++ ('netgroup_del', [netgroup_usercat], {}), ++ ('netgroup_del', [netgroup_hostcat], {}), ++ ('netgroup_del', [netgroup_usercat_hostcat], {}), ++ ('netgroup_del', [netgroup_exthost], {}), ++ ('netgroup_del', [netgroup_usercat_mod], {}), ++ ('netgroup_del', [netgroup_hostcat_mod], {}), + ('host_del', [host1], {}), + ('hostgroup_del', [hostgroup1], {}), + ('user_del', [user1], {}), + ('user_del', [user2], {}), + ('group_del', [group1], {}), ++ # Cleanup for netgroup-find tests ++ ('netgroup_del', [fnd_ng1], {}), ++ ('netgroup_del', [fnd_ng2], {}), ++ ('netgroup_del', [fnd_ng3], {}), ++ ('hostgroup_del', [fnd_hostgroup], {}), ++ ('host_del', [fnd_host], {}), ++ ('group_del', [fnd_group], {}), ++ ('user_del', [fnd_user1], {}), ++ ('user_del', [fnd_user2], {}), + ] + + tests=[ +@@ -133,6 +171,106 @@ class test_netgroup(Declarative): + ), + + ++ dict( ++ desc='Test netgroup add with space in nisdomain', ++ command=( ++ 'netgroup_add', [netgroup1], ++ dict(description='Test', nisdomainname=' ') ++ ), ++ expected=errors.ValidationError( ++ name='nisdomain', ++ error='may only include letters, numbers, _, -, and .'), ++ ), ++ ++ ++ dict( ++ desc='Test netgroup add with space in description', ++ command=( ++ 'netgroup_add', [netgroup1], ++ dict(description=' ') ++ ), ++ expected=errors.ValidationError( ++ name='desc', ++ error='Leading and trailing spaces are not allowed'), ++ ), ++ ++ ++ dict( ++ desc='Test netgroup add with invalid usercat', ++ command=( ++ 'netgroup_add', [netgroup1], ++ dict(description='Test', usercategory='badcat') ++ ), ++ expected=errors.ValidationError( ++ name='usercat', error="must be 'all'"), ++ ), ++ ++ ++ dict( ++ desc='Test netgroup add with space for usercat', ++ command=( ++ 'netgroup_add', [netgroup1], ++ dict(description='Test', usercategory=' ') ++ ), ++ expected=errors.ValidationError( ++ name='usercat', error="must be 'all'"), ++ ), ++ ++ ++ dict( ++ desc='Test netgroup add with invalid hostcat', ++ command=( ++ 'netgroup_add', [netgroup1], ++ dict(description='Test', hostcategory='badcat') ++ ), ++ expected=errors.ValidationError( ++ name='hostcat', error="must be 'all'"), ++ ), ++ ++ ++ dict( ++ desc='Test netgroup add with space for hostcat', ++ command=( ++ 'netgroup_add', [netgroup1], ++ dict(description='Test', hostcategory=' ') ++ ), ++ expected=errors.ValidationError( ++ name='hostcat', error="must be 'all'"), ++ ), ++ ++ ++ dict( ++ desc='Test netgroup add with both desc and addattr description', ++ command=( ++ 'netgroup_add', [netgroup1], ++ dict(description='Test', addattr='description=duplicate') ++ ), ++ expected=errors.OnlyOneValueAllowed(attr='description'), ++ ), ++ ++ ++ dict( ++ desc='Test netgroup add with invalid setattr attribute', ++ command=( ++ 'netgroup_add', [netgroup1], ++ dict(description='Test', setattr='badattr=somevalue') ++ ), ++ expected=errors.ObjectclassViolation( ++ info='attribute "badattr" not allowed'), ++ ), ++ ++ ++ dict( ++ desc='Test netgroup add with invalid addattr attribute', ++ command=( ++ 'netgroup_add', [netgroup1], ++ dict(description='Test', addattr='badattr=somevalue') ++ ), ++ expected=errors.ObjectclassViolation( ++ info='attribute "badattr" not allowed'), ++ ), ++ ++ + dict( + desc='Create %r' % netgroup1, + command=('netgroup_add', [netgroup1], +@@ -204,6 +342,178 @@ class test_netgroup(Declarative): + ), + + ++ dict( ++ desc='Create netgroup %r with custom nisdomain' % ( ++ netgroup_nisdomain), ++ command=( ++ 'netgroup_add', [netgroup_nisdomain], ++ dict(description='Test with custom nisdomain', ++ nisdomainname=custom_nisdomain) ++ ), ++ expected=dict( ++ value=netgroup_nisdomain, ++ summary='Added netgroup "%s"' % netgroup_nisdomain, ++ result=dict( ++ dn=fuzzy_netgroupdn, ++ cn=[netgroup_nisdomain], ++ objectclass=objectclasses.netgroup, ++ description=['Test with custom nisdomain'], ++ nisdomainname=[custom_nisdomain], ++ ipauniqueid=[fuzzy_uuid], ++ ), ++ ), ++ ), ++ ++ dict( ++ desc='Delete %r' % netgroup_nisdomain, ++ command=('netgroup_del', [netgroup_nisdomain], {}), ++ expected=dict( ++ value=[netgroup_nisdomain], ++ summary='Deleted netgroup "%s"' % netgroup_nisdomain, ++ result=dict(failed=[]), ++ ), ++ ), ++ ++ ++ dict( ++ desc='Create netgroup %r with usercat=all' % netgroup_usercat, ++ command=( ++ 'netgroup_add', [netgroup_usercat], ++ dict(description='Test with usercat all', ++ nisdomainname=custom_nisdomain, usercategory='all') ++ ), ++ expected=dict( ++ value=netgroup_usercat, ++ summary='Added netgroup "%s"' % netgroup_usercat, ++ result=dict( ++ dn=fuzzy_netgroupdn, ++ cn=[netgroup_usercat], ++ objectclass=objectclasses.netgroup, ++ description=['Test with usercat all'], ++ nisdomainname=[custom_nisdomain], ++ usercategory=['all'], ++ ipauniqueid=[fuzzy_uuid], ++ ), ++ ), ++ ), ++ ++ dict( ++ desc='Delete %r' % netgroup_usercat, ++ command=('netgroup_del', [netgroup_usercat], {}), ++ expected=dict( ++ value=[netgroup_usercat], ++ summary='Deleted netgroup "%s"' % netgroup_usercat, ++ result=dict(failed=[]), ++ ), ++ ), ++ ++ ++ dict( ++ desc='Create netgroup %r with hostcat=all' % netgroup_hostcat, ++ command=( ++ 'netgroup_add', [netgroup_hostcat], ++ dict(description='Test with hostcat all', ++ nisdomainname=custom_nisdomain, hostcategory='all') ++ ), ++ expected=dict( ++ value=netgroup_hostcat, ++ summary='Added netgroup "%s"' % netgroup_hostcat, ++ result=dict( ++ dn=fuzzy_netgroupdn, ++ cn=[netgroup_hostcat], ++ objectclass=objectclasses.netgroup, ++ description=['Test with hostcat all'], ++ nisdomainname=[custom_nisdomain], ++ hostcategory=['all'], ++ ipauniqueid=[fuzzy_uuid], ++ ), ++ ), ++ ), ++ ++ dict( ++ desc='Delete %r' % netgroup_hostcat, ++ command=('netgroup_del', [netgroup_hostcat], {}), ++ expected=dict( ++ value=[netgroup_hostcat], ++ summary='Deleted netgroup "%s"' % netgroup_hostcat, ++ result=dict(failed=[]), ++ ), ++ ), ++ ++ ++ dict( ++ desc='Create netgroup %r with usercat/hostcat=all' % ( ++ netgroup_usercat_hostcat), ++ command=( ++ 'netgroup_add', [netgroup_usercat_hostcat], ++ dict(description='Test with usercat and hostcat all', ++ nisdomainname=custom_nisdomain, ++ usercategory='all', hostcategory='all') ++ ), ++ expected=dict( ++ value=netgroup_usercat_hostcat, ++ summary='Added netgroup "%s"' % netgroup_usercat_hostcat, ++ result=dict( ++ dn=fuzzy_netgroupdn, ++ cn=[netgroup_usercat_hostcat], ++ objectclass=objectclasses.netgroup, ++ description=['Test with usercat and hostcat all'], ++ nisdomainname=[custom_nisdomain], ++ usercategory=['all'], ++ hostcategory=['all'], ++ ipauniqueid=[fuzzy_uuid], ++ ), ++ ), ++ ), ++ ++ dict( ++ desc='Delete %r' % netgroup_usercat_hostcat, ++ command=('netgroup_del', [netgroup_usercat_hostcat], {}), ++ expected=dict( ++ value=[netgroup_usercat_hostcat], ++ summary='Deleted netgroup "%s"' % netgroup_usercat_hostcat, ++ result=dict(failed=[]), ++ ), ++ ), ++ ++ ++ dict( ++ desc='Create netgroup %r with externalHost' % netgroup_exthost, ++ command=( ++ 'netgroup_add', [netgroup_exthost], ++ dict(description='Test with externalHost', ++ nisdomainname=custom_nisdomain, ++ usercategory='all', hostcategory='all', ++ addattr='externalHost=%s' % external_host) ++ ), ++ expected=dict( ++ value=netgroup_exthost, ++ summary='Added netgroup "%s"' % netgroup_exthost, ++ result=dict( ++ dn=fuzzy_netgroupdn, ++ cn=[netgroup_exthost], ++ objectclass=objectclasses.netgroup, ++ description=['Test with externalHost'], ++ nisdomainname=[custom_nisdomain], ++ usercategory=['all'], ++ hostcategory=['all'], ++ externalhost=[external_host], ++ ipauniqueid=[fuzzy_uuid], ++ ), ++ ), ++ ), ++ ++ dict( ++ desc='Delete %r' % netgroup_exthost, ++ command=('netgroup_del', [netgroup_exthost], {}), ++ expected=dict( ++ value=[netgroup_exthost], ++ summary='Deleted netgroup "%s"' % netgroup_exthost, ++ result=dict(failed=[]), ++ ), ++ ), ++ ++ + dict( + desc='Try to create duplicate %r' % netgroup1, + command=('netgroup_add', [netgroup1], +@@ -628,6 +938,124 @@ class test_netgroup(Declarative): + ), + + ++ dict( ++ desc='Add non-existent user to netgroup %r' % netgroup1, ++ command=( ++ 'netgroup_add_member', [netgroup1], ++ dict(user='notfounduser') ++ ), ++ expected=dict( ++ completed=0, ++ failed=dict( ++ member=dict( ++ netgroup=tuple(), ++ ), ++ memberuser=dict( ++ group=tuple(), ++ user=[('notfounduser', 'no such entry')], ++ ), ++ memberhost=dict( ++ hostgroup=tuple(), ++ host=tuple(), ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Test netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Add non-existent group to netgroup %r' % netgroup1, ++ command=( ++ 'netgroup_add_member', [netgroup1], ++ dict(group='notfoundgroup') ++ ), ++ expected=dict( ++ completed=0, ++ failed=dict( ++ member=dict( ++ netgroup=tuple(), ++ ), ++ memberuser=dict( ++ group=[('notfoundgroup', 'no such entry')], ++ user=tuple(), ++ ), ++ memberhost=dict( ++ hostgroup=tuple(), ++ host=tuple(), ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Test netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Add non-existent hostgroup to netgroup %r' % netgroup1, ++ command=( ++ 'netgroup_add_member', [netgroup1], ++ dict(hostgroup='notfoundhg') ++ ), ++ expected=dict( ++ completed=0, ++ failed=dict( ++ member=dict( ++ netgroup=tuple(), ++ ), ++ memberuser=dict( ++ group=tuple(), ++ user=tuple(), ++ ), ++ memberhost=dict( ++ hostgroup=[('notfoundhg', 'no such entry')], ++ host=tuple(), ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Test netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Add member to non-existent netgroup', ++ command=( ++ 'netgroup_add_member', ['notfoundnetgroup'], dict(user=user1) ++ ), ++ expected=errors.NotFound( ++ reason='notfoundnetgroup: netgroup not found'), ++ ), ++ ++ + dict( + desc='Add duplicate user %r to netgroup %r' % (user1, netgroup1), + command=( +@@ -1035,358 +1463,1543 @@ class test_netgroup(Declarative): + + + dict( +- desc='Update %r' % netgroup1, +- command=('netgroup_mod', [netgroup1], +- dict(description=u'Updated netgroup 1') ++ desc='Search for %r by description' % netgroup1, ++ command=( ++ 'netgroup_find', [], ++ dict(description='Test netgroup 1') + ), + expected=dict( +- value=netgroup1, +- summary=u'Modified netgroup "%s"' % netgroup1, +- result={ +- 'memberhost_host': (host1,), +- 'memberhost_hostgroup': (hostgroup1,), +- 'memberuser_user': (user1,), +- 'memberuser_group': (group1,), +- 'member_netgroup': (netgroup2,), ++ count=1, ++ truncated=False, ++ summary=u'1 netgroup matched', ++ result=[ ++ { ++ 'dn': fuzzy_netgroupdn, + 'cn': [netgroup1], +- 'description': [u'Updated netgroup 1'], +- 'nisdomainname': [u'%s' % api.env.domain], ++ 'description': ['Test netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], + 'externalhost': [unknown_host], +- }, ++ }, ++ ], + ), + ), + + + dict( +- desc='Remove host %r from netgroup %r' % (host1, netgroup1), ++ desc='Search for netgroups by nisdomain', + command=( +- 'netgroup_remove_member', [netgroup1], dict(host=host1) ++ 'netgroup_find', [], ++ dict(nisdomainname='%s' % api.env.domain) + ), + expected=dict( +- completed=1, +- failed=dict( +- member=dict( +- netgroup=tuple(), +- ), +- memberuser=dict( +- group=tuple(), +- user=tuple(), +- ), +- memberhost=dict( +- hostgroup=tuple(), +- host=tuple(), +- ), +- ), +- result={ ++ count=2, ++ truncated=False, ++ summary='2 netgroups matched', ++ result=[ ++ { + 'dn': fuzzy_netgroupdn, +- 'memberhost_hostgroup': (hostgroup1,), +- 'memberuser_user': (user1,), +- 'memberuser_group': (group1,), +- 'member_netgroup': (netgroup2,), + 'cn': [netgroup1], +- 'description': [u'Updated netgroup 1'], +- 'nisdomainname': [u'%s' % api.env.domain], ++ 'description': ['Test netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], + 'externalhost': [unknown_host], +- }, ++ }, ++ { ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [netgroup2], ++ 'description': ['Test netgroup 2'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ }, ++ ], + ), + ), + + + dict( +- desc='Remove hostgroup %r from netgroup %r' % (hostgroup1, netgroup1), +- command=( +- 'netgroup_remove_member', [netgroup1], dict(hostgroup=hostgroup1) ++ desc='Search with non-existent criteria returns zero', ++ command=('netgroup_find', ['doesnotexist'], {}), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ ++ dict( ++ desc='Search with non-existent description returns zero', ++ command=('netgroup_find', [], dict(description='baddesc')), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], + ), ++ ), ++ ++ ++ dict( ++ desc='Search with non-existent nisdomain returns zero', ++ command=('netgroup_find', [], dict(nisdomainname='baddomain')), + expected=dict( +- completed=1, +- failed=dict( +- member=dict( +- netgroup=tuple(), +- ), +- memberuser=dict( +- group=tuple(), +- user=tuple(), +- ), +- memberhost=dict( +- hostgroup=tuple(), +- host=tuple(), +- ), +- ), ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ ++ dict( ++ desc='Update %r' % netgroup1, ++ command=('netgroup_mod', [netgroup1], ++ dict(description='Updated netgroup 1')), ++ expected=dict( ++ value=netgroup1, ++ summary='Modified netgroup "%s"' % netgroup1, + result={ +- 'dn': fuzzy_netgroupdn, +- 'memberuser_user': (user1,), +- 'memberuser_group': (group1,), +- 'member_netgroup': (netgroup2,), +- 'cn': [netgroup1], +- 'description': [u'Updated netgroup 1'], +- 'nisdomainname': [u'%s' % api.env.domain], +- 'externalhost': [unknown_host], ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], + }, + ), + ), + + + dict( +- desc='Remove user %r from netgroup %r' % (user1, netgroup1), ++ desc='Modify nisdomain of %r' % netgroup1, + command=( +- 'netgroup_remove_member', [netgroup1], dict(user=user1) ++ 'netgroup_mod', [netgroup1], ++ dict(nisdomainname='newnisdom1') + ), + expected=dict( +- completed=1, +- failed=dict( +- member=dict( +- netgroup=tuple(), +- ), +- memberuser=dict( +- group=tuple(), +- user=tuple(), +- ), +- memberhost=dict( +- hostgroup=tuple(), +- host=tuple(), +- ), +- ), ++ value=netgroup1, ++ summary='Modified netgroup "%s"' % netgroup1, + result={ +- 'dn': fuzzy_netgroupdn, +- 'memberuser_group': (group1,), +- 'member_netgroup': (netgroup2,), +- 'cn': [netgroup1], +- 'description': [u'Updated netgroup 1'], +- 'nisdomainname': [u'%s' % api.env.domain], +- 'externalhost': [unknown_host], ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['newnisdom1'], ++ 'externalhost': [unknown_host], + }, + ), + ), + + + dict( +- desc='Remove group %r from netgroup %r' % (group1, netgroup1), ++ desc='Restore nisdomain of %r' % netgroup1, + command=( +- 'netgroup_remove_member', [netgroup1], dict(group=group1) ++ 'netgroup_mod', [netgroup1], ++ dict(nisdomainname='%s' % api.env.domain) + ), + expected=dict( +- completed=1, +- failed=dict( +- member=dict( +- netgroup=tuple(), +- ), +- memberuser=dict( +- group=tuple(), +- user=tuple(), +- ), +- memberhost=dict( +- hostgroup=tuple(), +- host=tuple(), +- ), +- ), ++ value=netgroup1, ++ summary='Modified netgroup "%s"' % netgroup1, + result={ +- 'dn': fuzzy_netgroupdn, +- 'member_netgroup': (netgroup2,), +- 'cn': [netgroup1], +- 'description': [u'Updated netgroup 1'], +- 'nisdomainname': [u'%s' % api.env.domain], +- 'externalhost': [unknown_host], ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], + }, + ), + ), + + + dict( +- desc='Remove netgroup %r from netgroup %r' % (netgroup2, netgroup1), ++ desc='Modify description using setattr for %r' % netgroup1, + command=( +- 'netgroup_remove_member', [netgroup1], dict(netgroup=netgroup2) ++ 'netgroup_mod', [netgroup1], ++ dict(setattr='description=setattr description') + ), + expected=dict( +- completed=1, +- failed=dict( +- member=dict( +- netgroup=tuple(), +- ), +- memberuser=dict( +- group=tuple(), +- user=tuple(), +- ), +- memberhost=dict( +- hostgroup=tuple(), +- host=tuple(), +- ), +- ), ++ value=netgroup1, ++ summary='Modified netgroup "%s"' % netgroup1, + result={ +- 'dn': fuzzy_netgroupdn, +- 'cn': [netgroup1], +- 'description': [u'Updated netgroup 1'], +- 'nisdomainname': [u'%s' % api.env.domain], +- 'externalhost': [unknown_host], ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['setattr description'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], + }, + ), + ), + + + dict( +- desc='Remove host %r from netgroup %r again' % (host1, netgroup1), ++ desc='Modify nisdomain using setattr for %r' % netgroup1, + command=( +- 'netgroup_remove_member', [netgroup1], dict(host=host1) ++ 'netgroup_mod', [netgroup1], ++ dict(setattr='nisdomainname=setattrnisdom') + ), + expected=dict( +- completed=0, +- failed=dict( +- member=dict( +- netgroup=tuple(), +- ), +- memberuser=dict( +- group=tuple(), +- user=tuple(), +- ), +- memberhost=dict( +- hostgroup=tuple(), +- host=[('%s' % host1, u'This entry is not a member')] +- ), +- ), ++ value=netgroup1, ++ summary='Modified netgroup "%s"' % netgroup1, + result={ +- 'dn': fuzzy_netgroupdn, +- 'cn': [netgroup1], +- 'description': [u'Updated netgroup 1'], +- 'nisdomainname': [u'%s' % api.env.domain], +- 'externalhost': [unknown_host], ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['setattr description'], ++ 'nisdomainname': ['setattrnisdom'], ++ 'externalhost': [unknown_host], + }, + ), + ), + + + dict( +- desc='Remove hostgroup %r from netgroup %r again' % (hostgroup1, netgroup1), ++ desc='Restore description and nisdomain for %r' % netgroup1, + command=( +- 'netgroup_remove_member', [netgroup1], dict(hostgroup=hostgroup1) ++ 'netgroup_mod', [netgroup1], ++ dict(description='Updated netgroup 1', ++ nisdomainname='%s' % api.env.domain) + ), + expected=dict( +- completed=0, +- failed=dict( +- member=dict( +- netgroup=tuple(), +- ), +- memberuser=dict( +- group=tuple(), +- user=tuple(), +- ), +- memberhost=dict( +- hostgroup=[('%s' % hostgroup1, u'This entry is not a member')], +- host=tuple(), +- ), +- ), ++ value=netgroup1, ++ summary='Modified netgroup "%s"' % netgroup1, + result={ +- 'dn': fuzzy_netgroupdn, +- 'cn': [netgroup1], +- 'description': [u'Updated netgroup 1'], +- 'nisdomainname': [u'%s' % api.env.domain], +- 'externalhost': [unknown_host], ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], + }, + ), + ), + + + dict( +- desc='Remove user %r from netgroup %r again' % (user1, netgroup1), ++ desc='Set externalhost using setattr for %r' % netgroup1, + command=( +- 'netgroup_remove_member', [netgroup1], dict(user=user1) ++ 'netgroup_mod', [netgroup1], ++ dict(setattr='externalhost=setattr_exthost') + ), + expected=dict( +- completed=0, +- failed=dict( +- member=dict( +- netgroup=tuple(), +- ), +- memberuser=dict( +- group=tuple(), +- user=[('%s' % user1, u'This entry is not a member')], +- ), +- memberhost=dict( +- hostgroup=tuple(), +- host=tuple(), +- ), +- ), ++ value=netgroup1, ++ summary='Modified netgroup "%s"' % netgroup1, + result={ +- 'dn': fuzzy_netgroupdn, +- 'cn': [netgroup1], +- 'description': [u'Updated netgroup 1'], +- 'nisdomainname': [u'%s' % api.env.domain], +- 'externalhost': [unknown_host], ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': ['setattr_exthost'], + }, + ), + ), + + + dict( +- desc='Remove group %r from netgroup %r again' % (group1, netgroup1), ++ desc='Add externalhost using addattr for %r' % netgroup1, + command=( +- 'netgroup_remove_member', [netgroup1], dict(group=group1) ++ 'netgroup_mod', [netgroup1], ++ dict(addattr='externalhost=addattr_exthost') + ), + expected=dict( +- completed=0, +- failed=dict( +- member=dict( +- netgroup=tuple(), +- ), +- memberuser=dict( +- group= [('%s' % group1, u'This entry is not a member')], +- user=tuple(), +- ), +- memberhost=dict( +- hostgroup=tuple(), +- host=tuple(), +- ), +- ), ++ value=netgroup1, ++ summary='Modified netgroup "%s"' % netgroup1, + result={ +- 'dn': fuzzy_netgroupdn, +- 'cn': [netgroup1], +- 'description': [u'Updated netgroup 1'], +- 'nisdomainname': [u'%s' % api.env.domain], +- 'externalhost': [unknown_host], ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': ['addattr_exthost', 'setattr_exthost'], + }, + ), + ), + + + dict( +- desc='Remove netgroup %r from netgroup %r again' % (netgroup2, netgroup1), ++ desc='Delete one externalhost using delattr for %r' % netgroup1, + command=( +- 'netgroup_remove_member', [netgroup1], dict(netgroup=netgroup2) ++ 'netgroup_mod', [netgroup1], ++ dict(delattr='externalhost=setattr_exthost') + ), + expected=dict( +- completed=0, +- failed=dict( +- member=dict( +- netgroup=[('%s' % netgroup2, u'This entry is not a member')], +- ), +- memberuser=dict( +- group=tuple(), +- user=tuple(), +- ), +- memberhost=dict( +- hostgroup=tuple(), +- host=tuple(), +- ), +- ), ++ value=netgroup1, ++ summary='Modified netgroup "%s"' % netgroup1, + result={ +- 'dn': fuzzy_netgroupdn, +- 'cn': [netgroup1], +- 'description': [u'Updated netgroup 1'], +- 'nisdomainname': [u'%s' % api.env.domain], +- 'externalhost': [unknown_host], ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': ['addattr_exthost'], + }, + ), + ), + + + dict( +- desc='Delete %r' % netgroup1, +- command=('netgroup_del', [netgroup1], {}), ++ desc='Clear all externalhosts using setattr for %r' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(setattr='externalhost=') ++ ), + expected=dict( +- value=[netgroup1], +- summary=u'Deleted netgroup "%s"' % netgroup1, +- result=dict(failed=[]), ++ value=netgroup1, ++ summary='Modified netgroup "%s"' % netgroup1, ++ result={ ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ }, + ), + ), + ++ ++ dict( ++ desc='Restore externalhost for %r' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(addattr='externalhost=%s' % unknown_host) ++ ), ++ expected=dict( ++ value=netgroup1, ++ summary='Modified netgroup "%s"' % netgroup1, ++ result={ ++ 'memberhost_host': (host1,), ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Modify %r invalid usercat' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(usercategory='badcat') ++ ), ++ expected=errors.ValidationError( ++ name='usercat', error="must be 'all'"), ++ ), ++ ++ ++ dict( ++ desc='Modify %r invalid hostcat' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(hostcategory='badcat') ++ ), ++ expected=errors.ValidationError( ++ name='hostcat', error="must be 'all'"), ++ ), ++ ++ ++ dict( ++ desc='Modify %r with invalid addattr on nisDomainName' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(addattr='nisdomainname=seconddomain') ++ ), ++ expected=errors.OnlyOneValueAllowed(attr='nisdomainname'), ++ ), ++ ++ ++ dict( ++ desc='Modify %r with invalid setattr on ipauniqueid' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(setattr='ipauniqueid=mynew-unique-id') ++ ), ++ expected=errors.ValidationError( ++ name='ipauniqueid', error='attribute is not configurable'), ++ ), ++ ++ ++ dict( ++ desc='Modify %r with invalid setattr on dn' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(setattr='dn=cn=newdn') ++ ), ++ expected=errors.ObjectclassViolation( ++ info='attribute "distinguishedName" not allowed'), ++ ), ++ ++ ++ dict( ++ desc='Modify %r with invalid membergroup attribute' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(setattr='membergroup=%s' % group1) ++ ), ++ expected=errors.ObjectclassViolation( ++ info='attribute "membergroup" not allowed'), ++ ), ++ ++ ++ dict( ++ desc='Modify %r with invalid memberhostgroup attr' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(setattr='memberhostgroup=%s' % hostgroup1) ++ ), ++ expected=errors.ObjectclassViolation( ++ info='attribute "memberhostgroup" not allowed'), ++ ), ++ ++ ++ dict( ++ desc='Modify %r with invalid membernetgroup attribute' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(setattr='membernetgroup=%s' % netgroup2) ++ ), ++ expected=errors.ObjectclassViolation( ++ info='attribute "membernetgroup" not allowed'), ++ ), ++ ++ ++ dict( ++ desc='Modify %r with invalid addattr on description' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(addattr='description=newdesc') ++ ), ++ expected=errors.OnlyOneValueAllowed(attr='description'), ++ ), ++ ++ ++ dict( ++ desc='Modify %r with invalid nisdomain commas' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(nisdomainname='test1,test2') ++ ), ++ expected=errors.ValidationError( ++ name='nisdomain', ++ error='may only include letters, numbers, _, -, and .'), ++ ), ++ ++ ++ dict( ++ desc='Modify %r with invalid setattr nisdomain commas' % netgroup1, ++ command=( ++ 'netgroup_mod', [netgroup1], ++ dict(setattr='nisdomainname=test1,test2') ++ ), ++ expected=errors.ValidationError( ++ name='nisdomainname', ++ error='may only include letters, numbers, _, -, and .'), ++ ), ++ ++ ++ dict( ++ desc='Remove host %r from netgroup %r' % (host1, netgroup1), ++ command=( ++ 'netgroup_remove_member', [netgroup1], dict(host=host1) ++ ), ++ expected=dict( ++ completed=1, ++ failed=dict( ++ member=dict( ++ netgroup=tuple(), ++ ), ++ memberuser=dict( ++ group=tuple(), ++ user=tuple(), ++ ), ++ memberhost=dict( ++ hostgroup=tuple(), ++ host=tuple(), ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'memberhost_hostgroup': (hostgroup1,), ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Remove hostgroup %r from netgroup %r' % ( ++ hostgroup1, netgroup1), ++ command=( ++ 'netgroup_remove_member', [netgroup1], ++ dict(hostgroup=hostgroup1) ++ ), ++ expected=dict( ++ completed=1, ++ failed=dict( ++ member=dict( ++ netgroup=tuple(), ++ ), ++ memberuser=dict( ++ group=tuple(), ++ user=tuple(), ++ ), ++ memberhost=dict( ++ hostgroup=tuple(), ++ host=tuple(), ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'memberuser_user': (user1,), ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Remove user %r from netgroup %r' % (user1, netgroup1), ++ command=( ++ 'netgroup_remove_member', [netgroup1], dict(user=user1) ++ ), ++ expected=dict( ++ completed=1, ++ failed=dict( ++ member=dict( ++ netgroup=tuple(), ++ ), ++ memberuser=dict( ++ group=tuple(), ++ user=tuple(), ++ ), ++ memberhost=dict( ++ hostgroup=tuple(), ++ host=tuple(), ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'memberuser_group': (group1,), ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Remove group %r from netgroup %r' % (group1, netgroup1), ++ command=( ++ 'netgroup_remove_member', [netgroup1], dict(group=group1) ++ ), ++ expected=dict( ++ completed=1, ++ failed=dict( ++ member=dict( ++ netgroup=tuple(), ++ ), ++ memberuser=dict( ++ group=tuple(), ++ user=tuple(), ++ ), ++ memberhost=dict( ++ hostgroup=tuple(), ++ host=tuple(), ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'member_netgroup': (netgroup2,), ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Remove netgroup %r from netgroup %r' % (netgroup2, netgroup1), ++ command=( ++ 'netgroup_remove_member', [netgroup1], dict(netgroup=netgroup2) ++ ), ++ expected=dict( ++ completed=1, ++ failed=dict( ++ member=dict( ++ netgroup=tuple(), ++ ), ++ memberuser=dict( ++ group=tuple(), ++ user=tuple(), ++ ), ++ memberhost=dict( ++ hostgroup=tuple(), ++ host=tuple(), ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Remove host %r from netgroup %r again' % (host1, netgroup1), ++ command=( ++ 'netgroup_remove_member', [netgroup1], dict(host=host1) ++ ), ++ expected=dict( ++ completed=0, ++ failed=dict( ++ member=dict( ++ netgroup=tuple(), ++ ), ++ memberuser=dict( ++ group=tuple(), ++ user=tuple(), ++ ), ++ memberhost=dict( ++ hostgroup=tuple(), ++ host=[('%s' % host1, 'This entry is not a member')] ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Remove hostgroup %r from netgroup %r again' % ( ++ hostgroup1, netgroup1), ++ command=( ++ 'netgroup_remove_member', [netgroup1], ++ dict(hostgroup=hostgroup1) ++ ), ++ expected=dict( ++ completed=0, ++ failed=dict( ++ member=dict( ++ netgroup=tuple(), ++ ), ++ memberuser=dict( ++ group=tuple(), ++ user=tuple(), ++ ), ++ memberhost=dict( ++ hostgroup=[( ++ '%s' % hostgroup1, ++ 'This entry is not a member')], ++ host=tuple(), ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Remove user %r from netgroup %r again' % (user1, netgroup1), ++ command=( ++ 'netgroup_remove_member', [netgroup1], dict(user=user1) ++ ), ++ expected=dict( ++ completed=0, ++ failed=dict( ++ member=dict( ++ netgroup=tuple(), ++ ), ++ memberuser=dict( ++ group=tuple(), ++ user=[('%s' % user1, 'This entry is not a member')], ++ ), ++ memberhost=dict( ++ hostgroup=tuple(), ++ host=tuple(), ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Remove group %r from netgroup %r again' % (group1, netgroup1), ++ command=( ++ 'netgroup_remove_member', [netgroup1], dict(group=group1) ++ ), ++ expected=dict( ++ completed=0, ++ failed=dict( ++ member=dict( ++ netgroup=tuple(), ++ ), ++ memberuser=dict( ++ group=[('%s' % group1, 'This entry is not a member')], ++ user=tuple(), ++ ), ++ memberhost=dict( ++ hostgroup=tuple(), ++ host=tuple(), ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Remove netgroup %r from netgroup %r again' % ( ++ netgroup2, netgroup1), ++ command=( ++ 'netgroup_remove_member', [netgroup1], dict(netgroup=netgroup2) ++ ), ++ expected=dict( ++ completed=0, ++ failed=dict( ++ member=dict( ++ netgroup=[( ++ '%s' % netgroup2, ++ 'This entry is not a member')], ++ ), ++ memberuser=dict( ++ group=tuple(), ++ user=tuple(), ++ ), ++ memberhost=dict( ++ hostgroup=tuple(), ++ host=tuple(), ++ ), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [netgroup1], ++ 'description': ['Updated netgroup 1'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'externalhost': [unknown_host], ++ }, ++ ), ++ ), ++ ++ ++ dict( ++ desc='Remove member from non-existent netgroup', ++ command=( ++ 'netgroup_remove_member', ['notfoundnetgroup'], ++ dict(user=user1) ++ ), ++ expected=errors.NotFound( ++ reason='notfoundnetgroup: netgroup not found'), ++ ), ++ ++ ++ dict( ++ desc='Create %r with usercat=all' % netgroup_usercat_mod, ++ command=( ++ 'netgroup_add', [netgroup_usercat_mod], ++ dict(description='Test usercat mod', ++ nisdomainname=custom_nisdomain, usercategory='all') ++ ), ++ expected=dict( ++ value=netgroup_usercat_mod, ++ summary='Added netgroup "%s"' % netgroup_usercat_mod, ++ result=dict( ++ dn=fuzzy_netgroupdn, ++ cn=[netgroup_usercat_mod], ++ objectclass=objectclasses.netgroup, ++ description=['Test usercat mod'], ++ nisdomainname=[custom_nisdomain], ++ usercategory=['all'], ++ ipauniqueid=[fuzzy_uuid], ++ ), ++ ), ++ ), ++ ++ dict( ++ desc='Clear usercat via setattr for %r' % netgroup_usercat_mod, ++ command=( ++ 'netgroup_mod', [netgroup_usercat_mod], ++ dict(setattr='usercategory=') ++ ), ++ expected=dict( ++ value=netgroup_usercat_mod, ++ summary='Modified netgroup "%s"' % netgroup_usercat_mod, ++ result={ ++ 'cn': [netgroup_usercat_mod], ++ 'description': ['Test usercat mod'], ++ 'nisdomainname': [custom_nisdomain], ++ }, ++ ), ++ ), ++ ++ dict( ++ desc='Create %r with hostcat=all' % netgroup_hostcat_mod, ++ command=( ++ 'netgroup_add', [netgroup_hostcat_mod], ++ dict(description='Test hostcat mod', ++ nisdomainname=custom_nisdomain, hostcategory='all') ++ ), ++ expected=dict( ++ value=netgroup_hostcat_mod, ++ summary='Added netgroup "%s"' % netgroup_hostcat_mod, ++ result=dict( ++ dn=fuzzy_netgroupdn, ++ cn=[netgroup_hostcat_mod], ++ objectclass=objectclasses.netgroup, ++ description=['Test hostcat mod'], ++ nisdomainname=[custom_nisdomain], ++ hostcategory=['all'], ++ ipauniqueid=[fuzzy_uuid], ++ ), ++ ), ++ ), ++ ++ dict( ++ desc='Clear hostcat via setattr for %r' % netgroup_hostcat_mod, ++ command=( ++ 'netgroup_mod', [netgroup_hostcat_mod], ++ dict(setattr='hostcategory=') ++ ), ++ expected=dict( ++ value=netgroup_hostcat_mod, ++ summary='Modified netgroup "%s"' % netgroup_hostcat_mod, ++ result={ ++ 'cn': [netgroup_hostcat_mod], ++ 'description': ['Test hostcat mod'], ++ 'nisdomainname': [custom_nisdomain], ++ }, ++ ), ++ ), ++ ++ ++ # ===== netgroup-find tests ===== ++ # Setup: Create test entities for find tests ++ dict( ++ desc='Create user %r for find tests' % fnd_user1, ++ command=( ++ 'user_add', [fnd_user1], ++ dict(givenname='Find', sn='User1') ++ ), ++ expected=dict( ++ value=fnd_user1, ++ summary='Added user "%s"' % fnd_user1, ++ result=get_user_result(fnd_user1, 'Find', 'User1', 'add'), ++ ), ++ ), ++ ++ dict( ++ desc='Create user %r for find tests' % fnd_user2, ++ command=( ++ 'user_add', [fnd_user2], ++ dict(givenname='Find', sn='User2') ++ ), ++ expected=dict( ++ value=fnd_user2, ++ summary='Added user "%s"' % fnd_user2, ++ result=get_user_result(fnd_user2, 'Find', 'User2', 'add'), ++ ), ++ ), ++ ++ dict( ++ desc='Create group %r for find tests' % fnd_group, ++ command=( ++ 'group_add', [fnd_group], ++ dict(description='test') ++ ), ++ expected=dict( ++ value=fnd_group, ++ summary='Added group "%s"' % fnd_group, ++ result=dict( ++ cn=[fnd_group], ++ description=['test'], ++ gidnumber=[fuzzy_digits], ++ objectclass=fuzzy_set_optional_oc( ++ objectclasses.posixgroup, 'ipantgroupattrs'), ++ ipauniqueid=[fuzzy_uuid], ++ dn=DN( ++ ('cn', fnd_group), ('cn', 'groups'), ++ ('cn', 'accounts'), api.env.basedn), ++ ), ++ ), ++ ), ++ ++ dict( ++ desc='Create host %r for find tests' % fnd_host, ++ command=( ++ 'host_add', [fnd_host], ++ dict( ++ description='Test host for find', ++ l='Undisclosed location', ++ force=True, ++ ) ++ ), ++ expected=dict( ++ value=fnd_host, ++ summary='Added host "%s"' % fnd_host, ++ result=dict( ++ dn=DN( ++ ('fqdn', fnd_host), ('cn', 'computers'), ++ ('cn', 'accounts'), api.env.basedn), ++ fqdn=[fnd_host], ++ description=['Test host for find'], ++ l=['Undisclosed location'], ++ krbprincipalname=[ ++ 'host/%s@%s' % (fnd_host, api.env.realm)], ++ krbcanonicalname=[ ++ 'host/%s@%s' % (fnd_host, api.env.realm)], ++ objectclass=objectclasses.host, ++ ipauniqueid=[fuzzy_uuid], ++ managedby_host=[fnd_host], ++ has_keytab=False, ++ has_password=False, ++ ), ++ ), ++ ), ++ ++ dict( ++ desc='Create hostgroup %r for find tests' % fnd_hostgroup, ++ command=( ++ 'hostgroup_add', [fnd_hostgroup], ++ dict(description='test') ++ ), ++ expected=dict( ++ value=fnd_hostgroup, ++ summary='Added hostgroup "%s"' % fnd_hostgroup, ++ result=dict( ++ dn=DN( ++ ('cn', fnd_hostgroup), ('cn', 'hostgroups'), ++ ('cn', 'accounts'), api.env.basedn), ++ cn=[fnd_hostgroup], ++ objectclass=objectclasses.hostgroup, ++ description=['test'], ++ mepmanagedentry=[DN( ++ ('cn', fnd_hostgroup), ('cn', 'ng'), ('cn', 'alt'), ++ api.env.basedn)], ++ ipauniqueid=[fuzzy_uuid], ++ ), ++ ), ++ ), ++ ++ # Create netgroups with various attributes for find tests ++ dict( ++ desc='Create netgroup %r with nisdomain for find tests' % fnd_ng1, ++ command=( ++ 'netgroup_add', [fnd_ng1], ++ dict(description='findtest', nisdomainname=fnd_nisdomain) ++ ), ++ expected=dict( ++ value=fnd_ng1, ++ summary='Added netgroup "%s"' % fnd_ng1, ++ result=dict( ++ dn=fuzzy_netgroupdn, ++ cn=[fnd_ng1], ++ objectclass=objectclasses.netgroup, ++ description=['findtest'], ++ nisdomainname=[fnd_nisdomain], ++ ipauniqueid=[fuzzy_uuid], ++ ), ++ ), ++ ), ++ ++ dict( ++ desc='Create netgroup %r with usercat/hostcat=all' % fnd_ng2, ++ command=( ++ 'netgroup_add', [fnd_ng2], ++ dict(description='findtest2', ++ usercategory='all', hostcategory='all') ++ ), ++ expected=dict( ++ value=fnd_ng2, ++ summary='Added netgroup "%s"' % fnd_ng2, ++ result=dict( ++ dn=fuzzy_netgroupdn, ++ cn=[fnd_ng2], ++ objectclass=objectclasses.netgroup, ++ description=['findtest2'], ++ nisdomainname=['%s' % api.env.domain], ++ usercategory=['all'], ++ hostcategory=['all'], ++ ipauniqueid=[fuzzy_uuid], ++ ), ++ ), ++ ), ++ ++ dict( ++ desc='Create netgroup %r for find tests' % fnd_ng3, ++ command=( ++ 'netgroup_add', [fnd_ng3], ++ dict(description='findtest3') ++ ), ++ expected=dict( ++ value=fnd_ng3, ++ summary='Added netgroup "%s"' % fnd_ng3, ++ result=dict( ++ dn=fuzzy_netgroupdn, ++ cn=[fnd_ng3], ++ objectclass=objectclasses.netgroup, ++ description=['findtest3'], ++ nisdomainname=['%s' % api.env.domain], ++ ipauniqueid=[fuzzy_uuid], ++ ), ++ ), ++ ), ++ ++ # Add members to fnd_ng1 ++ dict( ++ desc='Add user %r to netgroup %r' % (fnd_user1, fnd_ng1), ++ command=( ++ 'netgroup_add_member', [fnd_ng1], ++ dict(user=fnd_user1) ++ ), ++ expected=dict( ++ completed=1, ++ failed=dict( ++ member=dict(netgroup=tuple()), ++ memberuser=dict(group=tuple(), user=tuple()), ++ memberhost=dict(hostgroup=tuple(), host=tuple()), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'memberuser_user': (fnd_user1,), ++ 'cn': [fnd_ng1], ++ 'description': ['findtest'], ++ 'nisdomainname': [fnd_nisdomain], ++ }, ++ ), ++ ), ++ ++ dict( ++ desc='Add group %r to netgroup %r' % (fnd_group, fnd_ng1), ++ command=( ++ 'netgroup_add_member', [fnd_ng1], ++ dict(group=fnd_group) ++ ), ++ expected=dict( ++ completed=1, ++ failed=dict( ++ member=dict(netgroup=tuple()), ++ memberuser=dict(group=tuple(), user=tuple()), ++ memberhost=dict(hostgroup=tuple(), host=tuple()), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'memberuser_user': (fnd_user1,), ++ 'memberuser_group': (fnd_group,), ++ 'cn': [fnd_ng1], ++ 'description': ['findtest'], ++ 'nisdomainname': [fnd_nisdomain], ++ }, ++ ), ++ ), ++ ++ dict( ++ desc='Add host %r to netgroup %r' % (fnd_host, fnd_ng1), ++ command=( ++ 'netgroup_add_member', [fnd_ng1], ++ dict(host=fnd_host) ++ ), ++ expected=dict( ++ completed=1, ++ failed=dict( ++ member=dict(netgroup=tuple()), ++ memberuser=dict(group=tuple(), user=tuple()), ++ memberhost=dict(hostgroup=tuple(), host=tuple()), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'memberuser_user': (fnd_user1,), ++ 'memberuser_group': (fnd_group,), ++ 'memberhost_host': (fnd_host,), ++ 'cn': [fnd_ng1], ++ 'description': ['findtest'], ++ 'nisdomainname': [fnd_nisdomain], ++ }, ++ ), ++ ), ++ ++ dict( ++ desc='Add hostgroup %r to netgroup %r' % (fnd_hostgroup, fnd_ng1), ++ command=( ++ 'netgroup_add_member', [fnd_ng1], ++ dict(hostgroup=fnd_hostgroup) ++ ), ++ expected=dict( ++ completed=1, ++ failed=dict( ++ member=dict(netgroup=tuple()), ++ memberuser=dict(group=tuple(), user=tuple()), ++ memberhost=dict(hostgroup=tuple(), host=tuple()), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'memberuser_user': (fnd_user1,), ++ 'memberuser_group': (fnd_group,), ++ 'memberhost_host': (fnd_host,), ++ 'memberhost_hostgroup': (fnd_hostgroup,), ++ 'cn': [fnd_ng1], ++ 'description': ['findtest'], ++ 'nisdomainname': [fnd_nisdomain], ++ }, ++ ), ++ ), ++ ++ # Add fnd_ng1 as member of fnd_ng3 (nested netgroup) ++ dict( ++ desc='Add netgroup %r to netgroup %r' % (fnd_ng1, fnd_ng3), ++ command=( ++ 'netgroup_add_member', [fnd_ng3], ++ dict(netgroup=fnd_ng1) ++ ), ++ expected=dict( ++ completed=1, ++ failed=dict( ++ member=dict(netgroup=tuple()), ++ memberuser=dict(group=tuple(), user=tuple()), ++ memberhost=dict(hostgroup=tuple(), host=tuple()), ++ ), ++ result={ ++ 'dn': fuzzy_netgroupdn, ++ 'member_netgroup': (fnd_ng1,), ++ 'cn': [fnd_ng3], ++ 'description': ['findtest3'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ }, ++ ), ++ ), ++ ++ # Find tests - positive scenarios ++ dict( ++ desc='Find netgroup by exact name %r' % fnd_ng1, ++ command=('netgroup_find', [], dict(cn=fnd_ng1)), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 netgroup matched', ++ result=[ ++ { ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [fnd_ng1], ++ 'description': ['findtest'], ++ 'nisdomainname': [fnd_nisdomain], ++ }, ++ ], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup by description findtest', ++ command=('netgroup_find', [], dict(description='findtest')), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 netgroup matched', ++ result=[ ++ { ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [fnd_ng1], ++ 'description': ['findtest'], ++ 'nisdomainname': [fnd_nisdomain], ++ }, ++ ], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup by nisdomain %r' % fnd_nisdomain, ++ command=('netgroup_find', [], dict(nisdomainname=fnd_nisdomain)), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 netgroup matched', ++ result=[ ++ { ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [fnd_ng1], ++ 'description': ['findtest'], ++ 'nisdomainname': [fnd_nisdomain], ++ }, ++ ], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup by usercat=all', ++ command=('netgroup_find', [], dict(usercategory='all')), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 netgroup matched', ++ result=[ ++ { ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [fnd_ng2], ++ 'description': ['findtest2'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'usercategory': ['all'], ++ 'hostcategory': ['all'], ++ }, ++ ], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup by hostcat=all', ++ command=('netgroup_find', [], dict(hostcategory='all')), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 netgroup matched', ++ result=[ ++ { ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [fnd_ng2], ++ 'description': ['findtest2'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ 'usercategory': ['all'], ++ 'hostcategory': ['all'], ++ }, ++ ], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup by user %r' % fnd_user1, ++ command=('netgroup_find', [], dict(user=fnd_user1)), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 netgroup matched', ++ result=[ ++ { ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [fnd_ng1], ++ 'description': ['findtest'], ++ 'nisdomainname': [fnd_nisdomain], ++ }, ++ ], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup by group %r' % fnd_group, ++ command=('netgroup_find', [], dict(group=fnd_group)), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 netgroup matched', ++ result=[ ++ { ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [fnd_ng1], ++ 'description': ['findtest'], ++ 'nisdomainname': [fnd_nisdomain], ++ }, ++ ], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup by host %r' % fnd_host, ++ command=('netgroup_find', [], dict(host=fnd_host)), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 netgroup matched', ++ result=[ ++ { ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [fnd_ng1], ++ 'description': ['findtest'], ++ 'nisdomainname': [fnd_nisdomain], ++ }, ++ ], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup by hostgroup %r' % fnd_hostgroup, ++ command=('netgroup_find', [], dict(hostgroup=fnd_hostgroup)), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 netgroup matched', ++ result=[ ++ { ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [fnd_ng1], ++ 'description': ['findtest'], ++ 'nisdomainname': [fnd_nisdomain], ++ }, ++ ], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup by member netgroup %r' % fnd_ng1, ++ command=('netgroup_find', [], dict(netgroup=fnd_ng1)), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 netgroup matched', ++ result=[ ++ { ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [fnd_ng3], ++ 'description': ['findtest3'], ++ 'nisdomainname': ['%s' % api.env.domain], ++ }, ++ ], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup by in_netgroup %r' % fnd_ng3, ++ command=('netgroup_find', [], dict(in_netgroup=fnd_ng3)), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 netgroup matched', ++ result=[ ++ { ++ 'dn': fuzzy_netgroupdn, ++ 'cn': [fnd_ng1], ++ 'description': ['findtest'], ++ 'nisdomainname': [fnd_nisdomain], ++ }, ++ ], ++ ), ++ ), ++ ++ # Find tests - negative scenarios (0 matches) ++ dict( ++ desc='Find netgroup with nonexistent name returns 0', ++ command=('netgroup_find', ['nonexistent_fnd'], {}), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup with bad user returns 0', ++ command=('netgroup_find', [], dict(user='baduser_fnd')), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup with bad group returns 0', ++ command=('netgroup_find', [], dict(group='badgroup_fnd')), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup with bad host returns 0', ++ command=('netgroup_find', [], dict(host='badhost_fnd')), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup with bad hostgroup returns 0', ++ command=('netgroup_find', [], dict(hostgroup='badhg_fnd')), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup with bad netgroup returns 0', ++ command=('netgroup_find', [], dict(netgroup='badng_fnd')), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ # Invalid parameter type tests ++ dict( ++ desc='Find netgroup with invalid timelimit type', ++ command=('netgroup_find', [], dict(timelimit='bad')), ++ expected=errors.ConversionError( ++ name='timelimit', ++ error='must be an integer'), ++ ), ++ ++ dict( ++ desc='Find netgroup with invalid sizelimit type', ++ command=('netgroup_find', [], dict(sizelimit='bad')), ++ expected=errors.ConversionError( ++ name='sizelimit', ++ error='must be an integer'), ++ ), ++ ++ # Space input tests (bz798792) ++ dict( ++ desc='Find netgroup with space in netgroup param (bz798792)', ++ command=('netgroup_find', [], dict(netgroup=' ')), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup with space in user param (bz798792)', ++ command=('netgroup_find', [], dict(user=' ')), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup with space in group param (bz798792)', ++ command=('netgroup_find', [], dict(group=' ')), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup with space in host param (bz798792)', ++ command=('netgroup_find', [], dict(host=' ')), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup with space in hostgroup param (bz798792)', ++ command=('netgroup_find', [], dict(hostgroup=' ')), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), ++ ++ dict( ++ desc='Find netgroup with space in in_netgroup param (bz798792)', ++ command=('netgroup_find', [], dict(in_netgroup=' ')), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 netgroups matched', ++ result=[], ++ ), ++ ), + ] + + # No way to convert this test just yet. +-- +2.52.0 + diff --git a/0036-ipatests-Add-user-principal-ipa-getkeytab-and-ipa-rm.patch b/0036-ipatests-Add-user-principal-ipa-getkeytab-and-ipa-rm.patch new file mode 100644 index 0000000..afe6dec --- /dev/null +++ b/0036-ipatests-Add-user-principal-ipa-getkeytab-and-ipa-rm.patch @@ -0,0 +1,543 @@ +From 9a8553fc297987b006b28ade2098d613f9b5360f Mon Sep 17 00:00:00 2001 +From: Jay Gondaliya +Date: Thu, 12 Mar 2026 19:03:24 +0530 +Subject: [PATCH] ipatests: Add user-principal ipa-getkeytab and + ipa-rmkeytab cmdline tests +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Port bash acceptance tests (getkeytab_001–getkeytab_007, rmkeytab_001-rmkeytab_003) from downstream to cmdline tests in test_ipagetkeytab.py: + + test_getkeytab_users: + getkeytab_001: quiet mode, insufficient access, empty ccache + getkeytab_002: --server / -s with valid and invalid hostnames, DNS-based server discovery when -s is omitted + getkeytab_003: --principal / -p with unknown, invalid-realm, + bare, and realm-qualified principals + getkeytab_004: --keytab / -k creation, enctype content, invalid path + getkeytab_005: -e single enctype filtering and kinit validation + getkeytab_006: --password / -P key rotation + getkeytab_007: -D / -w bind DN error cases (empty DN, wrong + password, missing password) + + test_rmkeytab_cmd: + rmkeytab_001: removal by principal (valid and invalid) + rmkeytab_002: removal by realm + rmkeytab_003: invalid keytab path + +Tests that duplicate existing coverage are skipped with comments referencing the original tests. Parametrized variants are used where flags accept both short and long forms. + +Signed-off-by: Jay Gondaliya +Fixes: https://pagure.io/freeipa/issue/9957 +Assisted-by: Claude +Reviewed-By: Rob Crittenden +Reviewed-By: Florence Blanc-Renaud +--- + ipatests/test_cmdline/test_ipagetkeytab.py | 459 +++++++++++++++++++++ + 1 file changed, 459 insertions(+) + +diff --git a/ipatests/test_cmdline/test_ipagetkeytab.py b/ipatests/test_cmdline/test_ipagetkeytab.py +index c4678a76d..9ec9efae6 100755 +--- a/ipatests/test_cmdline/test_ipagetkeytab.py ++++ b/ipatests/test_cmdline/test_ipagetkeytab.py +@@ -22,6 +22,7 @@ Test `ipa-getkeytab` + + from __future__ import absolute_import + ++import configparser + import os + import shutil + import tempfile +@@ -40,6 +41,28 @@ from ipatests.test_xmlrpc.tracker import host_plugin, service_plugin + from ipatests.test_xmlrpc.xmlrpc_test import fuzzy_digits, add_oc + from contextlib import contextmanager + ++from ipatests.test_xmlrpc.tracker.user_plugin import UserTracker ++from ipatests.util import unlock_principal_password ++ ++GK_USER1 = 'gkuser1' ++GK_USER2 = 'gkuser2' ++GK_INIT_PW = 'TempSecret123!' ++GK_USER_PW = 'Secret123' ++ ++CRYPTO_POLICIES_KRB5 = '/etc/krb5.conf.d/crypto-policies' ++ ++ ++def get_permitted_enctypes(): ++ """Read permitted encryption types from the system crypto-policies. ++ ++ Returns a list of enctype name strings, e.g. ++ ['aes256-cts-hmac-sha1-96', 'aes128-cts-hmac-sha1-96', ...]. ++ """ ++ cfg = configparser.ConfigParser() ++ cfg.read(CRYPTO_POLICIES_KRB5) ++ raw = cfg.get('libdefaults', 'permitted_enctypes', fallback='') ++ return [e for e in raw.split() if e] ++ + + @contextmanager + def use_keytab(principal, keytab): +@@ -63,6 +86,57 @@ def use_keytab(principal, keytab): + setattr(context, 'principal', old_principal) + + ++@contextmanager ++def kinit_as_user(principal, password): ++ """Kinit as *principal* into a private ccache; yield its path. ++ ++ private_ccache() already sets KRB5CCNAME in os.environ, so callers ++ (and ipautil.run) inherit it automatically. ++ """ ++ with private_ccache() as ccache_path: ++ ipautil.run( ++ ['kinit', principal], ++ stdin=password + '\n', ++ raiseonerr=True, ++ capture_output=True, ++ capture_error=True, ++ ) ++ yield ccache_path ++ ++ ++def run_getkeytab(args, env=None, stdin=None): ++ """Run ipa-getkeytab with arbitrary arguments.""" ++ return ipautil.run( ++ [paths.IPA_GETKEYTAB] + list(args), ++ raiseonerr=False, ++ capture_output=True, ++ capture_error=True, ++ stdin=stdin, ++ env=env, ++ ) ++ ++ ++def run_rmkeytab(args, env=None): ++ """Run ipa-rmkeytab with arbitrary arguments.""" ++ return ipautil.run( ++ [paths.IPA_RMKEYTAB] + list(args), ++ raiseonerr=False, ++ capture_output=True, ++ capture_error=True, ++ env=env, ++ ) ++ ++ ++def klist_keytab(keytab_path): ++ """Run ``klist -ekt`` and return the result object.""" ++ return ipautil.run( ++ ['klist', '-ekt', keytab_path], ++ raiseonerr=False, ++ capture_output=True, ++ capture_error=True, ++ ) ++ ++ + @pytest.fixture(scope='class') + def test_host(request): + host_tracker = host_plugin.HostTracker(u'test-host') +@@ -76,6 +150,20 @@ def test_service(request, test_host, keytab_retrieval_setup): + return service_tracker.make_fixture(request) + + ++@pytest.fixture(scope='class') ++def gk_users(request, keytab_retrieval_setup): ++ """Create GK_USER1 and GK_USER2; delete them after the class.""" ++ for uid in (GK_USER1, GK_USER2): ++ tracker = UserTracker( ++ name=uid, givenname='Test', sn='GKUser', ++ userpassword=GK_INIT_PW, ++ ) ++ tracker.make_fixture(request) ++ tracker.make_create_command()() ++ tracker.exists = True ++ unlock_principal_password(uid, GK_INIT_PW, GK_USER_PW) ++ ++ + @pytest.mark.needs_ipaapi + class KeytabRetrievalTest(cmdline_test): + """ +@@ -134,6 +222,22 @@ class KeytabRetrievalTest(cmdline_test): + rc = result.returncode + assert rc == retcode + ++ def _get_user1_keytab(self, keytab_path=None): ++ """Populate *keytab_path* (default self.keytabname) with GK_USER1's ++ keytab.""" ++ keytab = keytab_path or self.keytabname ++ run_getkeytab( ++ ['-s', api.env.host, '-p', GK_USER1, '-k', keytab] ++ ) ++ ++ def _assert_getkeytab_succeeds(self, result): ++ """Assert ipa-getkeytab returned success with the expected message.""" ++ assert result.returncode == 0 ++ assert ( ++ 'Keytab successfully retrieved and stored in:' ++ in result.error_output ++ ) ++ + + @pytest.mark.tier0 + class test_ipagetkeytab(KeytabRetrievalTest): +@@ -495,3 +599,358 @@ class test_smb_service(KeytabRetrievalTest): + ipanthash = entry.single_value.get('ipanthash') + conn.disconnect() + assert ipanthash != b'MagicRegen', 'LDBM backend entry corruption' ++ ++ ++# ----------------------------------------------------------------------- ++# User-principal tests ported from bash acceptance tests ++# (t.ipa-get-rm-keytabs.sh: getkeytab_001 – getkeytab_006) ++# ----------------------------------------------------------------------- ++ ++ ++@pytest.mark.tier1 ++@pytest.mark.usefixtures('gk_users') ++class test_getkeytab_users(KeytabRetrievalTest): ++ """ ++ User-principal ipa-getkeytab tests (bash getkeytab_001 – getkeytab_006). ++ """ ++ command = paths.IPA_GETKEYTAB ++ ++ def _ensure_keytab_absent(self, keytab_path=None): ++ try: ++ os.unlink(keytab_path or self.keytabname) ++ except OSError: ++ pass ++ ++ # --- getkeytab_001: quiet mode, access rights, missing ccache --- ++ ++ def test_insufficient_access_as_other_user(self): ++ """Retrieving another user's keytab without admin rights fails.""" ++ principal1 = '{}@{}'.format(GK_USER1, api.env.realm) ++ principal2 = '{}@{}'.format(GK_USER2, api.env.realm) ++ with kinit_as_user(principal1, GK_USER_PW): ++ result = run_getkeytab( ++ ['-s', api.env.host, ++ '-p', principal2, ++ '-k', self.keytabname], ++ ) ++ assert result.returncode == 9 ++ assert ( ++ 'Failed to parse result: Insufficient access rights' ++ in result.error_output ++ ) ++ ++ def test_empty_ccache_returns_exit6(self): ++ """An empty ccache (simulating kdestroy) returns exit 6.""" ++ principal1 = '{}@{}'.format(GK_USER1, api.env.realm) ++ with private_ccache(): ++ result = run_getkeytab( ++ ['-s', api.env.host, '-p', principal1, ++ '-k', self.keytabname], ++ ) ++ assert result.returncode == 6 ++ assert ( ++ 'Kerberos User Principal not found. ' ++ 'Do you have a valid Credential Cache?' ++ in result.error_output ++ ) ++ ++ def test_quiet_suppresses_success_message(self): ++ """-q must suppress the keytab-stored success message. ++ ++ Note: test_6_quiet_mode checks -q returncode with a service ++ principal but does not verify the message is absent. ++ """ ++ result = run_getkeytab( ++ ['-q', '-s', api.env.host, '-p', GK_USER1, ++ '-k', self.keytabname], ++ ) ++ assert result.returncode == 0 ++ assert result.error_output == '', ( ++ f"Expected no output with -q, got: {result.error_output!r}" ++ ) ++ ++ # Skipped: test_normal_mode_shows_success_message ++ # — covered by test_6_quiet_mode which verifies the success message ++ # appears without -q. ++ ++ # --- getkeytab_002: --server / -s --- ++ ++ @pytest.mark.parametrize('flag', ['--server', '-s']) ++ def test_invalid_server_fails(self, flag): ++ """An invalid server hostname returns exit 9 with bind error.""" ++ result = run_getkeytab( ++ [flag, 'invalid.ipaserver.com', ++ '-p', GK_USER1, ++ '-k', self.keytabname], ++ ) ++ assert result.returncode == 9 ++ assert 'Failed to bind to server' in result.error_output ++ ++ @pytest.mark.parametrize('flag', ['--server', '-s']) ++ def test_valid_server_succeeds(self, flag): ++ """A valid server hostname retrieves the keytab successfully. ++ ++ Note: -s case is also covered by test_7_server_name_check ++ with a service principal. ++ """ ++ result = run_getkeytab( ++ [flag, api.env.host, '-p', GK_USER1, ++ '-k', self.keytabname], ++ ) ++ self._assert_getkeytab_succeeds(result) ++ ++ def test_dns_discovery_succeeds(self): ++ """ipa-getkeytab discovers the server via DNS when -s is omitted.""" ++ result = run_getkeytab( ++ ['-p', GK_USER1, '-k', self.keytabname], ++ ) ++ self._assert_getkeytab_succeeds(result) ++ ++ # --- getkeytab_003: --principal / -p --- ++ ++ @pytest.mark.parametrize('flag', ['--principal', '-p']) ++ @pytest.mark.parametrize('principal', [ ++ pytest.param('unknownuser', id='unknown'), ++ pytest.param( ++ '{}@INVALID.IPASERVER.REALM.COM'.format(GK_USER1), ++ id='invalid-realm', ++ ), ++ ]) ++ def test_principal_not_found(self, flag, principal): ++ """Unresolvable principals return exit 9 with not-found error. ++ ++ Note: test_1_run tests a similar scenario with a non-existent ++ service principal. ++ """ ++ result = run_getkeytab( ++ ['-s', api.env.host, flag, principal, ++ '-k', self.keytabname], ++ ) ++ assert result.returncode == 9 ++ assert ( ++ 'Failed to parse result: PrincipalName not found.' ++ in result.error_output ++ ) ++ ++ @pytest.mark.parametrize('flag', ['--principal', '-p']) ++ @pytest.mark.parametrize('with_realm', [False, True], ++ ids=['bare', 'with-realm']) ++ def test_valid_principal_succeeds(self, flag, with_realm): ++ """Both bare and realm-qualified principals retrieve the keytab.""" ++ principal = ('{}@{}'.format(GK_USER1, api.env.realm) ++ if with_realm else GK_USER1) ++ result = run_getkeytab( ++ ['-s', api.env.host, flag, principal, ++ '-k', self.keytabname], ++ ) ++ self._assert_getkeytab_succeeds(result) ++ ++ # --- getkeytab_004: --keytab / -k --- ++ ++ @pytest.mark.parametrize('flag', ['--keytab', '-k']) ++ def test_creates_keytab_when_absent(self, flag): ++ """The keytab file is created when it does not previously exist.""" ++ self._ensure_keytab_absent() ++ result = run_getkeytab( ++ ['-s', api.env.host, '-p', GK_USER1, flag, self.keytabname], ++ ) ++ self._assert_getkeytab_succeeds(result) ++ assert os.path.isfile(self.keytabname) ++ ++ @pytest.mark.parametrize('flag', ['--keytab', '-k']) ++ def test_keytab_contains_aes_enctypes(self, flag): ++ """The created keytab contains both aes256 and aes128 entries.""" ++ run_getkeytab( ++ ['-s', api.env.host, '-p', GK_USER1, flag, self.keytabname] ++ ) ++ klist_result = klist_keytab(self.keytabname) ++ assert api.env.realm in klist_result.output ++ assert 'aes128' in klist_result.output ++ assert 'aes256' in klist_result.output ++ ++ @pytest.mark.parametrize('flag', ['--keytab', '-k']) ++ def test_text_file_path_returns_exit11(self, flag): ++ """Writing to a pre-existing plain-text file returns exit 11.""" ++ txtfd, txt_path = tempfile.mkstemp(suffix='.txt') ++ os.close(txtfd) ++ try: ++ result = run_getkeytab( ++ ['-s', api.env.host, '-p', GK_USER1, flag, txt_path], ++ ) ++ assert result.returncode == 11 ++ assert 'Failed to add key to the keytab' in result.error_output ++ finally: ++ try: ++ os.unlink(txt_path) ++ except OSError: ++ pass ++ ++ # --- getkeytab_005: -e encryption types --- ++ ++ def test_system_keytab_has_default_enctypes(self): ++ """The system keytab must contain both aes256 and aes128.""" ++ klist_result = klist_keytab('/etc/krb5.keytab') ++ assert '(aes256-cts-hmac-sha1-96)' in klist_result.output ++ assert '(aes128-cts-hmac-sha1-96)' in klist_result.output ++ ++ @pytest.mark.parametrize('enctype', get_permitted_enctypes()) ++ def test_single_enctype(self, enctype): ++ """With -e only that enctype appears and kinit works. ++ ++ Note: test_8_keytab_encryption_check tests -e with multiple ++ enctypes but only asserts success, not klist content. ++ """ ++ expected = f"({enctype})" ++ self._ensure_keytab_absent() ++ run_getkeytab( ++ ['-s', api.env.host, '-p', GK_USER1, ++ '-k', self.keytabname, '-e', enctype], ++ ) ++ klist_result = klist_keytab(self.keytabname) ++ assert expected in klist_result.output ++ for enc in get_permitted_enctypes(): ++ if enc != enctype: ++ assert f"({enc})" not in klist_result.output ++ assert '(des3-cbc-sha1)' not in klist_result.output ++ assert '(arcfour-hmac)' not in klist_result.output ++ with private_ccache(): ++ ipautil.run( ++ ['kinit', '-k', '-t', self.keytabname, ++ '{}@{}'.format(GK_USER1, api.env.realm)], ++ raiseonerr=True, ++ capture_output=True, ++ capture_error=True, ++ ) ++ ++ def test_invalid_enctype_returns_exit8(self): ++ """An invalid -e value returns exit 8 and creates no keytab.""" ++ self._ensure_keytab_absent() ++ result = run_getkeytab( ++ ['-s', api.env.host, '-p', GK_USER1, ++ '-k', self.keytabname, '-e', 'invalid'], ++ ) ++ assert result.returncode == 8 ++ assert 'Warning unrecognized encryption type' in result.error_output ++ assert not os.path.isfile(self.keytabname) ++ ++ # --- getkeytab_006: --password / -P --- ++ ++ @pytest.mark.parametrize('flag', ['--password', '-P']) ++ def test_password_flag_rotates_keys(self, flag): ++ """After --password and a random-key reset, the original password ++ will no longer authenticate. ++ ++ Sequence: admin sets password-derived keys -> user kinits -> user ++ regenerates random keys -> kinit with the original password must fail. ++ """ ++ self._ensure_keytab_absent() ++ principal1 = '{}@{}'.format(GK_USER1, api.env.realm) ++ stdin = '{pw}\n{pw}\n'.format(pw=GK_USER_PW) ++ result = run_getkeytab( ++ ['-s', api.env.host, '-p', GK_USER1, ++ '-k', self.keytabname, flag], ++ stdin=stdin, ++ ) ++ assert result.returncode == 0 ++ with kinit_as_user(principal1, GK_USER_PW): ++ regen = run_getkeytab( ++ ['-s', api.env.host, '-p', GK_USER1, ++ '-k', self.keytabname], ++ ) ++ assert regen.returncode == 0 ++ with private_ccache(): ++ kinit_result = ipautil.run( ++ ['kinit', principal1], ++ stdin=GK_USER_PW + '\n', ++ raiseonerr=False, ++ capture_output=True, ++ capture_error=True, ++ ) ++ assert kinit_result.returncode != 0 ++ assert ( ++ 'Password incorrect' in kinit_result.error_output ++ or 'Preauthentication failed' in kinit_result.error_output ++ ) ++ ++ # --- getkeytab_007: -D / -w bind DN error cases --- ++ ++ # Skipped: test_no_ccache_returns_exit6 ++ # — identical to test_empty_ccache_returns_exit6 above. ++ # Skipped: test_valid_dm_credentials_succeed ++ # — covered by TestBindMethods.test_retrieval_with_dm_creds. ++ ++ @pytest.mark.parametrize('extra_args,exitcode,message', [ ++ (['-D', ' ', '-w', GK_USER_PW], ++ 9, 'Anonymous Binds are not allowed'), ++ (['-D', 'cn=Directory Manager', '-w', ' '], ++ 9, 'Simple bind failed'), ++ (['-D', 'cn=Directory Manager'], ++ 10, 'Bind password required when using a bind DN'), ++ ], ids=['empty-dn', 'wrong-password', 'missing-password']) ++ def test_binddn_error_cases(self, extra_args, exitcode, message): ++ """Bind DN error cases return appropriate exit codes.""" ++ result = run_getkeytab( ++ ['--server', 'localhost', ++ '-p', GK_USER1, ++ '-k', self.keytabname] + extra_args, ++ ) ++ assert result.returncode == exitcode ++ assert message in result.error_output ++ ++ ++# ----------------------------------------------------------------------- ++# ipa-rmkeytab tests (bash rmkeytab_001 – rmkeytab_003) ++# ----------------------------------------------------------------------- ++ ++ ++@pytest.mark.tier1 ++@pytest.mark.usefixtures('gk_users') ++class test_rmkeytab_cmd(KeytabRetrievalTest): ++ """ ++ ipa-rmkeytab tests (bash rmkeytab_001 – rmkeytab_003). ++ """ ++ command = paths.IPA_RMKEYTAB ++ ++ # --- rmkeytab_001: -p removes a named principal --- ++ ++ def test_invalid_principal_returns_exit5(self): ++ """A non-existent principal name returns exit 5.""" ++ self._get_user1_keytab() ++ result = run_rmkeytab(['-p', 'invalidprinc', '-k', self.keytabname]) ++ assert result.returncode == 5 ++ assert 'principal not found' in result.error_output ++ ++ def test_valid_principal_removed(self): ++ """A present principal is removed and absent from klist.""" ++ self._get_user1_keytab() ++ result = run_rmkeytab(['-p', GK_USER1, '-k', self.keytabname]) ++ assert result.returncode == 0 ++ assert ( ++ 'Removing principal {}'.format(GK_USER1) ++ in result.error_output ++ ) ++ assert GK_USER1 not in klist_keytab(self.keytabname).output ++ ++ # --- rmkeytab_002: -r removes all principals of the given realm --- ++ ++ def test_realm_removes_all_principals(self): ++ """All principals of the realm are removed and absent from klist.""" ++ self._get_user1_keytab() ++ result = run_rmkeytab(['-r', api.env.realm, '-k', self.keytabname]) ++ assert result.returncode == 0 ++ assert ( ++ 'Removing principal {}@{}'.format(GK_USER1, api.env.realm) ++ in result.error_output ++ ) ++ assert api.env.realm not in klist_keytab(self.keytabname).output ++ ++ # --- rmkeytab_003: -k with a non-existent path --- ++ ++ def test_invalid_keytab_path_returns_exit7(self): ++ """A non-existent keytab path returns exit 7.""" ++ self._get_user1_keytab() ++ result = run_rmkeytab( ++ ['-p', GK_USER1, '-k', '/opt/invalid.keytab'] ++ ) ++ assert result.returncode == 7 ++ assert 'Failed to set cursor' in result.error_output +-- +2.52.0 + diff --git a/0037-ipatests-Additional-tests-for-ipa-ipa-migration-test.patch b/0037-ipatests-Additional-tests-for-ipa-ipa-migration-test.patch new file mode 100644 index 0000000..01ae7f3 --- /dev/null +++ b/0037-ipatests-Additional-tests-for-ipa-ipa-migration-test.patch @@ -0,0 +1,285 @@ +From b6d50197882dd62b5416948cb232c77c0349c0d3 Mon Sep 17 00:00:00 2001 +From: Sudhir Menon +Date: Thu, 5 Mar 2026 19:29:26 +0530 +Subject: [PATCH] ipatests: Additional tests for ipa-ipa migration + testsuite + +Covers tests scenarios for sshpubkey. + +1. Test to check sshpubkey is migrated for regular ipa user +2. Test to check sshpubkey is migrated for staged user +3. Test to check sshpubkey is migrated for preserved user +4. Test to check idoverides is migrated. + +Covers test scenarios for TestIPAMigrationDNSRecords + +1. Test to check dns zone is migrated +2. Test to check dynamic update value is preserved when migrated +3. Test to check dnsforwardzone is migrated +4. Test to check A record is migrated + +Covers test scenarios for IPA Privieleges and Permissions + +1. Test to check that IPA role and its privilege are migrated in Prod mode +2. Test to check that PBAC Permissions are migrated in Prod mode +3. Test to check that sysaccounts are migrated. +4. Test to check selinuxusermap is migrated + +Assisted-by: Claude +Signed-off-by: Sudhir Menon +Fixes: https://pagure.io/freeipa/issue/9964 +Reviewed-By: Florence Blanc-Renaud +Reviewed-By: David Hanina +Reviewed-By: Sudhir Menon +--- + .../test_ipa_ipa_migration.py | 164 +++++++++++++++++- + 1 file changed, 155 insertions(+), 9 deletions(-) + +diff --git a/ipatests/test_integration/test_ipa_ipa_migration.py b/ipatests/test_integration/test_ipa_ipa_migration.py +index 18f335de196a9d6c7d30e4adf35385122376b7d9..105c79b610804b68522e568b4028be5a2dc72714 100644 +--- a/ipatests/test_integration/test_ipa_ipa_migration.py ++++ b/ipatests/test_integration/test_ipa_ipa_migration.py +@@ -201,6 +201,12 @@ def prepare_ipa_server(master): + master.run_command( + ["ipa", "dnszone-mod", "example.test", "--dynamic-update=TRUE"] + ) ++ master.run_command( ++ [ ++ "ipa", "dnsrecord-add", "example.test", "migratetest", ++ "--a-rec", "192.0.2.100", ++ ] ++ ) + + # Add hbac rule + master.run_command(["ipa", "hbacrule-add", "--usercat=all", "test1"]) +@@ -221,7 +227,7 @@ def prepare_ipa_server(master): + "dnsforwardzone-add", + "forwardzone.test", + "--forwarder", +- "10.11.12.13", ++ "192.168.124.10", + ] + ) + +@@ -293,6 +299,11 @@ def prepare_ipa_server(master): + ] + ) + ++ # Add sysaccount ++ master.run_command( ++ ["ipa", "sysaccount-add", "migrate-test-sysaccount", "--random"] ++ ) ++ + + def run_migrate( + host, mode, remote_host, bind_dn=None, bind_pwd=None, extra_args=None +@@ -664,15 +675,15 @@ class TestIPAMigrateCLIOptions(MigrationTest): + realm_name = self.master.domain.realm + base_dn = str(self.master.domain.basedn) + dse_ldif = textwrap.dedent( +- f""" ++ """ + dn: cn={realm_name},cn=kerberos,{base_dn} + cn: {realm_name} + objectClass: top + objectClass: krbrealmcontainer + """ + ).format( +- realm_name=self.master.domain.realm, +- base_dn=str(self.master.domain.basedn), ++ realm_name=realm_name, ++ base_dn=base_dn, + ) + self.replicas[0].put_file_contents(ldif_file_path, dse_ldif) + result = run_migrate( +@@ -731,7 +742,6 @@ class TestIPAMigrateCLIOptions(MigrationTest): + base_dn = str(self.master.domain.basedn) + subtree = 'cn=security,{}'.format(base_dn) + params = ['-s', subtree, '-n', '-x'] +- base_dn = str(self.master.domain.basedn) + CUSTOM_SUBTREE_LOG = ( + "Add db entry 'cn=security,{} - custom'" + ).format(base_dn) +@@ -853,7 +863,7 @@ class TestIPAMigrateCLIOptions(MigrationTest): + def test_ipa_migrate_stage_mode_with_cert(self): + """ + This testcase checks that ipa-migrate command +- works without the 'ValuerError' ++ works without the 'ValueError' + when -Z option is used with valid cert + """ + cert_file = '/tmp/ipa.crt' +@@ -878,7 +888,7 @@ class TestIPAMigrateCLIOptions(MigrationTest): + error when invalid cert is specified with + -Z option + """ +- cert_file = '/tmp/invaid_cert.crt' ++ cert_file = '/tmp/invalid_cert.crt' + invalid_cert = ( + b'-----BEGIN CERTIFICATE-----\n' + b'MIIFazCCDQYJKoZIhvcNAQELBQAw\n' +@@ -1026,6 +1036,59 @@ class TestIPAMigrationProdMode(MigrationTest): + assert 'Rule name: readfiles\n' in cmd1.stdout_text + assert 'Sudo Command: /usr/bin/less\n' in cmd2.stdout_text + ++ def test_ipa_migrate_prod_mode_roles_privileges(self): ++ """ ++ Test that IPA roles are migrated from remote to local server ++ """ ++ role_name = "junioradmin" ++ privilege_name = "User Administrators" ++ tasks.kinit_admin(self.replicas[0]) ++ result = self.replicas[0].run_command( ++ ["ipa", "role-show", role_name] ++ ) ++ assert "Role name: {}".format(role_name) in result.stdout_text ++ assert "Privileges: {}".format(privilege_name) in result.stdout_text ++ ++ def test_ipa_migrate_prod_mode_permission(self): ++ """ ++ Test that PBAC permission is migrated ++ """ ++ permission_name = "Add Users" ++ tasks.kinit_admin(self.replicas[0]) ++ result = self.replicas[0].run_command( ++ ["ipa", "permission-show", permission_name] ++ ) ++ assert (f"Permission name: {permission_name}" in ++ result.stdout_text) ++ ++ def test_ipa_migrate_prod_mode_sysaccounts(self): ++ """ ++ Test that system accounts (sysaccounts) are migrated ++ from remote server to local server in prod mode. ++ """ ++ sysaccount_name = "migrate-test-sysaccount" ++ tasks.kinit_admin(self.replicas[0]) ++ result = self.replicas[0].run_command( ++ ["ipa", "sysaccount-show", sysaccount_name] ++ ) ++ assert sysaccount_name in result.stdout_text ++ assert (f"System account ID: {sysaccount_name}" in ++ result.stdout_text) ++ ++ def test_ipa_migrate_prod_mode_selinuxusermap(self): ++ """ ++ Test that SELinux usermap is migrated from remote ++ to local server. ++ """ ++ usermap_name = "test1" ++ tasks.kinit_admin(self.replicas[0]) ++ result = self.replicas[0].run_command( ++ ["ipa", "selinuxusermap-show", usermap_name] ++ ) ++ assert result.returncode == 0 ++ assert f"Rule name: {usermap_name}" in result.stdout_text ++ assert "xguest_u:s0" in result.stdout_text ++ + def test_ipa_migrate_prod_mode_new_user_sid(self): + """ + This testcase checks that in prod-mode uid/gid of the +@@ -1188,6 +1251,89 @@ class TestIPAMigrationProdMode(MigrationTest): + assert DEBUG_LOG in install_msg + + ++class TestIPAMigrationDNSRecords(MigrationTest): ++ """ ++ Tests to verify all DNS zones, forward zones, and DNS records ++ are migrated when ipa-migrate is run with --migrate-dns (-B). ++ "By default all DNS entries are migrated" / -B to migrate DNS. ++ """ ++ num_replicas = 1 ++ num_clients = 1 ++ topology = "line" ++ ++ @pytest.fixture(autouse=True) ++ def run_migration_with_dns(self): ++ """ ++ Run full prod-mode migration with -B so that DNS is migrated ++ to the local server. All tests in this class assume DNS has ++ been migrated. ++ """ ++ tasks.kinit_admin(self.master) ++ tasks.kinit_admin(self.replicas[0]) ++ run_migrate( ++ self.replicas[0], ++ "prod-mode", ++ self.master.hostname, ++ "cn=Directory Manager", ++ self.master.config.admin_password, ++ extra_args=["-B", "-n"], ++ ) ++ ++ def test_dns_zone_example_test_migrated(self): ++ """ ++ Check that DNS zone example.test (from prepare_ipa_server) ++ is migrated to the local server. ++ """ ++ zone_name = "example.test" ++ result = self.replicas[0].run_command( ++ ["ipa", "dnszone-show", zone_name] ++ ) ++ assert result.returncode == 0 ++ assert "Zone name: {}".format(zone_name) in result.stdout_text ++ ++ def test_dns_zone_dynamic_update_preserved(self): ++ """ ++ Check that zone attribute dynamic update is preserved ++ (prepare_ipa_server sets dynamic-update=TRUE for example.test). ++ """ ++ zone_name = "example.test" ++ result = self.replicas[0].run_command( ++ ["ipa", "dnszone-show", zone_name] ++ ) ++ assert result.returncode == 0 ++ assert "Dynamic update: True" in result.stdout_text ++ ++ def test_dns_zone_has_system_records(self): ++ """ ++ Check that migrated zone has system records (NS/SOA). ++ """ ++ zone_name = "example.test" ++ result = self.replicas[0].run_command( ++ ["ipa", "dnsrecord-find", zone_name] ++ ) ++ assert result.returncode == 0 ++ # Zone should have records (e.g. NS, SOA, or record list) ++ assert ( ++ "NS record" in result.stdout_text ++ or "SOA record" in result.stdout_text ++ or "Record name" in result.stdout_text ++ ) ++ ++ def test_dns_record_a_migrated(self): ++ """ ++ Verify that the A record added in prepare_ipa_server is ++ migrated to the local server. ++ """ ++ zone_name = "example.test" ++ record_name = "migratetest" ++ record_value = "192.0.2.100" ++ result = self.replicas[0].run_command( ++ ["ipa", "dnsrecord-show", zone_name, record_name] ++ ) ++ assert record_name in result.stdout_text ++ assert record_value in result.stdout_text ++ ++ + class TestIPAMigrationWithADtrust(IntegrationTest): + """ + Test for ipa-migrate tool with IPA Master having trust setup +@@ -1319,9 +1465,9 @@ class TestIPAMigratewithBackupRestore(IntegrationTest): + DB_LDIF_FILE = '{}-userRoot.ldif'.format( + dashed_domain_name + ) +- SCHEMA_LDIF_FILE = '{}''/config_files/schema/99user.ldif'.format( ++ SCHEMA_LDIF_FILE = "{}/config_files/schema/99user.ldif".format( + dashed_domain_name) +- CONFIG_LDIF_FILE = '{}''/config_files/dse.ldif'.format( ++ CONFIG_LDIF_FILE = "{}/config_files/dse.ldif".format( + dashed_domain_name) + param = [ + '-n', '-g', CONFIG_LDIF_FILE, '-m', SCHEMA_LDIF_FILE, +-- +2.52.0 + diff --git a/0038-ipatests-ipa-migrate-ds-test-scenarios.patch b/0038-ipatests-ipa-migrate-ds-test-scenarios.patch new file mode 100644 index 0000000..8e5ed13 --- /dev/null +++ b/0038-ipatests-ipa-migrate-ds-test-scenarios.patch @@ -0,0 +1,288 @@ +From 8f2bbb02ae64f322fb34f073d7a7cdb00a69aea0 Mon Sep 17 00:00:00 2001 +From: Sudhir Menon +Date: Thu, 19 Mar 2026 17:12:12 +0530 +Subject: [PATCH] ipatests: ipa-migrate-ds test scenarios + +This patch tests ipa-migrated-ds related sceanrios + +1. 389-ds instance is setup on client system. +2. Attached sample instance1.ldif file in data/ds_migration folder. +3. Attempt ipa-migrate-ds with configuration disabled. +3. Attempt ipa-migrate-ds over ldaps. + +Assisted by: claude-4.6-opus-high +Signed-off-by: Sudhir Menon +Reviewed-By: Florence Blanc-Renaud +--- + ipatests/setup.py | 2 +- + .../data/ds_migration/instance1.ldif | 48 +++++ + .../test_integration/test_ds_migration.py | 189 ++++++++++++++++++ + 3 files changed, 238 insertions(+), 1 deletion(-) + create mode 100644 ipatests/test_integration/data/ds_migration/instance1.ldif + create mode 100644 ipatests/test_integration/test_ds_migration.py + +diff --git a/ipatests/setup.py b/ipatests/setup.py +index 0aec4a70d..07595a501 100644 +--- a/ipatests/setup.py ++++ b/ipatests/setup.py +@@ -56,7 +56,7 @@ if __name__ == '__main__': + 'ipatests': ['prci_definitions/*'], + 'ipatests.test_custodia': ['*.conf', 'empty.conf.d/*.conf'], + 'ipatests.test_install': ['*.update'], +- 'ipatests.test_integration': ['scripts/*'], ++ 'ipatests.test_integration': ['data/*/*.ldif'], + 'ipatests.test_ipaclient': ['data/*/*/*'], + 'ipatests.test_ipalib': ['data/*'], + 'ipatests.test_ipaplatform': ['data/*'], +diff --git a/ipatests/test_integration/data/ds_migration/instance1.ldif b/ipatests/test_integration/data/ds_migration/instance1.ldif +new file mode 100644 +index 000000000..a148b52c8 +--- /dev/null ++++ b/ipatests/test_integration/data/ds_migration/instance1.ldif +@@ -0,0 +1,48 @@ ++# Minimal DS migration test data: base OUs and sample user/group ++# Used when setting up 389-ds on the client for migrate-ds tests. ++# Base suffix dc=testrealm,dc=test is created by dscreate. ++ ++dn: dc=testrealm,dc=test ++objectClass: top ++objectClass: domain ++objectClass: dcObject ++dc: testrealm ++ ++dn: ou=People,dc=testrealm,dc=test ++objectClass: top ++objectClass: organizationalUnit ++ou: People ++ ++dn: ou=Groups,dc=testrealm,dc=test ++objectClass: top ++objectClass: organizationalUnit ++ou: Groups ++ ++dn: cn=Directory Administrators, ou=Groups, dc=testrealm,dc=test ++cn: Directory Administrators ++objectclass: top ++objectclass: groupofuniquenames ++ou: Groups ++uniquemember: uid=ldapuser_0001, ou=People, dc=testrealm,dc=test ++ ++dn: uid=ldapuser_0001,ou=People,dc=testrealm,dc=test ++objectClass: top ++objectClass: person ++objectClass: posixAccount ++uid: ldapuser_0001 ++cn: LDAP User 1 ++sn: User 1 ++uidNumber: 1001 ++gidNumber: 1001 ++homeDirectory: /home/ldapuser_0001 ++userPassword: fo0m4nchU ++telephonenumber: +1 123 444 555 ++ ++dn: cn=ldapgroup_0001,ou=Groups,dc=testrealm,dc=test ++objectClass: top ++objectClass: groupOfNames ++objectClass: posixGroup ++cn: ldapgroup_0001 ++gidNumber: 1001 ++member: uid=ldapuser_0001,ou=People,dc=testrealm,dc=test ++ +diff --git a/ipatests/test_integration/test_ds_migration.py b/ipatests/test_integration/test_ds_migration.py +new file mode 100644 +index 000000000..f16759b37 +--- /dev/null ++++ b/ipatests/test_integration/test_ds_migration.py +@@ -0,0 +1,189 @@ ++# ++# Copyright (C) 2026 FreeIPA Contributors see COPYING for license ++# ++ ++""" ++ipa-migrate-ds migration acceptance tests. ++""" ++ ++from __future__ import absolute_import ++ ++import os ++import textwrap ++ ++from ipatests.test_integration.base import IntegrationTest ++from ipatests.pytest_ipa.integration import tasks ++ ++# 389-ds instance name and base DN on the client ++DS_INSTANCE_NAME = "dsinstance_01" ++DS_BASEDN = "dc=testrealm,dc=test" ++DS_PORT = 389 ++DS_SECURE_PORT = 636 ++ ++ ++def _setup_389ds_on_client(client, admin_password): ++ """ ++ Install 389 Directory Server on the client and load migration ++ test data from instance1.ldif i.e (ou=People, ou=groups, ++ sample user/group). ++ """ ++ tasks.install_packages(client, ["389-ds-base"]) ++ ++ # Create instance via dscreate ++ inf_content = textwrap.dedent("""\ ++ [general] ++ full_machine_name = {hostname} ++ [slapd] ++ instance_name = {instance} ++ port = {port} ++ secure_port = {secure_port} ++ root_dn = cn=Directory Manager ++ root_password = {password} ++ [backend-userroot] ++ sample_entries = no ++ suffix = {basedn} ++ """).format( ++ hostname=client.hostname, ++ instance=DS_INSTANCE_NAME, ++ port=DS_PORT, ++ secure_port=DS_SECURE_PORT, ++ password=admin_password, ++ basedn=DS_BASEDN, ++ ) ++ client.put_file_contents("/tmp/ds-instance.inf", inf_content) ++ client.run_command( ++ ["dscreate", "from-file", "/tmp/ds-instance.inf"] ++ ) ++ ++ # Load migration test data from instance1.ldif ++ test_dir = os.path.dirname(os.path.abspath(__file__)) ++ ldif_path = os.path.join( ++ test_dir, "data", "ds_migration", "instance1.ldif" ++ ) ++ with open(ldif_path) as f: ++ ldif_content = f.read() ++ client.put_file_contents("/tmp/instance1.ldif", ldif_content) ++ client.run_command( ++ [ ++ "/usr/bin/ldapmodify", ++ "-a", "-x", "-H", "ldap://localhost:{}".format(DS_PORT), ++ "-D", "cn=Directory Manager", "-w", admin_password, ++ "-f", "/tmp/instance1.ldif", ++ ] ++ ) ++ ++ ++class TestDSMigrationConfig(IntegrationTest): ++ """ ++ Test ipa migrate-ds related scenarios. ++ ++ Uses a client host with 389-ds populated from instance1.ldif ++ (ou=People, ou=groups, dc=testrealm,dc=test) for migration tests. ++ """ ++ ++ topology = "line" ++ num_replicas = 0 ++ num_clients = 1 ++ ++ @classmethod ++ def install(cls, mh): ++ # Install master and IPA client (full topology) ++ super(TestDSMigrationConfig, cls).install(mh) ++ # On the client host, set up 389-ds with migration test data ++ _setup_389ds_on_client( ++ cls.clients[0], ++ cls.master.config.admin_password, ++ ) ++ ++ def test_attempt_migration_with_configuration_false(self): ++ """ ++ Test attempts ipa-migrate-ds with migration disabled. ++ """ ++ tasks.kinit_admin(self.master) ++ # Ensure migration is disabled ++ cmd = ["ipa", "config-mod", "--enable-migration", "FALSE"] ++ error_msg = "ipa: ERROR: no modifications to be performed" ++ result = self.master.run_command(cmd, raiseonerr=False) ++ assert result.returncode != 0 ++ tasks.assert_error(result, error_msg) ++ client_host = self.clients[0].hostname ++ ldap_uri = "ldap://{}:{}".format(client_host, DS_PORT) ++ result = self.master.run_command( ++ [ ++ "ipa", ++ "migrate-ds", ++ "--user-container=ou=People", ++ "--group-container=ou=groups", ++ ldap_uri, ++ ], ++ stdin_text=self.master.config.admin_password, ++ raiseonerr=False, ++ ) ++ assert ( ++ result.returncode != 0 ++ ), "migrate-ds should fail when migration is disabled" ++ assert "migration mode is disabled" in ( ++ result.stdout_text + result.stderr_text ++ ).lower() ++ ++ def test_migration_over_ldaps(self): ++ """ ++ Migrate from the client's 389-ds over LDAPS (port 636). ++ """ ++ ca_cert_file = "/etc/ipa/remoteds.crt" ++ tasks.kinit_admin(self.master) ++ self.master.run_command( ++ ["ipa", "config-mod", "--enable-migration", "TRUE"], ++ ) ++ ++ # Copy 389-ds CA cert from client to master for LDAPS verification ++ client = self.clients[0] ++ ds_cert_dir = "/etc/dirsrv/slapd-{}".format(DS_INSTANCE_NAME) ++ cert_result = client.run_command( ++ [ ++ "certutil", "-d", ds_cert_dir, "-L", "-n", ++ "Self-Signed-CA", "-a", ++ ], ++ ) ++ self.master.put_file_contents( ++ ca_cert_file, cert_result.stdout_text ++ ) ++ self.master.run_command( ++ ["restorecon", ca_cert_file], raiseonerr=False ++ ) ++ ++ client_host = client.hostname ++ ldaps_uri = "ldaps://{}:{}".format(client_host, DS_SECURE_PORT) ++ user_container = "ou=People,{}".format(DS_BASEDN) ++ group_container = "ou=groups,{}".format(DS_BASEDN) ++ ++ self.master.run_command( ++ [ ++ "ipa", ++ "migrate-ds", ++ "--with-compat", ++ "--user-container", ++ user_container, ++ "--group-container", ++ group_container, ++ ldaps_uri, ++ "--ca-cert-file", ++ ca_cert_file, ++ ], ++ stdin_text=self.master.config.admin_password, ++ ) ++ # Verify migrated user and group from instance1.ldif ++ self.master.run_command( ++ ["ipa", "user-show", "ldapuser_0001"] ++ ) ++ self.master.run_command( ++ ["ipa", "group-show", "ldapgroup_0001"] ++ ) ++ ++ # Clean up migrated user and group ++ self.master.run_command( ++ ["ipa", "user-del", "ldapuser_0001"], raiseonerr=False ++ ) ++ self.master.run_command( ++ ["ipa", "group-del", "ldapgroup_0001"], raiseonerr=False ++ ) +-- +2.52.0 + diff --git a/0039-ipatests-Add-ipa-selfservice-show-and-selfservice-mo.patch b/0039-ipatests-Add-ipa-selfservice-show-and-selfservice-mo.patch new file mode 100644 index 0000000..506a8e2 --- /dev/null +++ b/0039-ipatests-Add-ipa-selfservice-show-and-selfservice-mo.patch @@ -0,0 +1,506 @@ +From 51e669d46f912d39841bc40560682f9f414f68fe Mon Sep 17 00:00:00 2001 +From: Jay Gondaliya +Date: Mon, 16 Mar 2026 15:03:17 +0530 +Subject: [PATCH] ipatests: Add ipa selfservice-show and + selfservice-mod CLI tests to xmlrpc + +Add two new Declarative test classes to test_selfservice_plugin.py covering the selfservice-show and selfservice-mod CLI command options, converted from the legacy bash test suite (selfservice_show_1001-1003 and selfservice_mod_1002-1008). + +test_selfservice_show_cli covers: +- selfservice-show --all +- selfservice-show --all --raw +- selfservice-show --raw + +test_selfservice_mod_cli covers: +- selfservice-mod with invalid attrs (negative) +- selfservice-mod with invalid permissions (negative) +- selfservice-mod with no changes / EmptyModlist (negative) +- selfservice-mod with bad attrs must not delete the entry (BZ 747741) +- selfservice-mod changing and expanding attrs (positive) +- selfservice-mod with invalid/same permissions (negative) +- selfservice-mod changing and expanding permissions (positive) + +Tests for selfservice_mod_1001 and selfservice_mod_1009, which require interactive input and are not valid in a non-interactive context, are covered as integration tests in test_commands.py by test_selfservice_mod_no_attrs_or_permissions_all and test_selfservice_mod_no_attrs_or_permissions_raw. Tests for BZ 772675 (mod --raw) and BZ 747741 (bad attrs) cross-reference existing coverage in test_selfservice_misc. + +Fixes: https://pagure.io/freeipa/issue/9945 +Assisted-by: Claude +Signed-off-by: Jay Gondaliya +Reviewed-By: Florence Blanc-Renaud +Reviewed-By: David Hanina +--- + ipatests/test_integration/test_commands.py | 54 +++ + .../test_xmlrpc/test_selfservice_plugin.py | 396 ++++++++++++++++++ + 2 files changed, 450 insertions(+) + +diff --git a/ipatests/test_integration/test_commands.py b/ipatests/test_integration/test_commands.py +index 6d7ee8f2d..5021747b6 100644 +--- a/ipatests/test_integration/test_commands.py ++++ b/ipatests/test_integration/test_commands.py +@@ -391,6 +391,60 @@ class TestIPACommand(IntegrationTest): + assert result.returncode == 1 + assert "Number of permissions added 0" in result.stdout_text + ++ def test_selfservice_mod_no_attrs_or_permissions_all(self): ++ """selfservice-mod --all with no --attrs or --permissions fails. ++ ++ When selfservice-mod is invoked with only --all and neither --attrs ++ nor --permissions is supplied, the CLI has no modifications to apply ++ and must return a non-zero exit code with an appropriate error message. ++ """ ++ entry = 'test_selfservice_mod_all' ++ tasks.kinit_admin(self.master) ++ self.master.run_command( ++ ['ipa', 'selfservice-add', entry, ++ '--attrs=l', '--permissions=write'] ++ ) ++ try: ++ result = self.master.run_command( ++ ['ipa', 'selfservice-mod', entry, '--all'], ++ stdin_text='\n', ++ raiseonerr=False, ++ ) ++ assert result.returncode != 0 ++ assert 'no modifications to be performed' in result.stderr_text ++ finally: ++ self.master.run_command( ++ ['ipa', 'selfservice-del', entry], ++ raiseonerr=False, ++ ) ++ ++ def test_selfservice_mod_no_attrs_or_permissions_raw(self): ++ """selfservice-mod --raw with no --attrs or --permissions fails. ++ ++ When selfservice-mod is invoked with only --raw and neither --attrs ++ nor --permissions is supplied, the CLI has no modifications to apply ++ and must return a non-zero exit code with an appropriate error message. ++ """ ++ entry = 'test_selfservice_mod_raw' ++ tasks.kinit_admin(self.master) ++ self.master.run_command( ++ ['ipa', 'selfservice-add', entry, ++ '--attrs=l', '--permissions=write'] ++ ) ++ try: ++ result = self.master.run_command( ++ ['ipa', 'selfservice-mod', entry, '--raw'], ++ stdin_text='\n', ++ raiseonerr=False, ++ ) ++ assert result.returncode != 0 ++ assert 'no modifications to be performed' in result.stderr_text ++ finally: ++ self.master.run_command( ++ ['ipa', 'selfservice-del', entry], ++ raiseonerr=False, ++ ) ++ + def test_change_sysaccount_password_issue7561(self): + sysuser = 'system' + original_passwd = 'Secret123' +diff --git a/ipatests/test_xmlrpc/test_selfservice_plugin.py b/ipatests/test_xmlrpc/test_selfservice_plugin.py +index 8f2307a20..48dfd7cc3 100644 +--- a/ipatests/test_xmlrpc/test_selfservice_plugin.py ++++ b/ipatests/test_xmlrpc/test_selfservice_plugin.py +@@ -942,3 +942,399 @@ class test_selfservice_cli_add_del(Declarative): + ), + + ] ++ ++ ++# selfservice-show & selfservice-mod CLI test rule names ++ ++SS_CLI_SHOW = 'SELFSERVICE_SHOW_TEST' ++ ++ ++@pytest.mark.tier1 ++class test_selfservice_show_cli(Declarative): ++ """Test selfservice-show CLI options.""" ++ ++ cleanup_commands = [ ++ ('selfservice_del', [SS_CLI_SHOW], {}), ++ ] ++ ++ tests = [ ++ dict( ++ desc='Create %r for show tests' % SS_CLI_SHOW, ++ command=( ++ 'selfservice_add', ++ [SS_CLI_SHOW], ++ dict( ++ attrs=['l'], ++ permissions='write', ++ ), ++ ), ++ expected=dict( ++ value=SS_CLI_SHOW, ++ summary='Added selfservice "%s"' % SS_CLI_SHOW, ++ result=dict( ++ attrs=['l'], ++ permissions=['write'], ++ selfaci=True, ++ aciname=SS_CLI_SHOW, ++ ), ++ ), ++ ), ++ ++ # Show with --all (positive test) ++ dict( ++ desc='Show %r with --all' % SS_CLI_SHOW, ++ command=( ++ 'selfservice_show', ++ [SS_CLI_SHOW], ++ {'all': True}, ++ ), ++ expected=dict( ++ value=SS_CLI_SHOW, ++ summary=None, ++ result=dict( ++ attrs=['l'], ++ permissions=['write'], ++ selfaci=True, ++ aciname=SS_CLI_SHOW, ++ ), ++ ), ++ ), ++ ++ # Show with --all and --raw (positive test) ++ dict( ++ desc='Show %r with --all and --raw' % SS_CLI_SHOW, ++ command=( ++ 'selfservice_show', ++ [SS_CLI_SHOW], ++ {'all': True, 'raw': True}, ++ ), ++ expected=dict( ++ value=SS_CLI_SHOW, ++ summary=None, ++ result={ ++ 'aci': '(targetattr = "l")' ++ '(version 3.0;acl ' ++ '"selfservice:%s";' ++ 'allow (write) ' ++ 'userdn = "ldap:///self";)' ++ % SS_CLI_SHOW, ++ }, ++ ), ++ ), ++ ++ # Show with --raw (positive test) ++ dict( ++ desc='Show %r with --raw' % SS_CLI_SHOW, ++ command=( ++ 'selfservice_show', ++ [SS_CLI_SHOW], ++ {'raw': True}, ++ ), ++ expected=dict( ++ value=SS_CLI_SHOW, ++ summary=None, ++ result={ ++ 'aci': '(targetattr = "l")' ++ '(version 3.0;acl ' ++ '"selfservice:%s";' ++ 'allow (write) ' ++ 'userdn = "ldap:///self";)' ++ % SS_CLI_SHOW, ++ }, ++ ), ++ ), ++ ++ dict( ++ desc='Delete %r' % SS_CLI_SHOW, ++ command=('selfservice_del', [SS_CLI_SHOW], {}), ++ expected=dict( ++ result=True, ++ value=SS_CLI_SHOW, ++ summary='Deleted selfservice "%s"' % SS_CLI_SHOW, ++ ), ++ ), ++ ++ ] ++ ++ ++SS_CLI_MOD = 'SELFSERVICE_MOD_TEST' ++ ++ ++@pytest.mark.tier1 ++class test_selfservice_mod_cli(Declarative): ++ """Test selfservice-mod CLI options.""" ++ ++ cleanup_commands = [ ++ ('selfservice_del', [SS_CLI_MOD], {}), ++ ] ++ ++ tests = [ ++ dict( ++ desc='Create %r for mod tests' % SS_CLI_MOD, ++ command=( ++ 'selfservice_add', ++ [SS_CLI_MOD], ++ dict( ++ attrs=['l'], ++ permissions='write', ++ ), ++ ), ++ expected=dict( ++ value=SS_CLI_MOD, ++ summary='Added selfservice "%s"' % SS_CLI_MOD, ++ result=dict( ++ attrs=['l'], ++ permissions=['write'], ++ selfaci=True, ++ aciname=SS_CLI_MOD, ++ ), ++ ), ++ ), ++ ++ # test_selfservice_mod_no_attrs_or_permissions_all and ++ # test_selfservice_mod_no_attrs_or_permissions_raw are covered in ++ # integration tests in test_commands.py since they are ++ # interactive tests. ++ ++ # Modify with --all --attrs=badattr --permissions=write --raw ++ # (negative test - invalid attr value) ++ dict( ++ desc='Try to modify %r with invalid attrs' % SS_CLI_MOD, ++ command=( ++ 'selfservice_mod', ++ [SS_CLI_MOD], ++ dict( ++ attrs=['badattr'], ++ permissions='write', ++ all=True, ++ raw=True, ++ ), ++ ), ++ expected=errors.InvalidSyntax( ++ attr=( ++ r'targetattr "badattr" does not exist in schema. ' ++ r'Please add attributeTypes "badattr" to ' ++ r'schema if necessary. ' ++ r'ACL Syntax Error(-5):' ++ r'(targetattr = \22badattr\22)' ++ r'(version 3.0;acl ' ++ r'\22selfservice:%s\22;' ++ r'allow (write) userdn = \22ldap:///self\22;)' ++ ) % SS_CLI_MOD, ++ ), ++ ), ++ ++ # Modify with --all --attrs=l --permissions=badperm --raw ++ # (negative test - invalid permission value) ++ dict( ++ desc=( ++ 'Try to modify %r with invalid permissions' ++ % SS_CLI_MOD ++ ), ++ command=( ++ 'selfservice_mod', ++ [SS_CLI_MOD], ++ dict( ++ attrs=['l'], ++ permissions='badperm', ++ all=True, ++ raw=True, ++ ), ++ ), ++ expected=errors.ValidationError( ++ name='permissions', ++ error='"badperm" is not a valid permission', ++ ), ++ ), ++ ++ # Modify with same attrs and perms already set ++ # (negative test - no modifications error) ++ dict( ++ desc='Try to modify %r with no changes' % SS_CLI_MOD, ++ command=( ++ 'selfservice_mod', ++ [SS_CLI_MOD], ++ dict( ++ attrs=['l'], ++ permissions='write', ++ all=True, ++ raw=True, ++ ), ++ ), ++ expected=errors.EmptyModlist(), ++ ), ++ ++ # Modify with --attrs=badattrs (negative test, ++ # BZ 747741 - a bad attrs mod must not delete the entry). ++ dict( ++ desc=( ++ 'Try to modify %r with bad attrs (BZ 747741)' ++ % SS_CLI_MOD ++ ), ++ command=( ++ 'selfservice_mod', ++ [SS_CLI_MOD], ++ dict(attrs=['badattrs']), ++ ), ++ expected=errors.InvalidSyntax( ++ attr=( ++ r'targetattr "badattrs" does not exist in schema. ' ++ r'Please add attributeTypes "badattrs" to ' ++ r'schema if necessary. ' ++ r'ACL Syntax Error(-5):' ++ r'(targetattr = \22badattrs\22)' ++ r'(version 3.0;acl ' ++ r'\22selfservice:%s\22;' ++ r'allow (write) userdn = \22ldap:///self\22;)' ++ ) % SS_CLI_MOD, ++ ), ++ ), ++ ++ # Verify entry still exists after failed mod (BZ 747741) ++ dict( ++ desc=( ++ 'Verify %r still exists after failed mod' ++ % SS_CLI_MOD ++ ), ++ command=('selfservice_show', [SS_CLI_MOD], {}), ++ expected=dict( ++ value=SS_CLI_MOD, ++ summary=None, ++ result=dict( ++ attrs=['l'], ++ permissions=['write'], ++ selfaci=True, ++ aciname=SS_CLI_MOD, ++ ), ++ ), ++ ), ++ ++ # Modify with --attrs=mobile (positive test - change attr) ++ dict( ++ desc='Modify %r attrs to mobile' % SS_CLI_MOD, ++ command=( ++ 'selfservice_mod', ++ [SS_CLI_MOD], ++ dict(attrs=['mobile']), ++ ), ++ expected=dict( ++ value=SS_CLI_MOD, ++ summary='Modified selfservice "%s"' % SS_CLI_MOD, ++ result=dict( ++ attrs=['mobile'], ++ permissions=['write'], ++ selfaci=True, ++ aciname=SS_CLI_MOD, ++ ), ++ ), ++ ), ++ ++ # Modify with --attrs={mobile,l} (positive test - add attr) ++ dict( ++ desc='Modify %r attrs to mobile and l' % SS_CLI_MOD, ++ command=( ++ 'selfservice_mod', ++ [SS_CLI_MOD], ++ dict(attrs=['mobile', 'l']), ++ ), ++ expected=dict( ++ value=SS_CLI_MOD, ++ summary='Modified selfservice "%s"' % SS_CLI_MOD, ++ result=dict( ++ attrs=['mobile', 'l'], ++ permissions=['write'], ++ selfaci=True, ++ aciname=SS_CLI_MOD, ++ ), ++ ), ++ ), ++ ++ # Modify with --permissions=badperm ++ # (negative test - invalid permission string) ++ dict( ++ desc=( ++ 'Try to modify %r with invalid permissions' ++ % SS_CLI_MOD ++ ), ++ command=( ++ 'selfservice_mod', ++ [SS_CLI_MOD], ++ dict(permissions='badperm'), ++ ), ++ expected=errors.ValidationError( ++ name='permissions', ++ error='"badperm" is not a valid permission', ++ ), ++ ), ++ ++ # Modify with --permissions=write ++ # (negative test - same perm already set, no modification) ++ dict( ++ desc='Try to modify %r with same permissions' % SS_CLI_MOD, ++ command=( ++ 'selfservice_mod', ++ [SS_CLI_MOD], ++ dict(permissions='write'), ++ ), ++ expected=errors.EmptyModlist(), ++ ), ++ ++ # Modify with --permissions=read (positive test - change perm) ++ dict( ++ desc='Modify %r permissions to read' % SS_CLI_MOD, ++ command=( ++ 'selfservice_mod', ++ [SS_CLI_MOD], ++ dict(permissions='read'), ++ ), ++ expected=dict( ++ value=SS_CLI_MOD, ++ summary='Modified selfservice "%s"' % SS_CLI_MOD, ++ result=dict( ++ attrs=['mobile', 'l'], ++ permissions=['read'], ++ selfaci=True, ++ aciname=SS_CLI_MOD, ++ ), ++ ), ++ ), ++ ++ # Modify with --permissions={read,write} (positive test - add perm) ++ dict( ++ desc=( ++ 'Modify %r permissions to read and write' ++ % SS_CLI_MOD ++ ), ++ command=( ++ 'selfservice_mod', ++ [SS_CLI_MOD], ++ dict(permissions=['read', 'write']), ++ ), ++ expected=dict( ++ value=SS_CLI_MOD, ++ summary='Modified selfservice "%s"' % SS_CLI_MOD, ++ result=dict( ++ attrs=['mobile', 'l'], ++ permissions=['read', 'write'], ++ selfaci=True, ++ aciname=SS_CLI_MOD, ++ ), ++ ), ++ ), ++ ++ # test_selfservice_mod_no_attrs_or_permissions_all and ++ # test_selfservice_mod_no_attrs_or_permissions_raw are covered in ++ # integration tests in test_commands.py since they are ++ # interactive tests. ++ ++ dict( ++ desc='Delete %r' % SS_CLI_MOD, ++ command=('selfservice_del', [SS_CLI_MOD], {}), ++ expected=dict( ++ result=True, ++ value=SS_CLI_MOD, ++ summary='Deleted selfservice "%s"' % SS_CLI_MOD, ++ ), ++ ), ++ ++ ] +-- +2.52.0 + diff --git a/0040-ipatests-Add-selfservice-find-cli-tests-to-xmlrpc-Ad.patch b/0040-ipatests-Add-selfservice-find-cli-tests-to-xmlrpc-Ad.patch new file mode 100644 index 0000000..f333392 --- /dev/null +++ b/0040-ipatests-Add-selfservice-find-cli-tests-to-xmlrpc-Ad.patch @@ -0,0 +1,506 @@ +From c294159878de52fa5762025ee4be893f280c5320 Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Tue, 31 Mar 2026 15:38:36 +0200 +Subject: [PATCH] ipatests: Add selfservice-find cli tests to xmlrpc + Add a new Declarative test class test_selfservice_cli_find covering CLI-level + behaviour of the selfservice-find command: + +- find with --all succeeds and returns the matching rule +- non-existent or wrong attrs filter returns zero results +- bad --name filter returns zero results with valid and invalid + positional arg +- bad permissions filter with --all --raw returns zero results, + no internal error (BZ 747693) +- all valid params with --all --raw succeeds and returns the raw + ACI string (BZ 747693) +- wrong or non-existent attrs filter returns zero results with and + without name arg +- valid --attrs filter succeeds with positional name arg and with + --name option +- valid --name filter succeeds and returns the matching rule +- bad permissions filter returns zero results +- valid --permissions filter succeeds with positional name arg and + with --name option +- --raw only succeeds and returns the raw ACI string, no internal + error (BZ 747693) + +Signed-off-by: Jay Gondaliya +Fixes: https://pagure.io/freeipa/issue/9945 +Assisted-by: Claude +Reviewed-By: Florence Blanc-Renaud +Reviewed-By: Rafael Guterres Jeffman +Reviewed-By: David Hanina +--- + .../test_xmlrpc/test_selfservice_plugin.py | 456 ++++++++++++++++++ + 1 file changed, 456 insertions(+) + +diff --git a/ipatests/test_xmlrpc/test_selfservice_plugin.py b/ipatests/test_xmlrpc/test_selfservice_plugin.py +index 48dfd7cc3..9e921156a 100644 +--- a/ipatests/test_xmlrpc/test_selfservice_plugin.py ++++ b/ipatests/test_xmlrpc/test_selfservice_plugin.py +@@ -944,6 +944,462 @@ class test_selfservice_cli_add_del(Declarative): + ] + + ++# selfservice-find CLI test rule name ++SS_CLI_FIND = 'SELFSERVICE_FIND_TEST' ++ ++ ++@pytest.mark.tier1 ++class test_selfservice_cli_find(Declarative): ++ """Tests for the selfservice-find CLI command.""" ++ ++ cleanup_commands = [ ++ ('selfservice_del', [SS_CLI_FIND], {}), ++ ] ++ ++ tests = [ ++ ++ # Setup: create the rule used by all find tests ++ dict( ++ desc='Setup: create %r' % SS_CLI_FIND, ++ command=( ++ 'selfservice_add', ++ [SS_CLI_FIND], ++ dict(attrs=['l'], permissions='write'), ++ ), ++ expected=dict( ++ value=SS_CLI_FIND, ++ summary='Added selfservice "%s"' % SS_CLI_FIND, ++ result=dict( ++ attrs=['l'], ++ permissions=['write'], ++ selfaci=True, ++ aciname=SS_CLI_FIND, ++ ), ++ ), ++ ), ++ ++ # Find with --all returns the parsed result ++ dict( ++ desc='Search for %r with --all' % SS_CLI_FIND, ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict(all=True), ++ ), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 selfservice matched', ++ result=[{ ++ 'attrs': ['l'], ++ 'permissions': ['write'], ++ 'selfaci': True, ++ 'aciname': SS_CLI_FIND, ++ }], ++ ), ++ ), ++ ++ # Bad attrs filter -- aci_find does pure string ++ # comparison; no schema validation in find. ++ dict( ++ desc=( ++ 'Non-existent attr with all filters' ++ ' returns no match (--all --raw)' ++ ), ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict( ++ all=True, ++ attrs=['badattrs'], ++ aciname=SS_CLI_FIND, ++ permissions='write', ++ raw=True, ++ ), ++ ), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 selfservices matched', ++ result=[], ++ ), ++ ), ++ ++ # Wrong attr for this rule (has 'l', not 'mobile') ++ dict( ++ desc=( ++ 'Wrong attr for rule with all filters' ++ ' returns no match (--all --raw)' ++ ), ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict( ++ all=True, ++ attrs=['mobile'], ++ aciname=SS_CLI_FIND, ++ permissions='write', ++ raw=True, ++ ), ++ ), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 selfservices matched', ++ result=[], ++ ), ++ ), ++ ++ # Bad --name filter with --all --raw ++ dict( ++ desc=( ++ 'Valid name arg with bad --name filter' ++ ' returns no match (--all --raw)' ++ ), ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict( ++ all=True, ++ attrs=['l'], ++ aciname='badname', ++ permissions='write', ++ raw=True, ++ ), ++ ), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 selfservices matched', ++ result=[], ++ ), ++ ), ++ ++ # Bad name arg also set to 'badname' ++ dict( ++ desc=( ++ 'Bad name arg with bad --name filter' ++ ' returns no match (--all --raw)' ++ ), ++ command=( ++ 'selfservice_find', ++ ['badname'], ++ dict( ++ all=True, ++ attrs=['l'], ++ aciname='badname', ++ permissions='write', ++ raw=True, ++ ), ++ ), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 selfservices matched', ++ result=[], ++ ), ++ ), ++ ++ # Bad permissions with --all --raw (BZ 747693) ++ # selfservice-find --raw must not return "internal error". ++ # aci_find treats permissions as a plain string filter (no ++ # validation), so 'badperm' simply matches nothing. ++ dict( ++ desc=( ++ 'Bad permissions with --all --raw' ++ ' returns no match (BZ 747693)' ++ ), ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict( ++ all=True, ++ attrs=['l'], ++ aciname=SS_CLI_FIND, ++ permissions='badperm', ++ raw=True, ++ ), ++ ), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 selfservices matched', ++ result=[], ++ ), ++ ), ++ ++ # All valid params with --all --raw (BZ 747693) ++ # selfservice-find --raw must not return "internal error". ++ dict( ++ desc=( ++ 'All valid params with --all --raw' ++ ' returns raw ACI (BZ 747693)' ++ ), ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict( ++ all=True, ++ attrs=['l'], ++ aciname=SS_CLI_FIND, ++ permissions='write', ++ raw=True, ++ ), ++ ), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 selfservice matched', ++ result=[{ ++ 'aci': ( ++ '(targetattr = "l")' ++ '(version 3.0;acl "selfservice:%s";' ++ 'allow (write) ' ++ 'userdn = "ldap:///self";)' ++ % SS_CLI_FIND ++ ), ++ }], ++ ), ++ ), ++ ++ # Bad attrs filter without --all --raw ++ dict( ++ desc='Wrong attr filter returns no match', ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict(attrs=['mobile']), ++ ), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 selfservices matched', ++ result=[], ++ ), ++ ), ++ ++ # Non-existent attr in filter ++ dict( ++ desc='Non-existent attr filter returns no match', ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict(attrs=['badattrs']), ++ ), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 selfservices matched', ++ result=[], ++ ), ++ ), ++ ++ # Non-existent attr without name arg ++ dict( ++ desc='Non-existent attr without name arg returns no match', ++ command=( ++ 'selfservice_find', ++ [], ++ dict(attrs=['badattrs']), ++ ), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 selfservices matched', ++ result=[], ++ ), ++ ), ++ ++ # Valid attrs filter ++ dict( ++ desc='Valid attrs filter with name arg returns match', ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict(attrs=['l']), ++ ), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 selfservice matched', ++ result=[{ ++ 'attrs': ['l'], ++ 'permissions': ['write'], ++ 'selfaci': True, ++ 'aciname': SS_CLI_FIND, ++ }], ++ ), ++ ), ++ ++ # Without positional name arg but with --name ++ # filter to get a deterministic result. ++ dict( ++ desc='Valid attrs filter with --name option returns match', ++ command=( ++ 'selfservice_find', ++ [], ++ dict(attrs=['l'], aciname=SS_CLI_FIND), ++ ), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 selfservice matched', ++ result=[{ ++ 'attrs': ['l'], ++ 'permissions': ['write'], ++ 'selfaci': True, ++ 'aciname': SS_CLI_FIND, ++ }], ++ ), ++ ), ++ ++ # Bad --name filter ++ dict( ++ desc='Valid name arg with bad --name filter returns no match', ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict(aciname='badname'), ++ ), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 selfservices matched', ++ result=[], ++ ), ++ ), ++ ++ # Bad name arg also set to 'badname' ++ dict( ++ desc='Bad name arg with bad --name filter returns no match', ++ command=( ++ 'selfservice_find', ++ ['badname'], ++ dict(aciname='badname'), ++ ), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 selfservices matched', ++ result=[], ++ ), ++ ), ++ ++ # Valid --name filter ++ dict( ++ desc='Valid --name filter returns match', ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict(aciname=SS_CLI_FIND), ++ ), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 selfservice matched', ++ result=[{ ++ 'attrs': ['l'], ++ 'permissions': ['write'], ++ 'selfaci': True, ++ 'aciname': SS_CLI_FIND, ++ }], ++ ), ++ ), ++ ++ # Bad permissions filter -- aci_find treats permissions ++ # as a plain string filter; 'badperm' matches nothing. ++ dict( ++ desc='Bad permissions filter returns no match', ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict(permissions='badperm'), ++ ), ++ expected=dict( ++ count=0, ++ truncated=False, ++ summary='0 selfservices matched', ++ result=[], ++ ), ++ ), ++ ++ # Valid permissions filter ++ dict( ++ desc='Valid permissions filter with name arg returns match', ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict(permissions='write'), ++ ), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 selfservice matched', ++ result=[{ ++ 'attrs': ['l'], ++ 'permissions': ['write'], ++ 'selfaci': True, ++ 'aciname': SS_CLI_FIND, ++ }], ++ ), ++ ), ++ ++ # Without positional name arg but with --name ++ # filter to get a deterministic result. ++ dict( ++ desc=( ++ 'Valid permissions filter with --name' ++ ' option returns match' ++ ), ++ command=( ++ 'selfservice_find', ++ [], ++ dict( ++ permissions='write', ++ aciname=SS_CLI_FIND, ++ ), ++ ), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 selfservice matched', ++ result=[{ ++ 'attrs': ['l'], ++ 'permissions': ['write'], ++ 'selfaci': True, ++ 'aciname': SS_CLI_FIND, ++ }], ++ ), ++ ), ++ ++ # Raw output only (BZ 747693) ++ # selfservice-find --raw must not return "internal error". ++ dict( ++ desc='Raw output returns ACI string without error (BZ 747693)', ++ command=( ++ 'selfservice_find', ++ [SS_CLI_FIND], ++ dict(raw=True), ++ ), ++ expected=dict( ++ count=1, ++ truncated=False, ++ summary='1 selfservice matched', ++ result=[{ ++ 'aci': ( ++ '(targetattr = "l")' ++ '(version 3.0;acl "selfservice:%s";' ++ 'allow (write) ' ++ 'userdn = "ldap:///self";)' ++ % SS_CLI_FIND ++ ), ++ }], ++ ), ++ ), ++ ++ ] ++ ++ + # selfservice-show & selfservice-mod CLI test rule names + + SS_CLI_SHOW = 'SELFSERVICE_SHOW_TEST' +-- +2.52.0 + diff --git a/0041-ipatests-fix-the-method-add_a_record.patch b/0041-ipatests-fix-the-method-add_a_record.patch new file mode 100644 index 0000000..275d4d6 --- /dev/null +++ b/0041-ipatests-fix-the-method-add_a_record.patch @@ -0,0 +1,36 @@ +From 0306d2c5ebb1732fb33f3da4199f29ec887e1db8 Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Thu, 9 Apr 2026 12:49:18 +0200 +Subject: [PATCH] ipatests: fix the method add_a_record + +The method add_a_record first checks if a DNS record exists. +If it finds one, it assumes there is already a DNS A record for +the host but this assumption is wrong. The dnsrecord-show command +may return another type of DNS record (for instance an SSHFP record). + +Create the A record if dnsrecord-show fails or if it doesn't return +any A record. + +Fixes: https://pagure.io/freeipa/issue/9972 +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Anuja More +--- + ipatests/pytest_ipa/integration/tasks.py | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/ipatests/pytest_ipa/integration/tasks.py b/ipatests/pytest_ipa/integration/tasks.py +index ee2befd6998da3b8035e4e015d7528b5a9676a7b..5ce18483d6c1049104afc1cef6524a7a19fc9047 100755 +--- a/ipatests/pytest_ipa/integration/tasks.py ++++ b/ipatests/pytest_ipa/integration/tasks.py +@@ -1620,7 +1620,7 @@ def add_a_record(master, host): + raiseonerr=False) + + # If not, add it +- if cmd.returncode != 0: ++ if cmd.returncode != 0 or 'A record' not in cmd.stdout_text: + master.run_command(['ipa', + 'dnsrecord-add', + master.domain.name, +-- +2.52.0 + diff --git a/0042-ipatests-Remove-xfail-for-sssd-issues-7169.patch b/0042-ipatests-Remove-xfail-for-sssd-issues-7169.patch new file mode 100644 index 0000000..646a3e1 --- /dev/null +++ b/0042-ipatests-Remove-xfail-for-sssd-issues-7169.patch @@ -0,0 +1,48 @@ +From a1b1a45c45f521659ee25709141b6470599030d0 Mon Sep 17 00:00:00 2001 +From: David Hanina +Date: Mon, 13 Apr 2026 16:23:24 +0200 +Subject: [PATCH] ipatests-Remove xfail for sssd/issues/7169 + +4 of the failing tests are now succeeding, removing the xfail. + +Signed-off-by: David Hanina +--- + ipatests/test_integration/test_trust.py | 18 +++--------------- + 1 file changed, 3 insertions(+), 15 deletions(-) + +diff --git a/ipatests/test_integration/test_trust.py b/ipatests/test_integration/test_trust.py +index 0cab277c910a6d35f35b57e3068ee6f38706af59..d7f25d658b24f3b261260e735d3679e81e35ed15 100644 +--- a/ipatests/test_integration/test_trust.py ++++ b/ipatests/test_integration/test_trust.py +@@ -1219,14 +1219,7 @@ class TestNonPosixAutoPrivateGroup(BaseTestTrust): + assert (uid == self.uid_override and gid == self.gid_override) + test_group = self.clients[0].run_command( + ["id", nonposixuser]).stdout_text +- cond2 = (((type == 'false' +- and sssd_version >= tasks.parse_version("2.9.4")) +- or type == 'hybrid') +- and sssd_version < tasks.parse_version("2.12.0")) +- with xfail_context(cond2, +- 'https://github.com/SSSD/sssd/issues/5989 ' +- 'and 7169'): +- assert "domain users@{0}".format(self.ad_domain) in test_group ++ assert "domain users@{0}".format(self.ad_domain) in test_group + + @pytest.mark.parametrize('type', ['hybrid', 'true', "false"]) + def test_nonposixuser_nondefault_primary_group(self, type): +@@ -1347,10 +1340,5 @@ class TestPosixAutoPrivateGroup(BaseTestTrust): + assert (uid == self.uid_override + and gid == self.gid_override) + result = self.clients[0].run_command(['id', posixuser]) +- sssd_version = tasks.get_sssd_version(self.clients[0]) +- bad_version = (tasks.parse_version("2.9.4") <= sssd_version +- < tasks.parse_version("2.12.0")) +- with xfail_context(bad_version and type in ('false', 'hybrid'), +- "https://github.com/SSSD/sssd/issues/7169"): +- assert "10047(testgroup@{0})".format( +- self.ad_domain) in result.stdout_text ++ assert "10047(testgroup@{0})".format( ++ self.ad_domain) in result.stdout_text +-- +2.52.0 + diff --git a/0043-Fix-ipa-ca-show-ipa-all-not-listing-RSN-version.patch b/0043-Fix-ipa-ca-show-ipa-all-not-listing-RSN-version.patch new file mode 100644 index 0000000..d473134 --- /dev/null +++ b/0043-Fix-ipa-ca-show-ipa-all-not-listing-RSN-version.patch @@ -0,0 +1,28 @@ +From 86a6e9cef5a3fdea3cb84c39575c90481bfe1623 Mon Sep 17 00:00:00 2001 +From: David Hanina +Date: Tue, 14 Apr 2026 14:24:13 +0200 +Subject: [PATCH] Fix ipa ca-show ipa --all not listing RSN version + +Resolves: RHEL-168047 + +Signed-off-by: David Hanina +--- + ipaserver/install/cainstance.py | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/ipaserver/install/cainstance.py b/ipaserver/install/cainstance.py +index 4738517..8e7512f 100644 +--- a/ipaserver/install/cainstance.py ++++ b/ipaserver/install/cainstance.py +@@ -1681,7 +1681,7 @@ class CAInstance(DogtagInstance): + api.env.basedn) + entry_attrs = api.Backend.ldap2.get_entry(dn) + version = entry_attrs.single_value.get( +- "ipaCaRandomSerialNumberVersion", "0" ++ "ipaCaRandomSerialNumberVersion", "-" + ) + if str(version) == str(value): + return +-- +2.52.0 + diff --git a/freeipa.spec b/freeipa.spec index 7704463..d0aade7 100644 --- a/freeipa.spec +++ b/freeipa.spec @@ -229,7 +229,7 @@ Name: %{package_name} Version: %{IPA_VERSION} -Release: 3%{?rc_version:.%rc_version}%{?dist} +Release: 3%{?rc_version:.%rc_version}%{?dist}.1 Summary: The Identity, Policy and Audit system License: GPL-3.0-or-later @@ -267,6 +267,29 @@ Patch0017: 0017-Replace-None-with-when-uninstalling-CA.patch Patch0018: 0018-ipatests-Add-xmlrpc-tests-for-ipa-delegation-cli.patch Patch0019: 0019-ipa-join-initialize-pointer.patch Patch0020: 0020-ipatests-pruning-is-enabled-when-RSN-is-enabled.patch +Patch0021: 0021-ipatests-Add-DNS-bugzilla-integration-tests.patch +Patch0022: 0022-Avoid-int-overflow-with-pwpolicy-minlife.patch +Patch0023: 0023-ipatests-fix-install-method-for-BasePWpolicy.patch +Patch0024: 0024-webui-tests-update-expected-max-value-for-krbminpwdl.patch +Patch0025: 0025-ipatests-Add-DNS-integration-tests.patch +Patch0026: 0026-ipatest-make-tests-compatible-with-Pytest-9.patch +Patch0027: 0027-ipatests-Add-ipa-selfservice-BZ-tests-to-xmlrpc.patch +Patch0028: 0028-Allow-32bit-gid.patch +Patch0029: 0029-ipatests-Add-ipa-selfservice-users-tests-to-xmlrpc.patch +Patch0030: 0030-ipatests-Fix-test_allow_query_transfer_ipv6-when-IPv.patch +Patch0031: 0031-ipatests-Add-XML-RPC-tests-for-i18n-user-attributes.patch +Patch0032: 0032-ipatests-Add-selfservice-add-and-selfservice-del-cli.patch +Patch0033: 0033-ipatests-Additional-tests-for-32BitIdranges.patch +Patch0034: 0034-ipatests-add-HTTP-GSSAPI-Kerberos-authentication-tes.patch +Patch0035: 0035-ipatests-Extend-netgroup-test-coverage.patch +Patch0036: 0036-ipatests-Add-user-principal-ipa-getkeytab-and-ipa-rm.patch +Patch0037: 0037-ipatests-Additional-tests-for-ipa-ipa-migration-test.patch +Patch0038: 0038-ipatests-ipa-migrate-ds-test-scenarios.patch +Patch0039: 0039-ipatests-Add-ipa-selfservice-show-and-selfservice-mo.patch +Patch0040: 0040-ipatests-Add-selfservice-find-cli-tests-to-xmlrpc-Ad.patch +Patch0041: 0041-ipatests-fix-the-method-add_a_record.patch +Patch0042: 0042-ipatests-Remove-xfail-for-sssd-issues-7169.patch +Patch0043: 0043-Fix-ipa-ca-show-ipa-all-not-listing-RSN-version.patch Patch1001: 1001-Change-branding-to-IPA-and-Identity-Management.patch %endif %endif @@ -1979,6 +2002,12 @@ fi %endif %changelog +* Mon Apr 13 2026 David Hanina - 4.13.1-3.1 +- Resolves: RHEL-166865 Include latest fixes in python3-ipatests package [rhel-9.8.z] +- Resolves: RHEL-155037 Pagure #9953: Adding a group with 32Bit Idrange fails. [rhel-9.8.z] +- Resolves: RHEL-153146 IdM password policy Min lifetime is not enforced when high minlife is set [rhel-9.8.z] +- Resolves: RHEL-168047 ipa ca-show ipa --all failing to list RSN version + * Tue Feb 10 2026 Florence Blanc-Renaud - 4.13.1-3 - RHEL-148282 ipa-replica-conncheck fails with "an internal error has occured" - RHEL-148481 Pruning is enabled by default with RSN on RHEL 9.8