From a65e18eaa10711bf67ffc0c7cdae87ae714764ae Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Wed, 19 Aug 2026 10:51:03 +0200 Subject: [PATCH 1/2] ESB-1229 Better exception management at startup when authorizations are imported from keycloak --- .github/configure | 2 + .github/workflows/build.yml | 3 + .../common/dao/DuplicateKeyDetector.java | 49 ++++ .../authorization/AuthorizationDAO.java | 20 +- .../group/DuplicateGroupException.java | 25 ++ .../aps/system/services/group/GroupDAO.java | 8 +- .../system/services/group/GroupManager.java | 3 + .../services/role/DuplicateRoleException.java | 25 ++ .../aps/system/services/role/RoleDAO.java | 6 + .../aps/system/services/role/RoleManager.java | 3 + .../common/dao/DuplicateKeyDetectorTest.java | 91 +++++++ .../services/group/TestGroupManager.java | 17 ++ .../system/services/role/TestRoleManager.java | 17 ++ .../KeycloakAuthorizationManager.java | 80 ++++--- .../KeycloakAuthorizationManagerTest.java | 223 ++++++++++++++++++ 15 files changed, 525 insertions(+), 47 deletions(-) create mode 100644 engine/src/main/java/com/agiletec/aps/system/common/dao/DuplicateKeyDetector.java create mode 100644 engine/src/main/java/com/agiletec/aps/system/services/group/DuplicateGroupException.java create mode 100644 engine/src/main/java/com/agiletec/aps/system/services/role/DuplicateRoleException.java create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/dao/DuplicateKeyDetectorTest.java diff --git a/.github/configure b/.github/configure index 1d1ea2344d..6d7589efe5 100755 --- a/.github/configure +++ b/.github/configure @@ -14,7 +14,9 @@ configure.start "$1" if [[ "$GITHUB_REF" == refs/tags/* ]]; then . .github/github-tools/base.configure --tag "$GITHUB_REF" else + set -x . .github/github-tools/base.configure "$GH_PR_TITLE" "$GH_PR_NUMBER" + set +x fi configure.complete diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3600bcce92..093460710d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,9 @@ jobs: runs-on: ubuntu-24.04 steps: + - name: Force DNS resolution for Nexus + run: echo "162.19.239.148 nexus-jx.apps.serv.run" | sudo tee -a /etc/hosts + - uses: actions/checkout@v5 - name: Set up JDK 17 diff --git a/engine/src/main/java/com/agiletec/aps/system/common/dao/DuplicateKeyDetector.java b/engine/src/main/java/com/agiletec/aps/system/common/dao/DuplicateKeyDetector.java new file mode 100644 index 0000000000..135b5fce4b --- /dev/null +++ b/engine/src/main/java/com/agiletec/aps/system/common/dao/DuplicateKeyDetector.java @@ -0,0 +1,49 @@ +/* + * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved. + * + * This library is free software; you can redistribute it and/or modify it under + * the terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This library 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 Lesser General Public License for more + * details. + */ +package com.agiletec.aps.system.common.dao; + +import java.sql.SQLException; + +public final class DuplicateKeyDetector { + + private DuplicateKeyDetector() { + // utility class + } + + public static boolean isDuplicateKey(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof SQLException && isDuplicateKey((SQLException) current)) { + return true; + } + current = current.getCause(); + } + return false; + } + + public static boolean isDuplicateKey(SQLException exception) { + if (exception == null) { + return false; + } + String sqlState = exception.getSQLState(); + int errorCode = exception.getErrorCode(); + + return "23505".equals(sqlState) + || errorCode == 1062 + || errorCode == 1586 + || errorCode == 1 + || errorCode == 2601 + || errorCode == 2627; + } +} diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java index bc60d8ebc9..a30df99849 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationDAO.java @@ -15,6 +15,7 @@ import com.agiletec.aps.system.common.AbstractSearcherDAO; import com.agiletec.aps.system.common.FieldSearchFilter; +import com.agiletec.aps.system.common.dao.DuplicateKeyDetector; import com.agiletec.aps.system.services.group.Group; import com.agiletec.aps.system.services.role.Role; @@ -340,24 +341,7 @@ private void updateIatAndAuthorizations(String username, Long iat, List list) { diff --git a/engine/src/main/java/com/agiletec/aps/system/services/group/DuplicateGroupException.java b/engine/src/main/java/com/agiletec/aps/system/services/group/DuplicateGroupException.java new file mode 100644 index 0000000000..5415708e0c --- /dev/null +++ b/engine/src/main/java/com/agiletec/aps/system/services/group/DuplicateGroupException.java @@ -0,0 +1,25 @@ +/* + * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved. + * + * This library is free software; you can redistribute it and/or modify it under + * the terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This library 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 Lesser General Public License for more + * details. + */ +package com.agiletec.aps.system.services.group; + +public class DuplicateGroupException extends RuntimeException { + + public DuplicateGroupException(String message) { + super(message); + } + + public DuplicateGroupException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/engine/src/main/java/com/agiletec/aps/system/services/group/GroupDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/group/GroupDAO.java index df4edafed9..fbcd0841eb 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/group/GroupDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/group/GroupDAO.java @@ -23,6 +23,7 @@ import com.agiletec.aps.system.common.AbstractSearcherDAO; import com.agiletec.aps.system.common.FieldSearchFilter; +import com.agiletec.aps.system.common.dao.DuplicateKeyDetector; import org.entando.entando.ent.util.EntLogging.EntLogger; import org.entando.entando.ent.util.EntLogging.EntLogFactory; @@ -94,7 +95,12 @@ public void addGroup(Group group) { conn.commit(); } catch (Throwable t) { this.executeRollback(conn); - logger.error("Error while adding a group", t); + if (DuplicateKeyDetector.isDuplicateKey(t)) { + logger.debug("Group '{}' already exists; treating duplicate key as a recoverable race", + group.getName()); + throw new DuplicateGroupException("Group already exists: " + group.getName(), t); + } + logger.error("Error while adding a group", t); throw new RuntimeException("Error while adding a group", t); } finally { closeDaoResources(null, stat, conn); diff --git a/engine/src/main/java/com/agiletec/aps/system/services/group/GroupManager.java b/engine/src/main/java/com/agiletec/aps/system/services/group/GroupManager.java index 565e763929..2827168feb 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/group/GroupManager.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/group/GroupManager.java @@ -91,6 +91,9 @@ public void addGroup(Group group) throws EntException { group.setDescr(LabelSanitizer.stripMarkup(group.getDescr())); this.getGroupDAO().addGroup(group); this.getCacheWrapper().addGroup(group); + } catch (DuplicateGroupException e) { + logger.debug("Group '{}' already present, skipping insert", group.getName()); + throw new EntException("Group already exists: " + group.getName(), e); } catch (Throwable t) { logger.error("Error detected while adding a group", t); throw new EntException("Error detected while adding a group", t); diff --git a/engine/src/main/java/com/agiletec/aps/system/services/role/DuplicateRoleException.java b/engine/src/main/java/com/agiletec/aps/system/services/role/DuplicateRoleException.java new file mode 100644 index 0000000000..81894e5b9c --- /dev/null +++ b/engine/src/main/java/com/agiletec/aps/system/services/role/DuplicateRoleException.java @@ -0,0 +1,25 @@ +/* + * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved. + * + * This library is free software; you can redistribute it and/or modify it under + * the terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This library 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 Lesser General Public License for more + * details. + */ +package com.agiletec.aps.system.services.role; + +public class DuplicateRoleException extends RuntimeException { + + public DuplicateRoleException(String message) { + super(message); + } + + public DuplicateRoleException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/engine/src/main/java/com/agiletec/aps/system/services/role/RoleDAO.java b/engine/src/main/java/com/agiletec/aps/system/services/role/RoleDAO.java index 63305ce7c3..1562fecbca 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/role/RoleDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/role/RoleDAO.java @@ -28,6 +28,7 @@ import org.entando.entando.ent.exception.EntException; import com.agiletec.aps.system.common.AbstractDAO; +import com.agiletec.aps.system.common.dao.DuplicateKeyDetector; /** * Data Access Object per gli oggetti ruolo (Role). @@ -164,6 +165,11 @@ public void addRole(Role role) { conn.commit(); } catch (Throwable t) { this.executeRollback(conn); + if (DuplicateKeyDetector.isDuplicateKey(t)) { + _logger.debug("Role '{}' already exists; treating duplicate key as a recoverable race", + role.getName()); + throw new DuplicateRoleException("Role already exists: " + role.getName(), t); + } _logger.error("Error while adding a role", t); throw new RuntimeException("Error while adding a role", t); } finally { diff --git a/engine/src/main/java/com/agiletec/aps/system/services/role/RoleManager.java b/engine/src/main/java/com/agiletec/aps/system/services/role/RoleManager.java index 8a3c0a592f..5463ded06b 100644 --- a/engine/src/main/java/com/agiletec/aps/system/services/role/RoleManager.java +++ b/engine/src/main/java/com/agiletec/aps/system/services/role/RoleManager.java @@ -133,6 +133,9 @@ public void addRole(Role role) throws EntException { try { this.getRoleDAO().addRole(role); this.getRoleCacheWrapper().addRole(role); + } catch (DuplicateRoleException e) { + logger.debug("Role '{}' already present, skipping insert", role.getName()); + throw new EntException("Role already exists: " + role.getName(), e); } catch (Throwable t) { logger.error("Error while adding a role", t); throw new EntException("Error while adding a role", t); diff --git a/engine/src/test/java/com/agiletec/aps/system/common/dao/DuplicateKeyDetectorTest.java b/engine/src/test/java/com/agiletec/aps/system/common/dao/DuplicateKeyDetectorTest.java new file mode 100644 index 0000000000..c5b595697e --- /dev/null +++ b/engine/src/test/java/com/agiletec/aps/system/common/dao/DuplicateKeyDetectorTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved. + * + * This library is free software; you can redistribute it and/or modify it under + * the terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This library 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 Lesser General Public License for more + * details. + */ +package com.agiletec.aps.system.common.dao; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +class DuplicateKeyDetectorTest { + + @Test + void shouldDetectPostgreSqlDuplicateKey() { + SQLException ex = new SQLException("duplicate key value violates unique constraint", "23505"); + assertTrue(DuplicateKeyDetector.isDuplicateKey(ex)); + } + + @Test + void shouldDetectDerbyDuplicateKey() { + SQLException ex = new SQLException("duplicate key value in unique index", "23505"); + assertTrue(DuplicateKeyDetector.isDuplicateKey(ex)); + } + + @Test + void shouldDetectMySqlDuplicateKey() { + SQLException ex = new SQLException("Duplicate entry", "23000", 1062); + assertTrue(DuplicateKeyDetector.isDuplicateKey(ex)); + } + + @Test + void shouldDetectMySqlDuplicateKeyWithKeyName() { + SQLException ex = new SQLException("Duplicate entry with key name", "23000", 1586); + assertTrue(DuplicateKeyDetector.isDuplicateKey(ex)); + } + + @Test + void shouldDetectOracleDuplicateKey() { + SQLException ex = new SQLException("ORA-00001: unique constraint violated", "23000", 1); + assertTrue(DuplicateKeyDetector.isDuplicateKey(ex)); + } + + @Test + void shouldDetectSqlServerDuplicateKey2627() { + SQLException ex = new SQLException("Violation of PRIMARY KEY constraint", "23000", 2627); + assertTrue(DuplicateKeyDetector.isDuplicateKey(ex)); + } + + @Test + void shouldDetectSqlServerDuplicateKey2601() { + SQLException ex = new SQLException("Cannot insert duplicate key row", "23000", 2601); + assertTrue(DuplicateKeyDetector.isDuplicateKey(ex)); + } + + @Test + void shouldNotTreatPostgreSqlForeignKeyViolationAsDuplicateKey() { + SQLException ex = new SQLException("foreign key violation", "23503"); + assertFalse(DuplicateKeyDetector.isDuplicateKey(ex)); + } + + @Test + void shouldNotTreatNotNullViolationAsDuplicateKey() { + SQLException ex = new SQLException("null value in column violates not-null constraint", "23502"); + assertFalse(DuplicateKeyDetector.isDuplicateKey(ex)); + } + + @Test + void shouldInspectNestedCauses() { + SQLException sql = new SQLException("duplicate key", "23505"); + RuntimeException wrapped = new RuntimeException("DAO failure", sql); + assertTrue(DuplicateKeyDetector.isDuplicateKey(wrapped)); + } + + @Test + void shouldReturnFalseForNullOrNonSqlException() { + assertFalse(DuplicateKeyDetector.isDuplicateKey((Throwable) null)); + assertFalse(DuplicateKeyDetector.isDuplicateKey((SQLException) null)); + assertFalse(DuplicateKeyDetector.isDuplicateKey(new RuntimeException("general error"))); + } +} diff --git a/engine/src/test/java/com/agiletec/aps/system/services/group/TestGroupManager.java b/engine/src/test/java/com/agiletec/aps/system/services/group/TestGroupManager.java index ba1c98888a..fbb136f9a3 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/group/TestGroupManager.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/group/TestGroupManager.java @@ -69,6 +69,23 @@ void testAddDeleteGroup() throws Throwable { } } + @Test + void testAddDuplicateGroupThrowsDuplicateGroupException() throws Throwable { + String groupCode = "grp_dup_test"; + Group group = new Group(); + group.setName(groupCode); + group.setDescription("descr_gruppo_duplicate"); + try { + groupManager.addGroup(group); + EntException ex = org.junit.jupiter.api.Assertions.assertThrows(EntException.class, () -> { + groupManager.addGroup(group); + }); + assertTrue(ex.getCause() instanceof DuplicateGroupException); + } finally { + groupManager.removeGroup(group); + } + } + @Test void testUpdateGroup() throws Throwable { int initSize = groupManager.getGroups().size(); diff --git a/engine/src/test/java/com/agiletec/aps/system/services/role/TestRoleManager.java b/engine/src/test/java/com/agiletec/aps/system/services/role/TestRoleManager.java index dc9aa1dfc5..6c39372a29 100644 --- a/engine/src/test/java/com/agiletec/aps/system/services/role/TestRoleManager.java +++ b/engine/src/test/java/com/agiletec/aps/system/services/role/TestRoleManager.java @@ -21,6 +21,7 @@ import com.agiletec.aps.BaseTestCase; import com.agiletec.aps.system.SystemConstants; +import org.entando.entando.ent.exception.EntException; import java.util.Iterator; import java.util.List; import java.util.Set; @@ -144,5 +145,21 @@ public void testGetRolesWithPemission() throws Throwable { assertEquals("supervisor", role.getName()); } } + + @Test + void testAddDuplicateRoleThrowsDuplicateRoleException() throws Throwable { + Role role = new Role(); + role.setName("temp_dup_role"); + role.setDescription("descr_dup_role"); + try { + roleManager.addRole(role); + EntException ex = org.junit.jupiter.api.Assertions.assertThrows(EntException.class, () -> { + roleManager.addRole(role); + }); + assertTrue(ex.getCause() instanceof DuplicateRoleException); + } finally { + roleManager.removeRole(role); + } + } } diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManager.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManager.java index 4fe19b29d6..b79dceaed0 100644 --- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManager.java +++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManager.java @@ -91,6 +91,19 @@ public KeycloakAuthorizationManager(final KeycloakConfiguration configuration, */ private final transient Map config = new ConcurrentHashMap<>(); + private final transient ConcurrentHashMap groupLocks = new ConcurrentHashMap<>(); + private final transient ConcurrentHashMap roleLocks = new ConcurrentHashMap<>(); + + private Object getGroupLock(String groupName) { + String key = this.getTenantCode() + ":" + groupName; + return groupLocks.computeIfAbsent(key, k -> new Object()); + } + + private Object getRoleLock(String roleName) { + String key = this.getTenantCode() + ":" + roleName; + return roleLocks.computeIfAbsent(key, k -> new Object()); + } + @Override public void init() throws Exception { initTenantAware(); @@ -467,43 +480,54 @@ private void assignGroupToUser(final String authorization, final UserDetails use } private Group findOrCreateGroup(String groupName) { + synchronized (getGroupLock(groupName)) { Group group = groupManager.getGroup(groupName); + if (group != null) { + return group; + } - if (group != null) { - return group; - } - - Group newGroup = new Group(); - newGroup.setName(groupName); - newGroup.setDescription(groupName); + Group newGroup = new Group(); + newGroup.setName(groupName); + newGroup.setDescription(groupName); - try { - groupManager.addGroup(newGroup); - return newGroup; - } catch (Exception e) { - log.debug("Error persisting group {} ( It might have been already added by another process).", - groupName); - return groupManager.getGroup(groupName); + try { + groupManager.addGroup(newGroup); + return newGroup; + } catch (Exception e) { + Group existing = groupManager.getGroup(groupName); + if (existing != null) { + log.debug("Group '{}' created concurrently by another process, reusing it", groupName); + return existing; + } + log.error("Unable to create or fetch group '{}'", groupName, e); + return null; + } } } private Role findOrCreateRole(final String roleName) { - Role newRole = roleManager.getRole(roleName); + synchronized (getRoleLock(roleName)) { + Role role = roleManager.getRole(roleName); + if (role != null) { + return role; + } - if (newRole != null) { - return newRole; - } + Role newRole = new Role(); + newRole.setName(roleName); + newRole.setDescription(roleName); - newRole = new Role(); - newRole.setName(roleName); - newRole.setDescription(roleName); - try { - roleManager.addRole(newRole); - return newRole; - } catch (Exception e) { - log.debug("Error persisting role {} (It might have been already added by another process).", - roleName); - return roleManager.getRole(roleName); + try { + roleManager.addRole(newRole); + return newRole; + } catch (Exception e) { + Role existing = roleManager.getRole(roleName); + if (existing != null) { + log.debug("Role '{}' created concurrently by another process, reusing it", roleName); + return existing; + } + log.error("Unable to create or fetch role '{}'", roleName, e); + return null; + } } } diff --git a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java index 0ee40a1187..d251e0f60f 100644 --- a/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java +++ b/keycloak-plugin/src/test/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManagerTest.java @@ -783,6 +783,229 @@ void testAuthAssignmentWhenRoleExistsAndAddRoleFailsWithPersistAuth() throws Exc assertThat(captured.get(0).getRole().getName()).isEqualTo("conflict_role"); } + @Test + void testAuthAssignmentWhenGroupExistsAndAddGroupFailsWithPersistAuth() throws Exception { + String xmlConf = "" + + " AUTH" + + " true" + + "" + + " " + + " true" + + " AD_GROUP" + + " GROUP" + + " " + + "" + + " " + + " conflict_group" + + " " + + ""; + + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(xmlConf); + + Group existingGroup = new Group(); + existingGroup.setName("conflict_group"); + + // First returns null (not cached), then after addGroup error returns the concurrently created group + when(groupManager.getGroup("conflict_group")) + .thenReturn(null) + .thenReturn(existingGroup); + + // Simulate a conflict on addGroup + org.mockito.Mockito.doThrow(new EntException("Conflict")) + .when(groupManager).addGroup(any(Group.class)); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_GROUP", List.of("conflict_group"))); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + + manager.init(); + manager.processNewUser(userDetails, null, false); + + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + List captured = listCaptor.getValue(); + assertThat(captured).hasSize(1); + assertThat(captured.get(0).getGroup().getName()).isEqualTo("conflict_group"); + } + + @Test + void testAuthAssignmentWhenGroupAddFailsAndStillNotFound() throws Exception { + String xmlConf = "" + + " AUTH" + + " true" + + "" + + " " + + " true" + + " AD_GROUP" + + " GROUP" + + " " + + "" + + " " + + " failing_group" + + " " + + ""; + + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(xmlConf); + + // Always returns null + when(groupManager.getGroup("failing_group")).thenReturn(null); + + // Simulate a fatal error on addGroup + org.mockito.Mockito.doThrow(new EntException("Database unavailable")) + .when(groupManager).addGroup(any(Group.class)); + + UserRepresentation userRepresentation = new UserRepresentation(); + userRepresentation.setAttributes(Map.of("AD_GROUP", List.of("failing_group"))); + when(userDetails.getUserRepresentation()).thenReturn(userRepresentation); + + when(userDetails.getAuthorizations()).thenReturn(new ArrayList<>()); + + manager.init(); + manager.processNewUser(userDetails, null, false); + + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(userDetails, times(1)).addAuthorizations(listCaptor.capture()); + List captured = listCaptor.getValue(); + assertThat(captured).hasSize(1); + assertThat(captured.get(0).getGroup()).isNull(); + } + + @Test + void testConcurrentGroupCreationInSingleJvmCallsAddGroupOnce() throws Exception { + String xmlConf = "" + + " AUTH" + + " true" + + "" + + " " + + " true" + + " AD_GROUP" + + " GROUP" + + " " + + "" + + " " + + " concurrent_group" + + " " + + ""; + + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(xmlConf); + + java.util.concurrent.atomic.AtomicReference createdGroup = new java.util.concurrent.atomic.AtomicReference<>(null); + org.mockito.Mockito.doAnswer(inv -> { + Group g = inv.getArgument(0); + Thread.sleep(50); + createdGroup.set(g); + return null; + }).when(groupManager).addGroup(any(Group.class)); + + org.mockito.Mockito.when(groupManager.getGroup("concurrent_group")).thenAnswer(inv -> createdGroup.get()); + + manager.init(); + + int threadCount = 10; + java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newFixedThreadPool(threadCount); + java.util.concurrent.CyclicBarrier barrier = new java.util.concurrent.CyclicBarrier(threadCount); + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + final int index = i; + executor.submit(() -> { + try { + barrier.await(); + KeycloakUser user = org.mockito.Mockito.mock(KeycloakUser.class); + when(user.getUsername()).thenReturn("user" + index); + UserRepresentation rep = new UserRepresentation(); + rep.setAttributes(Map.of("AD_GROUP", List.of("concurrent_group"))); + when(user.getUserRepresentation()).thenReturn(rep); + when(user.getAuthorizations()).thenReturn(new ArrayList<>()); + + manager.processNewUser(user, null, false); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + } + + assertThat(latch.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + executor.shutdown(); + + // Due to per-group synchronization in JVM, addGroup is executed exactly once + verify(groupManager, times(1)).addGroup(any(Group.class)); + } + + @Test + void testConcurrentRoleCreationInSingleJvmCallsAddRoleOnce() throws Exception { + String xmlConf = "" + + " AUTH" + + " true" + + "" + + " " + + " true" + + " AD_ROLE" + + " ROLE" + + " " + + "" + + " " + + " concurrent_role" + + " " + + ""; + + when(configuration.getDefaultAuthorizations()).thenReturn(null); + when(configManager.getConfigItem(anyString())).thenReturn(xmlConf); + + java.util.concurrent.atomic.AtomicReference createdRole = new java.util.concurrent.atomic.AtomicReference<>(null); + org.mockito.Mockito.doAnswer(inv -> { + Role r = inv.getArgument(0); + Thread.sleep(50); + createdRole.set(r); + return null; + }).when(roleManager).addRole(any(Role.class)); + + org.mockito.Mockito.when(roleManager.getRole("concurrent_role")).thenAnswer(inv -> createdRole.get()); + + manager.init(); + + int threadCount = 10; + java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newFixedThreadPool(threadCount); + java.util.concurrent.CyclicBarrier barrier = new java.util.concurrent.CyclicBarrier(threadCount); + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + final int index = i; + executor.submit(() -> { + try { + barrier.await(); + KeycloakUser user = org.mockito.Mockito.mock(KeycloakUser.class); + when(user.getUsername()).thenReturn("user" + index); + UserRepresentation rep = new UserRepresentation(); + rep.setAttributes(Map.of("AD_ROLE", List.of("concurrent_role"))); + when(user.getUserRepresentation()).thenReturn(rep); + when(user.getAuthorizations()).thenReturn(new ArrayList<>()); + + manager.processNewUser(user, null, false); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + } + + assertThat(latch.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + executor.shutdown(); + + // Due to per-role synchronization in JVM, addRole is executed exactly once + verify(roleManager, times(1)).addRole(any(Role.class)); + } + @Test From 8cb926a59c24f756256163f750c753f418a45620 Mon Sep 17 00:00:00 2001 From: Sergio Molino Date: Wed, 19 Aug 2026 18:22:47 +0200 Subject: [PATCH 2/2] Add DNS resolution step for Nexus in build.yml Added a step to force DNS resolution for Nexus in the build workflow. --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 093460710d..5e087da5fc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -153,6 +153,8 @@ jobs: needs: [build, test-and-scan] steps: + - name: Force DNS resolution for Nexus + run: echo "162.19.239.148 nexus-jx.apps.serv.run" | sudo tee -a /etc/hosts - uses: actions/checkout@v5 - name: Restore the build output