Skip to content

Commit 8f254d5

Browse files
ozimakovilgrosso
authored andcommitted
SYNCOPE-1744: restore notification template context after user delete (#1352)
1 parent a2c61c5 commit 8f254d5

File tree

2 files changed

+312
-12
lines changed

2 files changed

+312
-12
lines changed

core/provisioning-java/src/main/java/org/apache/syncope/core/provisioning/java/notification/DefaultNotificationManager.java

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -349,18 +349,31 @@ public List<NotificationTask> createTasks(
349349
jexlVars.put("output", output);
350350
jexlVars.put("input", input);
351351

352-
any.ifPresent(a -> {
353-
switch (a) {
354-
case User user ->
355-
jexlVars.put("user", userDataBinder.getUserTO(user, true));
356-
case Group group ->
357-
jexlVars.put("group", groupDataBinder.getGroupTO(group, true));
358-
case AnyObject anyObject ->
359-
jexlVars.put("anyObject", anyObjectDataBinder.getAnyObjectTO(anyObject, true));
360-
default -> {
361-
}
362-
}
363-
});
352+
any.ifPresentOrElse(
353+
a -> {
354+
switch (a) {
355+
case User user ->
356+
jexlVars.put("user", userDataBinder.getUserTO(user, true));
357+
case Group group ->
358+
jexlVars.put("group", groupDataBinder.getGroupTO(group, true));
359+
case AnyObject anyObject ->
360+
jexlVars.put("anyObject", anyObjectDataBinder.getAnyObjectTO(anyObject, true));
361+
default -> {
362+
}
363+
}
364+
},
365+
() -> {
366+
switch (before) {
367+
case UserTO userTO ->
368+
jexlVars.put("user", userTO);
369+
case GroupTO groupTO ->
370+
jexlVars.put("group", groupTO);
371+
case AnyObjectTO anyObjectTO ->
372+
jexlVars.put("anyObject", anyObjectTO);
373+
case null, default -> {
374+
}
375+
}
376+
});
364377

365378
NotificationTask notificationTask = getNotificationTask(notification, any.orElse(null), jexlVars);
366379
notificationTask = taskDAO.save(notificationTask);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.syncope.core.provisioning.java.notification;
20+
21+
import static org.junit.jupiter.api.Assertions.assertEquals;
22+
import static org.junit.jupiter.api.Assertions.assertFalse;
23+
import static org.mockito.ArgumentMatchers.any;
24+
import static org.mockito.ArgumentMatchers.anyString;
25+
import static org.mockito.Mockito.doReturn;
26+
import static org.mockito.Mockito.mock;
27+
import static org.mockito.Mockito.mockStatic;
28+
import static org.mockito.Mockito.verify;
29+
import static org.mockito.Mockito.when;
30+
31+
import java.util.Collections;
32+
import java.util.HashMap;
33+
import java.util.List;
34+
import java.util.Map;
35+
import java.util.Optional;
36+
import org.apache.commons.jexl3.JexlBuilder;
37+
import org.apache.commons.jexl3.JexlEngine;
38+
import org.apache.commons.jexl3.MapContext;
39+
import org.apache.commons.jexl3.introspection.JexlPermissions;
40+
import org.apache.syncope.common.keymaster.client.api.ConfParamOps;
41+
import org.apache.syncope.common.lib.Attr;
42+
import org.apache.syncope.common.lib.SyncopeConstants;
43+
import org.apache.syncope.common.lib.to.UserTO;
44+
import org.apache.syncope.common.lib.types.OpEvent;
45+
import org.apache.syncope.common.lib.types.TraceLevel;
46+
import org.apache.syncope.core.persistence.api.dao.AnyMatchDAO;
47+
import org.apache.syncope.core.persistence.api.dao.AnyObjectDAO;
48+
import org.apache.syncope.core.persistence.api.dao.AnySearchDAO;
49+
import org.apache.syncope.core.persistence.api.dao.DerSchemaDAO;
50+
import org.apache.syncope.core.persistence.api.dao.GroupDAO;
51+
import org.apache.syncope.core.persistence.api.dao.NotificationDAO;
52+
import org.apache.syncope.core.persistence.api.dao.RelationshipTypeDAO;
53+
import org.apache.syncope.core.persistence.api.dao.TaskDAO;
54+
import org.apache.syncope.core.persistence.api.dao.UserDAO;
55+
import org.apache.syncope.core.persistence.api.entity.EntityFactory;
56+
import org.apache.syncope.core.persistence.api.entity.MailTemplate;
57+
import org.apache.syncope.core.persistence.api.entity.Notification;
58+
import org.apache.syncope.core.persistence.api.entity.task.NotificationTask;
59+
import org.apache.syncope.core.persistence.api.search.AnySearchCondVisitor;
60+
import org.apache.syncope.core.provisioning.api.DerAttrHandler;
61+
import org.apache.syncope.core.provisioning.api.IntAttrNameParser;
62+
import org.apache.syncope.core.provisioning.api.data.AnyObjectDataBinder;
63+
import org.apache.syncope.core.provisioning.api.data.GroupDataBinder;
64+
import org.apache.syncope.core.provisioning.api.data.UserDataBinder;
65+
import org.apache.syncope.core.provisioning.api.jexl.EmptyClassLoader;
66+
import org.apache.syncope.core.provisioning.api.jexl.JexlTools;
67+
import org.apache.syncope.core.provisioning.api.jexl.SyncopeJexlFunctions;
68+
import org.apache.syncope.core.spring.security.AuthContextUtils;
69+
import org.junit.jupiter.api.BeforeEach;
70+
import org.junit.jupiter.api.Test;
71+
import org.junit.jupiter.api.extension.ExtendWith;
72+
import org.mockito.ArgumentCaptor;
73+
import org.mockito.Mock;
74+
import org.mockito.junit.jupiter.MockitoExtension;
75+
import org.mockito.junit.jupiter.MockitoSettings;
76+
import org.mockito.quality.Strictness;
77+
78+
@ExtendWith(MockitoExtension.class)
79+
@MockitoSettings(strictness = Strictness.LENIENT)
80+
public class DefaultNotificationManagerTest {
81+
82+
private static final String DELETE_SUCCESS = OpEvent.toString(
83+
OpEvent.CategoryType.LOGIC, "UserLogic", null, "delete", OpEvent.Outcome.SUCCESS);
84+
85+
@Mock
86+
private DerSchemaDAO derSchemaDAO;
87+
88+
@Mock
89+
private NotificationDAO notificationDAO;
90+
91+
@Mock
92+
private AnyObjectDAO anyObjectDAO;
93+
94+
@Mock
95+
private UserDAO userDAO;
96+
97+
@Mock
98+
private GroupDAO groupDAO;
99+
100+
@Mock
101+
private AnySearchDAO anySearchDAO;
102+
103+
@Mock
104+
private AnyMatchDAO anyMatchDAO;
105+
106+
@Mock
107+
private TaskDAO taskDAO;
108+
109+
@Mock
110+
private RelationshipTypeDAO relationshipTypeDAO;
111+
112+
@Mock
113+
private DerAttrHandler derAttrHandler;
114+
115+
@Mock
116+
private UserDataBinder userDataBinder;
117+
118+
@Mock
119+
private GroupDataBinder groupDataBinder;
120+
121+
@Mock
122+
private AnyObjectDataBinder anyObjectDataBinder;
123+
124+
@Mock
125+
private ConfParamOps confParamOps;
126+
127+
@Mock
128+
private EntityFactory entityFactory;
129+
130+
@Mock
131+
private IntAttrNameParser intAttrNameParser;
132+
133+
@Mock
134+
private AnySearchCondVisitor searchCondVisitor;
135+
136+
private JexlTools jexlTools;
137+
138+
private DefaultNotificationManager manager;
139+
140+
@BeforeEach
141+
void init() {
142+
JexlEngine jexlEngine = new JexlBuilder().
143+
loader(new EmptyClassLoader()).
144+
permissions(JexlPermissions.RESTRICTED.compose("java.time.*", "org.apache.syncope.*")).
145+
namespaces(Map.of("syncope", new SyncopeJexlFunctions())).
146+
cache(512).
147+
silent(false).
148+
strict(false).
149+
create();
150+
jexlTools = new JexlTools(jexlEngine);
151+
manager = new DefaultNotificationManager(
152+
derSchemaDAO,
153+
notificationDAO,
154+
anyObjectDAO,
155+
userDAO,
156+
groupDAO,
157+
anySearchDAO,
158+
anyMatchDAO,
159+
taskDAO,
160+
relationshipTypeDAO,
161+
derAttrHandler,
162+
userDataBinder,
163+
groupDataBinder,
164+
anyObjectDataBinder,
165+
confParamOps,
166+
entityFactory,
167+
intAttrNameParser,
168+
searchCondVisitor,
169+
jexlTools);
170+
}
171+
172+
@Test
173+
void jxltResolvesWhoAndUserInMapContext() {
174+
Map<String, Object> ctx = new HashMap<>();
175+
ctx.put("who", "admin");
176+
UserTO user = new UserTO();
177+
user.setUsername("deleted-user");
178+
ctx.put("user", user);
179+
String out = jexlTools.evaluateTemplate("${who} / ${user.username}", new MapContext(ctx));
180+
assertFalse(out.contains("${"), out);
181+
assertEquals("admin / deleted-user", out);
182+
}
183+
184+
/**
185+
* After user deletion the entity is no longer loadable, but {@code before} still holds the
186+
* {@link UserTO} captured by {@code LogicInvocationHandler}. Notification templates must resolve
187+
* against that snapshot (SYNCOPE-1744).
188+
*/
189+
@Test
190+
void deleteSuccessUsesBeforeUserWhenEntityRemoved() {
191+
UserTO beforeDelete = new UserTO();
192+
beforeDelete.setKey("c3b7107b-8886-4b1d-b0e3-2d6bfa6b1f9d");
193+
beforeDelete.setUsername("deleted-user");
194+
beforeDelete.getPlainAttrs().add(new Attr.Builder("u_email").value("deleted-user@example.org").build());
195+
196+
when(userDAO.findById(beforeDelete.getKey())).thenReturn(Optional.empty());
197+
198+
Notification notification = mock(Notification.class);
199+
doReturn(Collections.singletonList(notification)).when(notificationDAO).findAll();
200+
when(notification.isActive()).thenReturn(true);
201+
when(notification.getEvents()).thenReturn(List.of(DELETE_SUCCESS));
202+
when(notification.getRecipientsFIQL()).thenReturn(null);
203+
when(notification.getStaticRecipients()).thenReturn(null);
204+
when(notification.getRecipientsProvider()).thenReturn(null);
205+
when(notification.getRecipientAttrName()).thenReturn("email");
206+
when(notification.getTraceLevel()).thenReturn(TraceLevel.NONE);
207+
when(notification.getSender()).thenReturn("noreply@syncope.org");
208+
when(notification.getSubject()).thenReturn("User deleted");
209+
210+
MailTemplate mailTemplate = mock(MailTemplate.class);
211+
when(mailTemplate.getTextTemplate()).thenReturn("${user.getPlainAttr(\"u_email\").get().values[0]}");
212+
when(mailTemplate.getHTMLTemplate()).thenReturn(null);
213+
when(notification.getTemplate()).thenReturn(mailTemplate);
214+
215+
when(confParamOps.list(anyString())).thenReturn(Map.of());
216+
217+
NotificationTask task = mock(NotificationTask.class);
218+
when(entityFactory.newEntity(NotificationTask.class)).thenReturn(task);
219+
when(taskDAO.save(any(NotificationTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
220+
221+
try (var auth = mockStatic(AuthContextUtils.class)) {
222+
auth.when(AuthContextUtils::getDomain).thenReturn(SyncopeConstants.MASTER_DOMAIN);
223+
224+
manager.createTasks(
225+
"admin",
226+
OpEvent.CategoryType.LOGIC,
227+
"UserLogic",
228+
null,
229+
"delete",
230+
OpEvent.Outcome.SUCCESS,
231+
beforeDelete,
232+
null);
233+
}
234+
235+
ArgumentCaptor<String> textBody = ArgumentCaptor.forClass(String.class);
236+
verify(task).setTextBody(textBody.capture());
237+
assertEquals("deleted-user@example.org", textBody.getValue());
238+
}
239+
240+
/**
241+
* When {@code before} is {@code null} and the entity is not found, the empty branch of
242+
* {@code ifPresentOrElse} must not throw {@link NullPointerException} (SYNCOPE-1744).
243+
*/
244+
@Test
245+
void nullBeforeWithMissingEntityDoesNotThrow() {
246+
Notification notification = mock(Notification.class);
247+
doReturn(Collections.singletonList(notification)).when(notificationDAO).findAll();
248+
when(notification.isActive()).thenReturn(true);
249+
when(notification.getEvents()).thenReturn(List.of(DELETE_SUCCESS));
250+
when(notification.getRecipientsFIQL()).thenReturn(null);
251+
when(notification.getStaticRecipients()).thenReturn(null);
252+
when(notification.getRecipientsProvider()).thenReturn(null);
253+
when(notification.getRecipientAttrName()).thenReturn("email");
254+
when(notification.getTraceLevel()).thenReturn(TraceLevel.NONE);
255+
when(notification.getSender()).thenReturn("noreply@syncope.org");
256+
when(notification.getSubject()).thenReturn("User deleted");
257+
258+
MailTemplate mailTemplate = mock(MailTemplate.class);
259+
when(mailTemplate.getTextTemplate()).thenReturn("${who}");
260+
when(mailTemplate.getHTMLTemplate()).thenReturn(null);
261+
when(notification.getTemplate()).thenReturn(mailTemplate);
262+
263+
when(confParamOps.list(anyString())).thenReturn(Map.of());
264+
265+
NotificationTask task = mock(NotificationTask.class);
266+
when(entityFactory.newEntity(NotificationTask.class)).thenReturn(task);
267+
when(taskDAO.save(any(NotificationTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
268+
269+
try (var auth = mockStatic(AuthContextUtils.class)) {
270+
auth.when(AuthContextUtils::getDomain).thenReturn(SyncopeConstants.MASTER_DOMAIN);
271+
272+
manager.createTasks(
273+
"admin",
274+
OpEvent.CategoryType.LOGIC,
275+
"UserLogic",
276+
null,
277+
"delete",
278+
OpEvent.Outcome.SUCCESS,
279+
null, // before is null
280+
null);
281+
}
282+
283+
ArgumentCaptor<String> textBody = ArgumentCaptor.forClass(String.class);
284+
verify(task).setTextBody(textBody.capture());
285+
assertEquals("admin", textBody.getValue());
286+
}
287+
}

0 commit comments

Comments
 (0)