/* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2008-2010 Sun Microsystems, Inc. * Portions Copyright 2015-2016 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.replication.plugin; import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.opends.messages.ReplicationMessages.ERR_OPERATION_NOT_FOUND_IN_PENDING; import static org.opends.server.TestCaseUtils.TEST_ROOT_DN_STRING; import static org.opends.server.replication.plugin.ReplicationRepairRequestControl.OID_REPLICATION_REPAIR_CONTROL; import static org.opends.server.types.NullOutputStream.nullPrintStream; import static org.testng.Assert.assertEquals; import java.net.SocketTimeoutException; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import org.forgerock.opendj.ldap.Connection; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.LDAPConnectionFactory; import org.forgerock.opendj.ldap.LdapException; import org.forgerock.opendj.ldap.ModificationType; import org.forgerock.opendj.ldap.ResultCode; import org.forgerock.opendj.ldap.SearchScope; import org.forgerock.opendj.ldap.controls.GenericControl; import org.forgerock.opendj.ldap.controls.ProxiedAuthV2RequestControl; import org.forgerock.opendj.ldap.requests.AddRequest; import org.forgerock.opendj.ldap.requests.ModifyRequest; import org.forgerock.opendj.ldap.requests.Requests; import org.forgerock.opendj.ldap.responses.SearchResultEntry; import org.opends.server.TestCaseUtils; import org.opends.server.replication.ReplicationTestCase; import org.opends.server.replication.protocol.DeleteMsg; import org.opends.server.replication.protocol.LDAPUpdateMsg; import org.opends.server.replication.protocol.ModifyMsg; import org.opends.server.replication.protocol.ReplicationMsg; import org.opends.server.replication.service.ReplicationBroker; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; import com.forgerock.opendj.ldap.tools.LDAPModify; /** * Tests the replication repair request control, which lets an administrator write the * attributes replication owns - {@code entryUUID}, {@code ds-sync-hist} - on the replica the * request is sent to, and on that replica only. *

* The suffix is replicated and a broker listens on its replication server: what a repair * publishes, if anything, is read from there. */ @SuppressWarnings("javadoc") public class ReplicationRepairControlTest extends ReplicationTestCase { private static final String REPAIRED_DN = "cn=repair me," + TEST_ROOT_DN_STRING; private static final String HISTORY = "ds-sync-hist"; /** * The values the repair gives the entry - not the ones {@link #testRepairControl()} gives to * an entry of its own: two entries under one {@code entryUUID} is the corruption this control * can cause, and a case reading by UUID would then pick either of them. */ private static final String REPAIRED_UUID = "3d2a6b4c-7f1e-4c62-9a0d-5e8b2c1f4a77"; private static final String REPAIRED_NS_UNIQUE_ID = "3d2a6b4c-7f1e4c62-9a0d5e8b-2c1f4a77"; /** * The record the error logger writes carries the id of the message rather than its text, so * what is looked for here does not depend on the locale the tests run under. The publishers * on {@link TestCaseUtils#ERROR_TEXT_WRITER} write the plain ordinal, which the trailing * space - the record always has {@code msg=} after the id - keeps from matching the * ordinals of other messages which begin with the same digits. */ private static final String NOT_IN_PENDING = "msgID=" + ERR_OPERATION_NOT_FOUND_IN_PENDING.get("", "").ordinal() + " "; /** A client which may write anything under the suffix, but has no {@code bypass-acl} privilege. */ private static final String USER_DN = "uid=repair.user," + TEST_ROOT_DN_STRING; /** A client which may use any control, and act as {@link #USER_DN} through proxied authorization. */ private static final String PROXY_DN = "uid=repair.proxy," + TEST_ROOT_DN_STRING; private static final String ACCESS_HANDLER_DN = "cn=Access Control Handler,cn=config"; /** The global ACI of the test configuration which lets anyone use any control. */ private static final String ANY_CONTROL_ACI = "(targetcontrol=\"*\")" + " (version 3.0; acl \"Anonymous control access\"; allow(read) userdn=\"ldap:///anyone\";)"; /** Lets {@link #USER_DN} use the repair control, and no other control. */ private static final String REPAIR_CONTROL_ACI = "(targetcontrol=\"" + OID_REPLICATION_REPAIR_CONTROL + "\")" + "(version 3.0; acl \"Repair control access\"; allow(read) userdn=\"ldap:///" + USER_DN + "\";)"; private ReplicationBroker broker; private LDAPConnectionFactory factory; private Connection connection; @Override @BeforeClass(alwaysRun = true) public void setUp() throws Exception { super.setUp(); final DN baseDN = DN.valueOf(TEST_ROOT_DN_STRING); TestCaseUtils.initializeTestBackend(true); final int replServerPort = TestCaseUtils.findFreePort(); final String replServerLdif = "dn: cn=Replication Server, " + SYNCHRO_PLUGIN_DN + "\n" + "objectClass: top\n" + "objectClass: ds-cfg-replication-server\n" + "cn: Replication Server\n" + "ds-cfg-replication-port: " + replServerPort + "\n" + "ds-cfg-replication-db-directory: ReplicationRepairControlTest\n" + "ds-cfg-replication-server-id: 106\n"; final String synchroServerLdif = "dn: cn=replicationRepairControlTest, cn=domains, " + SYNCHRO_PLUGIN_DN + "\n" + "objectClass: top\n" + "objectClass: ds-cfg-replication-domain\n" + "cn: replicationRepairControlTest\n" + "ds-cfg-base-dn: " + baseDN + "\n" + "ds-cfg-replication-server: localhost:" + replServerPort + "\n" + "ds-cfg-server-id: 1\n" + "ds-cfg-receive-status: true\n"; configureReplication(replServerLdif, synchroServerLdif); broker = openReplicationSession(baseDN, 2, 100, replServerPort, 1000); factory = new LDAPConnectionFactory("localhost", TestCaseUtils.getServerLdapPort()); connection = factory.getConnection(); connection.bind("cn=Directory Manager", "password".toCharArray()); // The user may write anything under the suffix, operational attributes included: what // decides whether it may repair is the access control of the controls alone. TestCaseUtils.addEntry( "dn: " + USER_DN, "objectClass: top", "objectClass: person", "objectClass: organizationalPerson", "objectClass: inetOrgPerson", "uid: repair.user", "sn: User", "cn: Repair User", "userPassword: password"); assertThat(nextUpdate()).as("the add of the user was not published").isNotNull(); connection.modify(Requests.newModifyRequest(TEST_ROOT_DN_STRING).addModification(ModificationType.ADD, "aci", "(targetattr=\"*||+\")(version 3.0; acl \"Repair control test user\"; allow(all) userdn=\"ldap:///" + USER_DN + "\";)")); assertThat(nextUpdate()).as("the ACI of the user was not published").isNotNull(); } @AfterClass(alwaysRun = true) public void tearDown() throws Exception { if (connection != null) { connection.close(); } if (factory != null) { factory.close(); } stop(broker); } @Test public void testRepairControl() throws Exception { // Test that we can't add an entry with the entryuuid attribute // without specifying the replication repair control. String path = TestCaseUtils.createTempFile( "dn: uid=test.repair," + TEST_ROOT_DN_STRING + "\n" + "changetype: add\n" + "objectClass: top\n" + "objectClass: person\n" + "objectClass: organizationalPerson\n" + "objectClass: inetOrgPerson\n" + "uid: test.repair\n" + "givenName: Test\n" + "sn: User\n" + "cn: Test User\n" + "userPassword: password\n" + "entryuuid: d5b910d8-47cb-4ac0-9e5f-0f4a77de58d4\n"); String[] args = { "-h", "127.0.0.1", "-p", String.valueOf(TestCaseUtils.getServerLdapPort()), "-D", "cn=Directory Manager", "-w", "password", "-f", path }; assertEquals(LDAPModify.run(nullPrintStream(), nullPrintStream(), args), 53); // Test that we can't add an entry with the ds-sync-hist attribute // without specifying the replication repair control. String path1 = TestCaseUtils.createTempFile( "dn: uid=test.repair," + TEST_ROOT_DN_STRING + "\n" + "changetype: add\n" + "objectClass: top\n" + "objectClass: person\n" + "objectClass: organizationalPerson\n" + "objectClass: inetOrgPerson\n" + "uid: test.repair\n" + "givenName: Test\n" + "sn: User\n" + "cn: Test User\n" + "userPassword: password\n" + "ds-sync-hist: description:00000108b3a6cbb800000001:del:deleted_value\n"); String[] args1 = { "-h", "127.0.0.1", "-p", String.valueOf(TestCaseUtils.getServerLdapPort()), "-D", "cn=Directory Manager", "-w", "password", "-f", path1 }; assertEquals(LDAPModify.run(nullPrintStream(), nullPrintStream(), args1), 53); // Now Test specifying the replication repair control makes // possible to add an entry with the entryuuid and ds-sync-hist attributes // (notice the -J repairControlOid in the ldapmodify arguments) String path2 = TestCaseUtils.createTempFile( "dn: uid=test.repair," + TEST_ROOT_DN_STRING + "\n" + "changetype: add\n" + "objectClass: top\n" + "objectClass: person\n" + "objectClass: organizationalPerson\n" + "objectClass: inetOrgPerson\n" + "uid: test.repair\n" + "givenName: Test\n" + "sn: User\n" + "cn: Test User\n" + "userPassword: password\n" + "ds-sync-hist: description:00000108b3a6cbb800000001:del:deleted_value\n" + "entryuuid: d5b910d8-47cb-4ac0-9e5f-0f4a77de58d4\n"); String[] args2 = { "-h", "127.0.0.1", "-p", String.valueOf(TestCaseUtils.getServerLdapPort()), "-D", "cn=Directory Manager", "-w", "password", "-J", "1.3.6.1.4.1.26027.1.5.2", "-f", path2 }; assertEquals(LDAPModify.run(nullPrintStream(), nullPrintStream(), args2), 0); // A repair is a change to this replica alone: the add was not published. assertThat(nextUpdate()).as("the repaired add was published to the topology").isNull(); } /** * An administrator setting the entryUUID of an entry - to give it back the value another * directory had, say - does it with the repair control, on every replica in turn: the * change is not published, and replication does not treat it as a change of its own either. */ @Test public void aRepairedModifySetsTheEntryUUIDOnThisReplicaOnly() throws Exception { TestCaseUtils.addEntry( "dn: " + REPAIRED_DN, "objectClass: top", "objectClass: person", "sn: repair", "cn: repair me"); assertThat(nextUpdate()).as("the add of the entry to repair was not published").isNotNull(); assertThat(attributeOfRepairedEntry("entryUUID")).isNotNull().isNotEqualTo(REPAIRED_UUID); // Without the control, entryUUID is NO-USER-MODIFICATION for an administrator too. try { connection.modify(repairRequest(false)); throw new AssertionError("entryUUID was modified without the repair control"); } catch (LdapException e) { assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.CONSTRAINT_VIOLATION); } // The add of the entry was a replicated change, so the entry carries a history already: // what the repair must leave alone is something rather than nothing. final Set historyBeforeTheRepair = valuesOfRepairedEntry(HISTORY); assertThat(historyBeforeTheRepair).as("the entry to repair carries no history").isNotEmpty(); TestCaseUtils.ERROR_TEXT_WRITER.clear(); try { connection.modify(repairRequest(true)); } finally { // Read the history back on every road out of the modify, including the ones where it // failed: the entry keeps the history it had, and a repair which ends badly must not // have left a record of itself there either. assertThat(valuesOfRepairedEntry(HISTORY)) .as("the repair was recorded in the history of the entry") .isEqualTo(historyBeforeTheRepair); } assertThat(attributeOfRepairedEntry("entryUUID")).isEqualTo(REPAIRED_UUID); assertThat(attributeOfRepairedEntry("nsUniqueId")).isEqualTo(REPAIRED_NS_UNIQUE_ID); assertThat(nextUpdate()).as("the repair was published to the topology").isNull(); // The change is not a replayed one either: replication is not left looking for it among // the changes it was replaying. final List records = new ArrayList<>(TestCaseUtils.ERROR_TEXT_WRITER.getMessages()); assertThat(records).as("the repair was reported as a change missing from the pending list") .noneMatch(record -> record.contains(NOT_IN_PENDING)); } /** * The procedure has the control sent as not critical, and on a modify it has to be: the * backend refuses a critical control it does not know, and it does so before the replication * plugin takes the control off the request. */ @Test(dependsOnMethods = "aRepairedModifySetsTheEntryUUIDOnThisReplicaOnly") public void aCriticalRepairControlIsRefusedOnAModify() throws Exception { final ModifyRequest critical = Requests.newModifyRequest(REPAIRED_DN) .addModification(ModificationType.REPLACE, "entryUUID", REPAIRED_UUID) .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL, true)); try { connection.modify(critical); throw new AssertionError("a critical repair control was accepted on a modify"); } catch (LdapException e) { assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.UNAVAILABLE_CRITICAL_EXTENSION); } } /** A repaired entry goes on replicating as any other, under its repaired entryUUID. */ @Test(dependsOnMethods = "aRepairedModifySetsTheEntryUUIDOnThisReplicaOnly") public void aChangeAfterTheRepairIsPublishedUnderTheRepairedEntryUUID() throws Exception { connection.modify(Requests.newModifyRequest(REPAIRED_DN) .addModification(ModificationType.REPLACE, "description", "changed after the repair")); final LDAPUpdateMsg published = nextUpdate(); assertThat(published).as("a change after the repair was not published").isInstanceOf(ModifyMsg.class); assertThat(published.getEntryUUID()).isEqualTo(attributeOfRepairedEntry("entryUUID")); } /** * A client which may write the entries, but which no ACI lets use the control, gets no repair * out of it on any operation: the control is dropped, as any control the client may not use, * and the request is processed as if it had not been sent. On a modify the backend does that * itself; an add and a delete reach the replication plugin before the backend checks their * controls, and they used to be repaired there all the same. */ @Test public void aClientNoAciLetsUseTheControlGetsNoRepair() throws Exception { final String dn = "cn=not repaired," + TEST_ROOT_DN_STRING; TestCaseUtils.addEntry( "dn: " + dn, "objectClass: top", "objectClass: person", "sn: not repaired", "cn: not repaired"); assertThat(nextUpdate()).as("the add of the entry was not published").isNotNull(); final Runnable restoreTheGlobalControlAci = withGlobalControlAci(null); try (Connection user = connectAsUser()) { try { user.modify(Requests.newModifyRequest(dn) .addModification(ModificationType.REPLACE, "entryUUID", REPAIRED_UUID) .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL))); throw new AssertionError("a client no ACI lets use the control repaired a modify"); } catch (LdapException e) { assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.CONSTRAINT_VIOLATION); } final String addedDN = "cn=added with the control," + TEST_ROOT_DN_STRING; try { user.add(Requests.newAddRequest( "dn: " + addedDN, "objectClass: top", "objectClass: person", "sn: added", "cn: added with the control", "entryUUID: " + REPAIRED_UUID) .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL))); throw new AssertionError("a client no ACI lets use the control repaired an add"); } catch (LdapException e) { assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.UNWILLING_TO_PERFORM); } assertThat(nextUpdate()).as("a refused add was published").isNull(); } finally { restoreTheGlobalControlAci.run(); } } /** * A repaired delete would remove the entry from this replica alone: dropped, the control * leaves an ordinary delete, which the other replicas are told about. */ @Test public void aDeleteByAClientNoAciLetsUseTheControlIsPublished() throws Exception { final String dn = "cn=deleted with the control," + TEST_ROOT_DN_STRING; TestCaseUtils.addEntry( "dn: " + dn, "objectClass: top", "objectClass: person", "sn: deleted", "cn: deleted with the control"); assertThat(nextUpdate()).as("the add of the entry was not published").isNotNull(); final Runnable restoreTheGlobalControlAci = withGlobalControlAci(null); try (Connection user = connectAsUser()) { user.delete(Requests.newDeleteRequest(dn) .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL))); } finally { restoreTheGlobalControlAci.run(); } assertThat(nextUpdate()).as("the delete was not published").isInstanceOf(DeleteMsg.class); } /** A client an ACI lets use the control repairs as an administrator does. */ @Test public void aClientAnAciLetsUseTheControlRepairs() throws Exception { final String dn = "cn=repaired by the user," + TEST_ROOT_DN_STRING; final String uuid = "8f4c2e1a-6b3d-4a9e-b7c5-1d2e3f4a5b6c"; final Runnable restoreTheGlobalControlAci = withGlobalControlAci(REPAIR_CONTROL_ACI); try (Connection user = connectAsUser()) { user.add(Requests.newAddRequest( "dn: " + dn, "objectClass: top", "objectClass: person", "sn: repaired", "cn: repaired by the user", "entryUUID: " + uuid) .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL))); } finally { restoreTheGlobalControlAci.run(); } final SearchResultEntry added = connection.searchSingleEntry( Requests.newSearchRequest(dn, SearchScope.BASE_OBJECT, "(objectClass=*)").addAttribute("entryUUID")); assertThat(added.parseAttribute("entryUUID").asString()).isEqualTo(uuid); assertThat(nextUpdate()).as("the repaired add was published to the topology").isNull(); } /** * A control the client may not use is refused when it is critical: on a delete, which reaches * the replication plugin before the backend checks its controls, the plugin must leave it on * the request for the backend to refuse, rather than drop it. */ @Test public void aCriticalRepairControlAClientMayNotUseIsRefusedOnADelete() throws Exception { final String dn = "cn=kept by a critical control," + TEST_ROOT_DN_STRING; TestCaseUtils.addEntry( "dn: " + dn, "objectClass: top", "objectClass: person", "sn: kept", "cn: kept by a critical control"); assertThat(nextUpdate()).as("the add of the entry was not published").isNotNull(); ResultCode resultCode = ResultCode.SUCCESS; final Runnable restoreTheGlobalControlAci = withGlobalControlAci(null); try (Connection user = connectAsUser()) { user.delete(Requests.newDeleteRequest(dn) .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL, true))); } catch (LdapException e) { resultCode = e.getResult().getResultCode(); } finally { restoreTheGlobalControlAci.run(); } // Read first: a delete carried out is published, and must not be left for the next case. assertThat(nextUpdate()).as("a critical control the client may not use was dropped, and the delete published") .isNull(); assertThat(resultCode).isEqualTo(ResultCode.UNAVAILABLE_CRITICAL_EXTENSION); } /** * The ACI of the controls is evaluated at the entry being repaired: on a delete, and on an add * too, where the backend evaluates the other controls of the request at the parent entry. */ @Test public void theRepairControlAciIsJudgedAtTheEntryBeingRepaired() throws Exception { final String repairable = "cn=repairable delete," + TEST_ROOT_DN_STRING; final String other = "cn=not repairable delete," + TEST_ROOT_DN_STRING; for (String dn : new String[] { repairable, other }) { final String cn = dn.substring("cn=".length(), dn.indexOf(',')); TestCaseUtils.addEntry("dn: " + dn, "objectClass: top", "objectClass: person", "sn: " + cn, "cn: " + cn); assertThat(nextUpdate()).as("the add of the entry was not published").isNotNull(); } final String repairableAdd = "cn=repairable add," + TEST_ROOT_DN_STRING; final String otherAdd = "cn=not repairable add," + TEST_ROOT_DN_STRING; final String uuid = "5b1d7e3a-2c4f-4e8b-9a6d-0f1e2d3c4b5a"; final Runnable restoreTheGlobalControlAci = withGlobalControlAci( "(target=\"ldap:///cn=repairable*," + TEST_ROOT_DN_STRING + "\")" + "(targetcontrol=\"" + OID_REPLICATION_REPAIR_CONTROL + "\")" + "(version 3.0; acl \"Repair control on some entries\"; allow(read) userdn=\"ldap:///" + USER_DN + "\";)"); try (Connection user = connectAsUser()) { user.delete(Requests.newDeleteRequest(other) .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL))); assertThat(nextUpdate()).as("a delete outside the ACI's target was repaired").isInstanceOf(DeleteMsg.class); user.delete(Requests.newDeleteRequest(repairable) .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL))); assertThat(nextUpdate()).as("a delete inside the ACI's target was published").isNull(); try { user.add(personWithEntryUUID(otherAdd, uuid) .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL))); throw new AssertionError("an add outside the ACI's target was repaired"); } catch (LdapException e) { assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.UNWILLING_TO_PERFORM); } try { user.add(personWithEntryUUID(repairableAdd, uuid) .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL))); } catch (LdapException e) { throw new AssertionError("an add inside the ACI's target was not repaired", e); } assertThat(nextUpdate()).as("an add inside the ACI's target was published").isNull(); } finally { restoreTheGlobalControlAci.run(); } } /** * On an add and a delete the replication plugin runs before the backend applies a proxied * authorization control, so it could only judge the bound client: it refuses the repair there, * even to a client which may use any control, and the request is processed as if the control * had not been sent. */ @Test public void aProxiedAddOrDeleteIsNotRepaired() throws Exception { TestCaseUtils.addEntry( "dn: " + PROXY_DN, "objectClass: top", "objectClass: person", "objectClass: organizationalPerson", "objectClass: inetOrgPerson", "uid: repair.proxy", "sn: Proxy", "cn: Repair Proxy", "userPassword: password", "ds-privilege-name: bypass-acl", "ds-privilege-name: proxied-auth"); assertThat(nextUpdate()).as("the add of the proxy was not published").isNotNull(); final String dn = "cn=deleted through a proxy," + TEST_ROOT_DN_STRING; TestCaseUtils.addEntry( "dn: " + dn, "objectClass: top", "objectClass: person", "sn: deleted", "cn: deleted through a proxy"); assertThat(nextUpdate()).as("the add of the entry was not published").isNotNull(); final ProxiedAuthV2RequestControl asUser = ProxiedAuthV2RequestControl.newControl("dn:" + USER_DN); final Runnable restoreTheGlobalControlAci = withGlobalControlAci(null); try (Connection proxy = factory.getConnection()) { proxy.bind(PROXY_DN, "password".toCharArray()); try { proxy.add(personWithEntryUUID("cn=added through a proxy," + TEST_ROOT_DN_STRING, REPAIRED_UUID) .addControl(asUser) .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL))); throw new AssertionError("a proxied add was repaired"); } catch (LdapException e) { assertThat(e.getResult().getResultCode()).isEqualTo(ResultCode.UNWILLING_TO_PERFORM); } assertThat(nextUpdate()).as("a refused add was published").isNull(); proxy.delete(Requests.newDeleteRequest(dn) .addControl(asUser) .addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL))); assertThat(nextUpdate()).as("a proxied delete was repaired").isInstanceOf(DeleteMsg.class); } finally { restoreTheGlobalControlAci.run(); } } private static AddRequest personWithEntryUUID(String dn, String uuid) { final String cn = dn.substring("cn=".length(), dn.indexOf(',')); return Requests.newAddRequest( "dn: " + dn, "objectClass: top", "objectClass: person", "sn: " + cn, "cn: " + cn, "entryUUID: " + uuid); } /** * Takes the global ACI which lets anyone use any control out of the configuration, and puts * {@code aci} there instead when it is not {@code null}. * * @return what puts the configuration back as it was */ private Runnable withGlobalControlAci(String aci) throws Exception { connection.modify(Requests.newModifyRequest(ACCESS_HANDLER_DN) .addModification(ModificationType.DELETE, "ds-cfg-global-aci", ANY_CONTROL_ACI)); if (aci != null) { connection.modify(Requests.newModifyRequest(ACCESS_HANDLER_DN) .addModification(ModificationType.ADD, "ds-cfg-global-aci", aci)); } return () -> { try { if (aci != null) { connection.modify(Requests.newModifyRequest(ACCESS_HANDLER_DN) .addModification(ModificationType.DELETE, "ds-cfg-global-aci", aci)); } connection.modify(Requests.newModifyRequest(ACCESS_HANDLER_DN) .addModification(ModificationType.ADD, "ds-cfg-global-aci", ANY_CONTROL_ACI)); } catch (LdapException e) { throw new AssertionError("the global ACI of the controls could not be put back", e); } }; } private Connection connectAsUser() throws Exception { final Connection user = factory.getConnection(); user.bind(USER_DN, "password".toCharArray()); return user; } private static ModifyRequest repairRequest(boolean withRepairControl) { final ModifyRequest request = Requests.newModifyRequest(REPAIRED_DN) .addModification(ModificationType.REPLACE, "entryUUID", REPAIRED_UUID) .addModification(ModificationType.REPLACE, "nsUniqueId", REPAIRED_NS_UNIQUE_ID); if (withRepairControl) { // Not critical: the control is taken off the request by the replication plugin, which // runs after the backend has refused a critical control it does not know. request.addControl(GenericControl.newControl(OID_REPLICATION_REPAIR_CONTROL)); } return request; } private String attributeOfRepairedEntry(String attribute) throws Exception { final Set values = valuesOfRepairedEntry(attribute); return values.isEmpty() ? null : values.iterator().next(); } /** * The attribute is asked for by name as well as by {@code *} and {@code +}: an operational * attribute is not always among the ones those two stand for, and a read which came back * empty would leave the assertions on it comparing nothing with nothing. */ private Set valuesOfRepairedEntry(String attribute) throws Exception { final SearchResultEntry entry = connection.searchSingleEntry( Requests.newSearchRequest(REPAIRED_DN, SearchScope.BASE_OBJECT, "(objectClass=*)") .addAttribute("*", "+", attribute)); return entry.containsAttribute(attribute) ? new HashSet<>(entry.parseAttribute(attribute).asSetOfString()) : Collections. emptySet(); } /** * The next update the replication server forwards, or {@code null} if none comes within a * few seconds - long enough for a change published by the operation which just returned, * which the replication server forwards as soon as it has it. */ private LDAPUpdateMsg nextUpdate() throws Exception { final long deadline = System.nanoTime() + SECONDS.toNanos(4); while (deadline - System.nanoTime() > 0) { final ReplicationMsg msg; try { msg = broker.receive(); } catch (SocketTimeoutException e) { // The broker reads under a timeout of its own, shorter than the budget here. continue; } if (msg == null) { // The broker reconnects a session it lost, and returns null only once it is stopped. throw new AssertionError("the broker was stopped"); } if (msg instanceof LDAPUpdateMsg) { return (LDAPUpdateMsg) msg; } } return null; } }