From 2b2f34cd5875a0f76a7a20862c1b59bc3ccfa50e Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Tue, 22 Sep 2026 09:21:58 +0000
Subject: [PATCH] [#1023] Delete every objectClass value a modification asks for (#1024)

---
 opendj-server-legacy/src/test/java/org/opends/server/types/TestEntry.java              |   63 +++++++++++++++++++++
 opendj-server-legacy/src/main/java/org/opends/server/types/Entry.java                  |   16 +++-
 opendj-server-legacy/src/test/java/org/opends/server/core/ModifyOperationTestCase.java |   85 ++++++++++++++++++++++++++++
 3 files changed, 159 insertions(+), 5 deletions(-)

diff --git a/opendj-server-legacy/src/main/java/org/opends/server/types/Entry.java b/opendj-server-legacy/src/main/java/org/opends/server/types/Entry.java
index f826fca..cbb62d1 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/types/Entry.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/types/Entry.java
@@ -1323,27 +1323,33 @@
       return true;
     }
 
-    boolean allSuccessful = true;
+    // The objectClass attribute is present as soon as the entry has an object class: a value of
+    // the modification which the entry does not have is a missing value, not an absent attribute.
+    boolean attributeTypePresent = !objectClasses.isEmpty();
 
     MatchingRule rule = attrType.getEqualityMatchingRule();
     for (ByteString v : attribute)
     {
       String ocName = toLowerName(rule, v);
 
+      boolean matchFound = false;
       for (ObjectClass oc : objectClasses.keySet())
       {
         if (oc.hasNameOrOID(ocName))
         {
           objectClasses.remove(oc);
-          return true;
+          matchFound = true;
+          break;
         }
       }
 
-      allSuccessful = false;
-      missingValues.add(v);
+      if (!matchFound)
+      {
+        missingValues.add(v);
+      }
     }
 
