Skip to content

Commit 2fcb1a0

Browse files
ozimakovilgrosso
authored andcommitted
SYNCOPE-1744: restore notification template context after user delete (#1352)
1 parent 397e58b commit 2fcb1a0

2 files changed

Lines changed: 307 additions & 12 deletions

File tree

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
@@ -337,18 +337,31 @@ public List<NotificationTask> createTasks(
337337
jexlVars.put("output", output);
338338
jexlVars.put("input", input);
339339

340-
any.ifPresent(a -> {
341-
switch (a) {
342-
case User user ->
343-
jexlVars.put("user", userDataBinder.getUserTO(user, true));
344-
case Group group ->
345-
jexlVars.put("group", groupDataBinder.getGroupTO(group, true));
346-
case AnyObject anyObject ->
347-
jexlVars.put("anyObject", anyObjectDataBinder.getAnyObjectTO(anyObject, true));
348-
default -> {
349-
}
350-
}
351-
});
340+
any.ifPresentOrElse(
341+
a -> {
342+
switch (a) {
343+
case User user ->
344+
jexlVars.put("user", userDataBinder.getUserTO(user, true));
345+
case Group group ->
346+
jexlVars.put("group", groupDataBinder.getGroupTO(group, true));
347+
case AnyObject anyObject ->
348+
jexlVars.put("anyObject", anyObjectDataBinder.getAnyObjectTO(anyObject, true));
349+
default -> {
350+
}
351+
}
352+
},
353+
() -> {
354+
switch (before) {
355+
case UserTO userTO ->
356+
jexlVars.put("user", userTO);
357+
case GroupTO groupTO ->
358+
jexlVars.put("group", groupTO);
359+
case AnyObjectTO anyObjectTO ->
360+
jexlVars.put("anyObject", anyObjectTO);
361+
case null, default -> {
362+
}
363+
}
364+
});
352365

353366
NotificationTask notificationTask = getNotificationTask(notification, any.orElse(null), jexlVars);
354367
notificationTask = taskDAO.save(notificationTask);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
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.TaskDAO;
53+
import org.apache.syncope.core.persistence.api.dao.UserDAO;
54+
import org.apache.syncope.core.persistence.api.entity.EntityFactory;
55+
import org.apache.syncope.core.persistence.api.entity.MailTemplate;
56+
import org.apache.syncope.core.persistence.api.entity.Notification;
57+
import org.apache.syncope.core.persistence.api.entity.task.NotificationTask;
58+
import org.apache.syncope.core.persistence.api.search.AnySearchCondVisitor;
59+
import org.apache.syncope.core.provisioning.api.DerAttrHandler;
60+
import org.apache.syncope.core.provisioning.api.IntAttrNameParser;
61+
import org.apache.syncope.core.provisioning.api.data.AnyObjectDataBinder;
62+
import org.apache.syncope.core.provisioning.api.data.GroupDataBinder;
63+
import org.apache.syncope.core.provisioning.api.data.UserDataBinder;
64+
import org.apache.syncope.core.provisioning.api.jexl.EmptyClassLoader;
65+
import org.apache.syncope.core.provisioning.api.jexl.JexlTools;
66+
import org.apache.syncope.core.provisioning.api.jexl.SyncopeJexlFunctions;
67+
import org.apache.syncope.core.spring.security.AuthContextUtils;
68+
import org.junit.jupiter.api.BeforeEach;
69+
import org.junit.jupiter.api.Test;
70+
import org.junit.jupiter.api.extension.ExtendWith;
71+
import org.mockito.ArgumentCaptor;
72+
import org.mockito.Mock;
73+
import org.mockito.junit.jupiter.MockitoExtension;
74+
import org.mockito.junit.jupiter.MockitoSettings;
75+
import org.mockito.quality.Strictness;
76+
77+
@ExtendWith(MockitoExtension.class)
78+
@MockitoSettings(strictness = Strictness.LENIENT)
79+
public class DefaultNotificationManagerTest {
80+
81+
private static final String DELETE_SUCCESS = OpEvent.toString(
82+
OpEvent.CategoryType.LOGIC, "UserLogic", null, "delete", OpEvent.Outcome.SUCCESS);
83+
84+
@Mock
85+
private DerSchemaDAO derSchemaDAO;
86+
87+
@Mock
88+
private NotificationDAO notificationDAO;
89+
90+
@Mock
91+
private AnyObjectDAO anyObjectDAO;
92+
93+
@Mock
94+
private UserDAO userDAO;
95+
96+
@Mock
97+
private GroupDAO groupDAO;
98+
99+
@Mock
100+
private AnySearchDAO anySearchDAO;
101+
102+
@Mock
103+
private AnyMatchDAO anyMatchDAO;
104+
105+
@Mock
106+
private TaskDAO taskDAO;
107+
108+
@Mock
109+
private DerAttrHandler derAttrHandler;
110+
111+
@Mock
112+
private UserDataBinder userDataBinder;
113+
114+
@Mock
115+
private GroupDataBinder groupDataBinder;
116+
117+
@Mock
118+
private AnyObjectDataBinder anyObjectDataBinder;
119+
120+
@Mock
121+
private ConfParamOps confParamOps;
122+
123+
@Mock
124+
private EntityFactory entityFactory;
125+
126+
@Mock
127+
private IntAttrNameParser intAttrNameParser;
128+
129+
@Mock
130+
private AnySearchCondVisitor searchCondVisitor;
131+
132+
private JexlTools jexlTools;
133+
134+
private DefaultNotificationManager manager;
135+
136+
@BeforeEach
137+
void init() {
138+
JexlEngine jexlEngine = new JexlBuilder().
139+
loader(new EmptyClassLoader()).
140+
permissions(JexlPermissions.RESTRICTED.compose("java.time.*", "org.apache.syncope.*")).
141+
namespaces(Map.of("syncope", new SyncopeJexlFunctions())).
142+
cache(512).
143+
silent(false).
144+
strict(false).
145+
create();
146+
jexlTools = new JexlTools(jexlEngine);
147+
manager = new DefaultNotificationManager(
148+
derSchemaDAO,
149+
notificationDAO,
150+
anyObjectDAO,
151+
userDAO,
152+
groupDAO,
153+
anySearchDAO,
154+
anyMatchDAO,
155+
taskDAO,
156+
derAttrHandler,
157+
userDataBinder,
158+
groupDataBinder,
159+
anyObjectDataBinder,
160+
confParamOps,
161+
entityFactory,
162+
intAttrNameParser,
163+
searchCondVisitor,
164+
jexlTools);
165+
}
166+
167+
@Test
168+
void jxltResolvesWhoAndUserInMapContext() {
169+
Map<String, Object> ctx = new HashMap<>();
170+
ctx.put("who", "admin");
171+
UserTO user = new UserTO();
172+
user.setUsername("deleted-user");
173+
ctx.put("user", user);
174+
String out = jexlTools.evaluateTemplate("${who} / ${user.username}", new MapContext(ctx));
175+
assertFalse(out.contains("${"), out);
176+
assertEquals("admin / deleted-user", out);
177+
}
178+
179+
/**
180+
* After user deletion the entity is no longer loadable, but {@code before} still holds the
181+
* {@link UserTO} captured by {@code LogicInvocationHandler}. Notification templates must resolve
182+
* against that snapshot (SYNCOPE-1744).
183+
*/
184+
@Test
185+
void deleteSuccessUsesBeforeUserWhenEntityRemoved() {
186+
UserTO beforeDelete = new UserTO();
187+
beforeDelete.setKey("c3b7107b-8886-4b1d-b0e3-2d6bfa6b1f9d");
188+
beforeDelete.setUsername("deleted-user");
189+
beforeDelete.getPlainAttrs().add(new Attr.Builder("u_email").value("deleted-user@example.org").build());
190+
191+
when(userDAO.findById(beforeDelete.getKey())).thenReturn(Optional.empty());
192+
193+
Notification notification = mock(Notification.class);
194+
doReturn(Collections.singletonList(notification)).when(notificationDAO).findAll();
195+
when(notification.isActive()).thenReturn(true);
196+
when(notification.getEvents()).thenReturn(List.of(DELETE_SUCCESS));
197+
when(notification.getRecipientsFIQL()).thenReturn(null);
198+
when(notification.getStaticRecipients()).thenReturn(null);
199+
when(notification.getRecipientsProvider()).thenReturn(null);
200+
when(notification.getRecipientAttrName()).thenReturn("email");
201+
when(notification.getTraceLevel()).thenReturn(TraceLevel.NONE);
202+
when(notification.getSender()).thenReturn("noreply@syncope.org");
203+
when(notification.getSubject()).thenReturn("User deleted");
204+
205+
MailTemplate mailTemplate = mock(MailTemplate.class);
206+
when(mailTemplate.getTextTemplate()).thenReturn("${user.getPlainAttr(\"u_email\").get().values[0]}");
207+
when(mailTemplate.getHTMLTemplate()).thenReturn(null);
208+
when(notification.getTemplate()).thenReturn(mailTemplate);
209+
210+
when(confParamOps.list(anyString())).thenReturn(Map.of());
211+
212+
NotificationTask task = mock(NotificationTask.class);
213+
when(entityFactory.newEntity(NotificationTask.class)).thenReturn(task);
214+
when(taskDAO.save(any(NotificationTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
215+
216+
try (var auth = mockStatic(AuthContextUtils.class)) {
217+
auth.when(AuthContextUtils::getDomain).thenReturn(SyncopeConstants.MASTER_DOMAIN);
218+
219+
manager.createTasks(
220+
"admin",
221+
OpEvent.CategoryType.LOGIC,
222+
"UserLogic",
223+
null,
224+
"delete",
225+
OpEvent.Outcome.SUCCESS,
226+
beforeDelete,
227+
null);
228+
}
229+
230+
ArgumentCaptor<String> textBody = ArgumentCaptor.forClass(String.class);
231+
verify(task).setTextBody(textBody.capture());
232+
assertEquals("deleted-user@example.org", textBody.getValue());
233+
}
234+
235+
/**
236+
* When {@code before} is {@code null} and the entity is not found, the empty branch of
237+
* {@code ifPresentOrElse} must not throw {@link NullPointerException} (SYNCOPE-1744).
238+
*/
239+
@Test
240+
void nullBeforeWithMissingEntityDoesNotThrow() {
241+
Notification notification = mock(Notification.class);
242+
doReturn(Collections.singletonList(notification)).when(notificationDAO).findAll();
243+
when(notification.isActive()).thenReturn(true);
244+
when(notification.getEvents()).thenReturn(List.of(DELETE_SUCCESS));
245+
when(notification.getRecipientsFIQL()).thenReturn(null);
246+
when(notification.getStaticRecipients()).thenReturn(null);
247+
when(notification.getRecipientsProvider()).thenReturn(null);
248+
when(notification.getRecipientAttrName()).thenReturn("email");
249+
when(notification.getTraceLevel()).thenReturn(TraceLevel.NONE);
250+
when(notification.getSender()).thenReturn("noreply@syncope.org");
251+
when(notification.getSubject()).thenReturn("User deleted");
252+
253+
MailTemplate mailTemplate = mock(MailTemplate.class);
254+
when(mailTemplate.getTextTemplate()).thenReturn("${who}");
255+
when(mailTemplate.getHTMLTemplate()).thenReturn(null);
256+
when(notification.getTemplate()).thenReturn(mailTemplate);
257+
258+
when(confParamOps.list(anyString())).thenReturn(Map.of());
259+
260+
NotificationTask task = mock(NotificationTask.class);
261+
when(entityFactory.newEntity(NotificationTask.class)).thenReturn(task);
262+
when(taskDAO.save(any(NotificationTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
263+
264+
try (var auth = mockStatic(AuthContextUtils.class)) {
265+
auth.when(AuthContextUtils::getDomain).thenReturn(SyncopeConstants.MASTER_DOMAIN);
266+
267+
manager.createTasks(
268+
"admin",
269+
OpEvent.CategoryType.LOGIC,
270+
"UserLogic",
271+
null,
272+
"delete",
273+
OpEvent.Outcome.SUCCESS,
274+
null, // before is null
275+
null);
276+
}
277+
278+
ArgumentCaptor<String> textBody = ArgumentCaptor.forClass(String.class);
279+
verify(task).setTextBody(textBody.capture());
280+
assertEquals("admin", textBody.getValue());
281+
}
282+
}

0 commit comments

Comments
 (0)