This is an automated email from the ASF dual-hosted git repository. ilgrosso pushed a commit to branch 4_1_X in repository https://gitbox.apache.org/repos/asf/syncope.git
commit 8f254d584b0f7f89127ba8984ed6b3a584c6b40c Author: Oleg Zimakov <[email protected]> AuthorDate: Tue Apr 21 01:08:50 2026 -0700 SYNCOPE-1744: restore notification template context after user delete (#1352) --- .../notification/DefaultNotificationManager.java | 37 ++- .../DefaultNotificationManagerTest.java | 287 +++++++++++++++++++++ 2 files changed, 312 insertions(+), 12 deletions(-) diff --git a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/notification/DefaultNotificationManager.java b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/notification/DefaultNotificationManager.java index 067ea8ebce..d0e5e788d0 100644 --- a/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/notification/DefaultNotificationManager.java +++ b/core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/notification/DefaultNotificationManager.java @@ -349,18 +349,31 @@ public class DefaultNotificationManager implements NotificationManager { jexlVars.put("output", output); jexlVars.put("input", input); - any.ifPresent(a -> { - switch (a) { - case User user -> - jexlVars.put("user", userDataBinder.getUserTO(user, true)); - case Group group -> - jexlVars.put("group", groupDataBinder.getGroupTO(group, true)); - case AnyObject anyObject -> - jexlVars.put("anyObject", anyObjectDataBinder.getAnyObjectTO(anyObject, true)); - default -> { - } - } - }); + any.ifPresentOrElse( + a -> { + switch (a) { + case User user -> + jexlVars.put("user", userDataBinder.getUserTO(user, true)); + case Group group -> + jexlVars.put("group", groupDataBinder.getGroupTO(group, true)); + case AnyObject anyObject -> + jexlVars.put("anyObject", anyObjectDataBinder.getAnyObjectTO(anyObject, true)); + default -> { + } + } + }, + () -> { + switch (before) { + case UserTO userTO -> + jexlVars.put("user", userTO); + case GroupTO groupTO -> + jexlVars.put("group", groupTO); + case AnyObjectTO anyObjectTO -> + jexlVars.put("anyObject", anyObjectTO); + case null, default -> { + } + } + }); NotificationTask notificationTask = getNotificationTask(notification, any.orElse(null), jexlVars); notificationTask = taskDAO.save(notificationTask); diff --git a/core/provisioning-java/src/test/java/org/apache/syncope/core/provisioning/java/notification/DefaultNotificationManagerTest.java b/core/provisioning-java/src/test/java/org/apache/syncope/core/provisioning/java/notification/DefaultNotificationManagerTest.java new file mode 100644 index 0000000000..8efdedc5eb --- /dev/null +++ b/core/provisioning-java/src/test/java/org/apache/syncope/core/provisioning/java/notification/DefaultNotificationManagerTest.java @@ -0,0 +1,287 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.syncope.core.provisioning.java.notification; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.commons.jexl3.JexlBuilder; +import org.apache.commons.jexl3.JexlEngine; +import org.apache.commons.jexl3.MapContext; +import org.apache.commons.jexl3.introspection.JexlPermissions; +import org.apache.syncope.common.keymaster.client.api.ConfParamOps; +import org.apache.syncope.common.lib.Attr; +import org.apache.syncope.common.lib.SyncopeConstants; +import org.apache.syncope.common.lib.to.UserTO; +import org.apache.syncope.common.lib.types.OpEvent; +import org.apache.syncope.common.lib.types.TraceLevel; +import org.apache.syncope.core.persistence.api.dao.AnyMatchDAO; +import org.apache.syncope.core.persistence.api.dao.AnyObjectDAO; +import org.apache.syncope.core.persistence.api.dao.AnySearchDAO; +import org.apache.syncope.core.persistence.api.dao.DerSchemaDAO; +import org.apache.syncope.core.persistence.api.dao.GroupDAO; +import org.apache.syncope.core.persistence.api.dao.NotificationDAO; +import org.apache.syncope.core.persistence.api.dao.RelationshipTypeDAO; +import org.apache.syncope.core.persistence.api.dao.TaskDAO; +import org.apache.syncope.core.persistence.api.dao.UserDAO; +import org.apache.syncope.core.persistence.api.entity.EntityFactory; +import org.apache.syncope.core.persistence.api.entity.MailTemplate; +import org.apache.syncope.core.persistence.api.entity.Notification; +import org.apache.syncope.core.persistence.api.entity.task.NotificationTask; +import org.apache.syncope.core.persistence.api.search.AnySearchCondVisitor; +import org.apache.syncope.core.provisioning.api.DerAttrHandler; +import org.apache.syncope.core.provisioning.api.IntAttrNameParser; +import org.apache.syncope.core.provisioning.api.data.AnyObjectDataBinder; +import org.apache.syncope.core.provisioning.api.data.GroupDataBinder; +import org.apache.syncope.core.provisioning.api.data.UserDataBinder; +import org.apache.syncope.core.provisioning.api.jexl.EmptyClassLoader; +import org.apache.syncope.core.provisioning.api.jexl.JexlTools; +import org.apache.syncope.core.provisioning.api.jexl.SyncopeJexlFunctions; +import org.apache.syncope.core.spring.security.AuthContextUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class DefaultNotificationManagerTest { + + private static final String DELETE_SUCCESS = OpEvent.toString( + OpEvent.CategoryType.LOGIC, "UserLogic", null, "delete", OpEvent.Outcome.SUCCESS); + + @Mock + private DerSchemaDAO derSchemaDAO; + + @Mock + private NotificationDAO notificationDAO; + + @Mock + private AnyObjectDAO anyObjectDAO; + + @Mock + private UserDAO userDAO; + + @Mock + private GroupDAO groupDAO; + + @Mock + private AnySearchDAO anySearchDAO; + + @Mock + private AnyMatchDAO anyMatchDAO; + + @Mock + private TaskDAO taskDAO; + + @Mock + private RelationshipTypeDAO relationshipTypeDAO; + + @Mock + private DerAttrHandler derAttrHandler; + + @Mock + private UserDataBinder userDataBinder; + + @Mock + private GroupDataBinder groupDataBinder; + + @Mock + private AnyObjectDataBinder anyObjectDataBinder; + + @Mock + private ConfParamOps confParamOps; + + @Mock + private EntityFactory entityFactory; + + @Mock + private IntAttrNameParser intAttrNameParser; + + @Mock + private AnySearchCondVisitor searchCondVisitor; + + private JexlTools jexlTools; + + private DefaultNotificationManager manager; + + @BeforeEach + void init() { + JexlEngine jexlEngine = new JexlBuilder(). + loader(new EmptyClassLoader()). + permissions(JexlPermissions.RESTRICTED.compose("java.time.*", "org.apache.syncope.*")). + namespaces(Map.of("syncope", new SyncopeJexlFunctions())). + cache(512). + silent(false). + strict(false). + create(); + jexlTools = new JexlTools(jexlEngine); + manager = new DefaultNotificationManager( + derSchemaDAO, + notificationDAO, + anyObjectDAO, + userDAO, + groupDAO, + anySearchDAO, + anyMatchDAO, + taskDAO, + relationshipTypeDAO, + derAttrHandler, + userDataBinder, + groupDataBinder, + anyObjectDataBinder, + confParamOps, + entityFactory, + intAttrNameParser, + searchCondVisitor, + jexlTools); + } + + @Test + void jxltResolvesWhoAndUserInMapContext() { + Map<String, Object> ctx = new HashMap<>(); + ctx.put("who", "admin"); + UserTO user = new UserTO(); + user.setUsername("deleted-user"); + ctx.put("user", user); + String out = jexlTools.evaluateTemplate("${who} / ${user.username}", new MapContext(ctx)); + assertFalse(out.contains("${"), out); + assertEquals("admin / deleted-user", out); + } + + /** + * After user deletion the entity is no longer loadable, but {@code before} still holds the + * {@link UserTO} captured by {@code LogicInvocationHandler}. Notification templates must resolve + * against that snapshot (SYNCOPE-1744). + */ + @Test + void deleteSuccessUsesBeforeUserWhenEntityRemoved() { + UserTO beforeDelete = new UserTO(); + beforeDelete.setKey("c3b7107b-8886-4b1d-b0e3-2d6bfa6b1f9d"); + beforeDelete.setUsername("deleted-user"); + beforeDelete.getPlainAttrs().add(new Attr.Builder("u_email").value("[email protected]").build()); + + when(userDAO.findById(beforeDelete.getKey())).thenReturn(Optional.empty()); + + Notification notification = mock(Notification.class); + doReturn(Collections.singletonList(notification)).when(notificationDAO).findAll(); + when(notification.isActive()).thenReturn(true); + when(notification.getEvents()).thenReturn(List.of(DELETE_SUCCESS)); + when(notification.getRecipientsFIQL()).thenReturn(null); + when(notification.getStaticRecipients()).thenReturn(null); + when(notification.getRecipientsProvider()).thenReturn(null); + when(notification.getRecipientAttrName()).thenReturn("email"); + when(notification.getTraceLevel()).thenReturn(TraceLevel.NONE); + when(notification.getSender()).thenReturn("[email protected]"); + when(notification.getSubject()).thenReturn("User deleted"); + + MailTemplate mailTemplate = mock(MailTemplate.class); + when(mailTemplate.getTextTemplate()).thenReturn("${user.getPlainAttr(\"u_email\").get().values[0]}"); + when(mailTemplate.getHTMLTemplate()).thenReturn(null); + when(notification.getTemplate()).thenReturn(mailTemplate); + + when(confParamOps.list(anyString())).thenReturn(Map.of()); + + NotificationTask task = mock(NotificationTask.class); + when(entityFactory.newEntity(NotificationTask.class)).thenReturn(task); + when(taskDAO.save(any(NotificationTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + try (var auth = mockStatic(AuthContextUtils.class)) { + auth.when(AuthContextUtils::getDomain).thenReturn(SyncopeConstants.MASTER_DOMAIN); + + manager.createTasks( + "admin", + OpEvent.CategoryType.LOGIC, + "UserLogic", + null, + "delete", + OpEvent.Outcome.SUCCESS, + beforeDelete, + null); + } + + ArgumentCaptor<String> textBody = ArgumentCaptor.forClass(String.class); + verify(task).setTextBody(textBody.capture()); + assertEquals("[email protected]", textBody.getValue()); + } + + /** + * When {@code before} is {@code null} and the entity is not found, the empty branch of + * {@code ifPresentOrElse} must not throw {@link NullPointerException} (SYNCOPE-1744). + */ + @Test + void nullBeforeWithMissingEntityDoesNotThrow() { + Notification notification = mock(Notification.class); + doReturn(Collections.singletonList(notification)).when(notificationDAO).findAll(); + when(notification.isActive()).thenReturn(true); + when(notification.getEvents()).thenReturn(List.of(DELETE_SUCCESS)); + when(notification.getRecipientsFIQL()).thenReturn(null); + when(notification.getStaticRecipients()).thenReturn(null); + when(notification.getRecipientsProvider()).thenReturn(null); + when(notification.getRecipientAttrName()).thenReturn("email"); + when(notification.getTraceLevel()).thenReturn(TraceLevel.NONE); + when(notification.getSender()).thenReturn("[email protected]"); + when(notification.getSubject()).thenReturn("User deleted"); + + MailTemplate mailTemplate = mock(MailTemplate.class); + when(mailTemplate.getTextTemplate()).thenReturn("${who}"); + when(mailTemplate.getHTMLTemplate()).thenReturn(null); + when(notification.getTemplate()).thenReturn(mailTemplate); + + when(confParamOps.list(anyString())).thenReturn(Map.of()); + + NotificationTask task = mock(NotificationTask.class); + when(entityFactory.newEntity(NotificationTask.class)).thenReturn(task); + when(taskDAO.save(any(NotificationTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + try (var auth = mockStatic(AuthContextUtils.class)) { + auth.when(AuthContextUtils::getDomain).thenReturn(SyncopeConstants.MASTER_DOMAIN); + + manager.createTasks( + "admin", + OpEvent.CategoryType.LOGIC, + "UserLogic", + null, + "delete", + OpEvent.Outcome.SUCCESS, + null, // before is null + null); + } + + ArgumentCaptor<String> textBody = ArgumentCaptor.forClass(String.class); + verify(task).setTextBody(textBody.capture()); + assertEquals("admin", textBody.getValue()); + } +}