-    return allSuccessful;
+    return attributeTypePresent;
   }
 
   private boolean removeNonObjectClassAttribute(Attribute attribute, Collection<? super ByteString> missingValues)
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/core/ModifyOperationTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/core/ModifyOperationTestCase.java
index 31ed0c3..23a0ad2 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/core/ModifyOperationTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/core/ModifyOperationTestCase.java
@@ -2136,6 +2136,91 @@
 
 
   /**
+   * Adds the entry the object class deletes below work on: two auxiliary classes which the entry
+   * can lose without breaking the schema.
+   */
+  private void addUserWithTwoAuxiliaryObjectClasses(String baseDN) throws Exception
+  {
+    TestCaseUtils.addEntry(
+         "dn: uid=test.user," + baseDN,
+         "objectClass: top",
+         "objectClass: person",
+         "objectClass: organizationalPerson",
+         "objectClass: inetOrgPerson",
+         "objectClass: uidObject",
+         "objectClass: userSecurityInformation",
+         "uid: test.user",
+         "givenName: Test",
+         "sn: User",
+         "cn: Test User");
+  }
+
+  private void assertObjectClassesAre(String baseDN, String... objectClasses) throws Exception
+  {
+    Entry e = DirectoryServer.getEntry(DN.valueOf("uid=test.user," + baseDN));
+    assertThat(e.getObjectClasses().values()).containsOnly(objectClasses);
+  }
+
+  /**
+   * A delete of several object class values must remove every one of them, not just the first
+   * (see issue #1023).
+   */
+  @Test(dataProvider = "baseDNs")
+  public void testSuccessRemoveSeveralObjectClassValues(String baseDN) throws Exception
+  {
+    addUserWithTwoAuxiliaryObjectClasses(baseDN);
+
+    RawModification mod =
+        newRawModification(DELETE, "objectClass", "uidObject", "userSecurityInformation");
+    ModifyOperation modifyOperation = processModify("uid=test.user," + baseDN, mod);
+    assertEquals(modifyOperation.getResultCode(), ResultCode.SUCCESS);
+    retrieveSuccessfulOperationElements(modifyOperation);
+
+    assertObjectClassesAre(baseDN, "top", "person", "organizationalPerson", "inetOrgPerson");
+  }
+
+  /**
+   * A delete of several object class values one of which the entry does not have must name that
+   * value: the objectClass attribute itself is there, so this is a missing value and not an
+   * absent attribute.
+   */
+  @Test(dataProvider = "baseDNs")
+  public void testFailRemoveSeveralObjectClassValuesOneOfWhichIsMissing(String baseDN)
+         throws Exception
+  {
+    addUserWithTwoAuxiliaryObjectClasses(baseDN);
+
+    RawModification mod = newRawModification(DELETE, "objectClass", "uidObject", "domain");
+    ModifyOperation modifyOperation = processModify("uid=test.user," + baseDN, mod);
+    assertEquals(modifyOperation.getResultCode(), ResultCode.NO_SUCH_ATTRIBUTE);
+    assertThat(modifyOperation.getErrorMessage().toString()).contains("domain");
+    retrieveFailedOperationElements(modifyOperation);
+
+    assertObjectClassesAre(baseDN, "top", "person", "organizationalPerson", "inetOrgPerson",
+        "uidObject", "userSecurityInformation");
+  }
+
+  /**
+   * The permissive modify control drops the object class value which the entry does not have, and
+   * the values it does have are all removed.
+   */
+  @Test(dataProvider = "baseDNs")
+  public void testSuccessPermissiveModifyControlRemoveSeveralObjectClassValuesOneMissing(
+      String baseDN) throws Exception
+  {
+    addUserWithTwoAuxiliaryObjectClasses(baseDN);
+
+    ModifyRequest modifyRequest = Requests.newModifyRequest("uid=test.user," + baseDN)
+        .addModification(DELETE, "objectClass", "uidObject", "domain", "userSecurityInformation")
+        .addControl(newControl(OID_PERMISSIVE_MODIFY_CONTROL));
+    ModifyOperation modifyOperation = getRootConnection().processModify(modifyRequest);
+    assertEquals(modifyOperation.getResultCode(), ResultCode.SUCCESS);
+    retrieveSuccessfulOperationElements(modifyOperation);
+
+    assertObjectClassesAre(baseDN, "top", "person", "organizationalPerson", "inetOrgPerson");
+  }
+
+  /**
    * Tests the ability to perform a modification that adds an auxiliary
    * objectclass.
    *
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/types/TestEntry.java b/opendj-server-legacy/src/test/java/org/opends/server/types/TestEntry.java
index d3a99a5..b025a9b 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/types/TestEntry.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/types/TestEntry.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2006-2008 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.types;
 
@@ -26,6 +27,7 @@
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashSet;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Set;
 
@@ -108,6 +110,67 @@
     TestCaseUtils.startServer();
   }
 
+  /** Returns an entry to delete object class values from. */
+  private Entry newTestUserEntry() throws Exception
+  {
+    return TestCaseUtils.makeEntry(
+        "dn: cn=Test User,ou=People,dc=example,dc=com",
+        "objectClass: top",
+        "objectClass: person",
+        "objectClass: organizationalPerson",
+        "objectClass: inetOrgPerson",
+        "cn: Test User",
+        "sn: User");
+  }
+
+  /**
+   * A delete of several object class values must remove every one of them, the way a delete of
+   * several values of any other attribute does.
+   */
+  @Test
+  public void testRemoveSeveralObjectClassValues() throws Exception
+  {
+    Entry e = newTestUserEntry();
+
+    List<ByteString> missingValues = new LinkedList<>();
+    assertTrue(e.removeAttribute(
+        Attributes.create("objectClass", "organizationalPerson", "inetOrgPerson"), missingValues));
+
+    assertThat(missingValues).isEmpty();
+    assertThat(e.getObjectClasses().values()).containsOnly("top", "person");
+  }
+
+  /**
+   * A delete of an object class value which the entry does not have must be reported as a missing
+   * value, whatever the other values of the same modification are.
+   */
+  @Test
+  public void testRemoveObjectClassValuesOneOfWhichIsMissing() throws Exception
+  {
+    Entry e = newTestUserEntry();
+
+    List<ByteString> missingValues = new LinkedList<>();
+    assertTrue(e.removeAttribute(
+        Attributes.create("objectClass", "inetOrgPerson", "domain"), missingValues));
+
+    assertThat(missingValues).containsOnly(ByteString.valueOfUtf8("domain"));
+    assertThat(e.getObjectClasses().values()).containsOnly("top", "person", "organizationalPerson");
+  }
+
+  /** The same, with the missing value first: the walk must not stop at it. */
+  @Test
+  public void testRemoveObjectClassValuesTheFirstOfWhichIsMissing() throws Exception
+  {
+    Entry e = newTestUserEntry();
+
+    List<ByteString> missingValues = new LinkedList<>();
+    assertTrue(e.removeAttribute(
+        Attributes.create("objectClass", "domain", "inetOrgPerson"), missingValues));
+
+    assertThat(missingValues).containsOnly(ByteString.valueOfUtf8("domain"));
+    assertThat(e.getObjectClasses().values()).containsOnly("top", "person", "organizationalPerson");
+  }
+
   /**
    * Test the {@link Entry#parseAttribute(String)} method.
    */

--
Gitblit v1.10.0