Repository: AthenZ/athenz

Issue:
missing notifications to domain admins if the member's domain has no admins
Let's assume the following scenario:

Domain: athenz
  Role: readers 
  Members: sports.api (expiring tomorrow)

Domain: sports
  Role: admin
  Members: none

So we have a member sports.api that is expiring tomorrow so we need to notify both the service and the role domain admins. However, sports domain has no admins (the last admin has left the company without adding other admins).

When the expiry code is processing the expiry notifications, it goes through the list of role members that are supposed to get notifications and in this case would process sports.api service. Since the sports domain has no admin, it skips processing the member. However, this is incorrect since we still need to notify the admins of the athenz:role.readers role that the member is expiring. 

The correct behavior should be to process the sports.api member but only skip generating the actual notification so that the admins of the athenz domain do get their notifications

Discussion:

Solution patch:
diff --git a/servers/zms/src/main/java/com/yahoo/athenz/zms/notification/GroupMemberExpiryNotificationTask.java b/servers/zms/src/main/java/com/yahoo/athenz/zms/notification/GroupMemberExpiryNotificationTask.java
index 48f41146c..e6ddc14f5 100644
--- a/servers/zms/src/main/java/com/yahoo/athenz/zms/notification/GroupMemberExpiryNotificationTask.java
+++ b/servers/zms/src/main/java/com/yahoo/athenz/zms/notification/GroupMemberExpiryNotificationTask.java
@@ -37,13 +37,16 @@ import static com.yahoo.athenz.common.server.notification.impl.MetricNotificatio
 
 public class GroupMemberExpiryNotificationTask implements NotificationTask {
 
+    private static final Logger LOGGER = LoggerFactory.getLogger(GroupMemberExpiryNotificationTask.class);
+
+    private final static String DESCRIPTION = "group membership expiration reminders";
+    static final String NOTIFY_SKIP_PRINCIPAL = "_NOTIFY-SKIP-PRINCIPAL_";
+
     private final DBService dbService;
     private final String userDomainPrefix;
     private final NotificationCommon notificationCommon;
     private final DomainRoleMembersFetcher domainRoleMembersFetcher;
     private final DomainMetaFetcher domainMetaFetcher;
-    private static final Logger LOGGER = LoggerFactory.getLogger(GroupMemberExpiryNotificationTask.class);
-    private final static String DESCRIPTION = "group membership expiration reminders";
     private final GroupExpiryDomainNotificationToEmailConverter groupExpiryDomainNotificationToEmailConverter;
     private final GroupExpiryPrincipalNotificationToEmailConverter groupExpiryPrincipalNotificationToEmailConverter;
     private final GroupExpiryDomainNotificationToMetricConverter groupExpiryDomainNotificationToMetricConverter;
@@ -292,7 +295,12 @@ public class GroupMemberExpiryNotificationTask implements NotificationTask {
             // notification agent for processing
 
             Map<String, String> details = processGroupReminder(domainAdminMap, consolidatedMembers.get(principal));
-            if (!details.isEmpty()) {
+
+            // we'll generate the actual notification object if the details were
+            // provided and the principal is not our special principal that was
+            // only maintained in order to process notifications for the role admins
+
+            if (!details.isEmpty() && !NOTIFY_SKIP_PRINCIPAL.equals(principal)) {
                 Notification notification = notificationCommon.createNotification(
                         Notification.Type.GROUP_MEMBER_EXPIRY,
                         consolidatedBy,
@@ -380,9 +388,16 @@ public class GroupMemberExpiryNotificationTask implements NotificationTask {
                 // domain role fetcher only returns the human users
 
                 Set<String> domainAdminMembers = domainRoleMembersFetcher.getDomainRoleMembers(domainName, ADMIN_ROLE_NAME);
+
+                // if there are no domain admins for the given service principal, we still need
+                // to process this entry because we need to notify the role admins and as such
+                // we're going to assign this entry to a special principal that we'll skip when
+                // generating and processing actual notifications
+
                 if (ZMSUtils.isCollectionEmpty(domainAdminMembers)) {
-                    continue;
+                    domainAdminMembers.add(NOTIFY_SKIP_PRINCIPAL);
                 }
+
                 for (String domainAdminMember : domainAdminMembers) {
                     addGroupMembers(domainAdminMember, consolidatedMembers, members.get(principal).getMemberGroups());
                 }
diff --git a/servers/zms/src/main/java/com/yahoo/athenz/zms/notification/RoleMemberNotificationCommon.java b/servers/zms/src/main/java/com/yahoo/athenz/zms/notification/RoleMemberNotificationCommon.java
index 571af5f6f..7f999317e 100644
--- a/servers/zms/src/main/java/com/yahoo/athenz/zms/notification/RoleMemberNotificationCommon.java
+++ b/servers/zms/src/main/java/com/yahoo/athenz/zms/notification/RoleMemberNotificationCommon.java
@@ -38,6 +38,7 @@ import static com.yahoo.athenz.common.server.notification.NotificationServiceCon
 public class RoleMemberNotificationCommon {
 
     private static final Logger LOGGER = LoggerFactory.getLogger(RoleMemberNotificationCommon.class);
+    static final String NOTIFY_SKIP_PRINCIPAL = "_NOTIFY-SKIP-PRINCIPAL_";
 
     private final DBService dbService;
     private final String userDomainPrefix;
@@ -116,7 +117,12 @@ public class RoleMemberNotificationCommon {
 
             Map<String, String> details = processRoleReminder(domainAdminMap, consolidatedMembers.get(principal),
                     roleMemberDetailStringer, disableRoleMemberNotificationFilter);
-            if (!details.isEmpty()) {
+
+            // we'll generate the actual notification object if the details were
+            // provided and the principal is not our special principal that was
+            // only maintained in order to process notifications for the role admins
+
+            if (!details.isEmpty() && !NOTIFY_SKIP_PRINCIPAL.equals(principal)) {
                 Notification notification = notificationCommon.createNotification(
                         type, consolidatedBy, principal, details, principalNotificationToEmailConverter,
                         principalNotificationToMetricConverter, principalNotificationToSlackMessageConverter);
@@ -226,9 +232,16 @@ public class RoleMemberNotificationCommon {
                     // domain role fetcher only returns the human users
 
                     Set<String> domainAdminMembers = domainRoleMembersFetcher.getDomainRoleMembers(domainName, ADMIN_ROLE_NAME);
+
+                    // if there are no domain admins for the given service principal, we still need
+                    // to process this entry because we need to notify the role admins and as such
+                    // we're going to assign this entry to a special principal that we'll skip when
+                    // generating and processing actual notifications
+
                     if (ZMSUtils.isCollectionEmpty(domainAdminMembers)) {
-                        continue;
+                        domainAdminMembers.add(NOTIFY_SKIP_PRINCIPAL);
                     }
+
                     for (String domainAdminMember : domainAdminMembers) {
                         addRoleMembers(domainAdminMember, consolidatedMembers, members.get(principal).getMemberRoles());
                     }



Test patch:
diff --git a/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/jwts/JwtsSigningKeyResolverTest.java b/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/jwts/JwtsSigningKeyResolverTest.java
index 0c546c207..f2aaa5dc3 100644
--- a/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/jwts/JwtsSigningKeyResolverTest.java
+++ b/libs/java/auth_core/src/test/java/com/yahoo/athenz/auth/token/jwts/JwtsSigningKeyResolverTest.java
@@ -15,15 +15,18 @@
  */
 package com.yahoo.athenz.auth.token.jwts;
 
+import com.nimbusds.jose.KeySourceException;
+import com.nimbusds.jose.proc.SecurityContext;
 import com.yahoo.athenz.auth.util.CryptoException;
+import org.mockito.Mockito;
 import org.testng.annotations.Test;
 
 import java.io.File;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
+import java.util.*;
 
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
 import static org.testng.Assert.*;
 
 public class JwtsSigningKeyResolverTest {
@@ -321,4 +324,59 @@ public class JwtsSigningKeyResolverTest {
             assertTrue(ex.getMessage().contains("Invalid jwks uri: invalid-uri"));
         }
     }
+
+    @Test
+    public void testGetPublicKeyUnsupportedKeyType() {
+        // Test with a JWKS file that contains an unsupported key type (OctetSequenceKey)
+        final String jwksUri = Objects.requireNonNull(classLoader.getResource("jwt_jwks_unsupported_key.json")).toString();
+        JwtsSigningKeyResolver resolver = new JwtsSigningKeyResolver(jwksUri, null, null, true);
+        resolver.setMillisBetweenZtsCalls(1000);
+
+        // Should return null for unsupported key type
+        assertNull(resolver.getPublicKey("octkey1"));
+    }
+
+    @Test
+    public void testGetPublicKeyMalformedRSAKey() {
+        // Test with a JWKS file that contains a malformed RSA key (missing required components)
+        final String jwksUri = Objects.requireNonNull(classLoader.getResource("jwt_jwks_malformed_rsa.json")).toString();
+        JwtsSigningKeyResolver resolver = new JwtsSigningKeyResolver(jwksUri, null, null, true);
+        resolver.setMillisBetweenZtsCalls(1000);
+
+        // Should return null when key extraction fails
+        assertNull(resolver.getPublicKey("malformed-rsa"));
+    }
+
+    @Test
+    public void testGetPublicKeyMalformedECKey() {
+        // Test with a JWKS file that contains a malformed EC key (missing required components)
+        final String jwksUri = Objects.requireNonNull(classLoader.getResource("jwt_jwks_malformed_ec.json")).toString();
+        JwtsSigningKeyResolver resolver = new JwtsSigningKeyResolver(jwksUri, null, null, true);
+        resolver.setMillisBetweenZtsCalls(1000);
+
+        // Should return null when key extraction fails
+        assertNull(resolver.getPublicKey("malformed-ec"));
+    }
+
+    @Test
+    public void testGetPublicKeyKeySourceException() throws KeySourceException {
+        final String jwksUri = Objects.requireNonNull(classLoader.getResource("jwt_jwks.json")).toString();
+        JwtsSigningKeyResolver resolver = new JwtsSigningKeyResolver(jwksUri, null, null, true);
+
+        JwtsHelper.CompositeJWKSource<SecurityContext> keySource = Mockito.mock(JwtsHelper.CompositeJWKSource.class);
+        when(keySource.get(any(), any()))
+                .thenReturn(null)
+                .thenReturn(Collections.emptyList())
+                .thenThrow(new KeySourceException());
+        resolver.keySource = keySource;
+
+        // null object
+        assertNull(resolver.getPublicKey("eckey1"));
+
+        // empty list
+        assertNull(resolver.getPublicKey("eckey1"));
+
+        // key-source exception
+        assertNull(resolver.getPublicKey("eckey1"));
+    }
 }
diff --git a/libs/java/auth_core/src/test/resources/jwt_jwks_malformed_ec.json b/libs/java/auth_core/src/test/resources/jwt_jwks_malformed_ec.json
new file mode 100644
index 000000000..426964179
--- /dev/null
+++ b/libs/java/auth_core/src/test/resources/jwt_jwks_malformed_ec.json
@@ -0,0 +1,14 @@
+{
+    "keys": [
+        {
+            "kty": "EC",
+            "kid": "malformed-ec",
+            "alg": "ES256",
+            "use": "sig",
+            "crv": "P-256",
+            "x": "invalid-x-coordinate",
+            "y": "invalid-y-coordinate"
+        }
+    ]
+}
+
diff --git a/libs/java/auth_core/src/test/resources/jwt_jwks_malformed_rsa.json b/libs/java/auth_core/src/test/resources/jwt_jwks_malformed_rsa.json
new file mode 100644
index 000000000..11f418e6d
--- /dev/null
+++ b/libs/java/auth_core/src/test/resources/jwt_jwks_malformed_rsa.json
@@ -0,0 +1,13 @@
+{
+    "keys": [
+        {
+            "kty": "RSA",
+            "kid": "malformed-rsa",
+            "alg": "RS256",
+            "use": "sig",
+            "n": "invalid-modulus-that-cannot-be-decoded",
+            "e": "AQAB"
+        }
+    ]
+}
+
diff --git a/libs/java/auth_core/src/test/resources/jwt_jwks_unsupported_key.json b/libs/java/auth_core/src/test/resources/jwt_jwks_unsupported_key.json
new file mode 100644
index 000000000..ddcce8991
--- /dev/null
+++ b/libs/java/auth_core/src/test/resources/jwt_jwks_unsupported_key.json
@@ -0,0 +1,11 @@
+{
+    "keys": [
+        {
+            "kty": "oct",
+            "kid": "octkey1",
+            "alg": "HS256",
+            "k": "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow"
+        }
+    ]
+}
+
diff --git a/servers/zms/src/test/java/com/yahoo/athenz/zms/notification/GroupMemberExpiryNotificationTaskTest.java b/servers/zms/src/test/java/com/yahoo/athenz/zms/notification/GroupMemberExpiryNotificationTaskTest.java
index f838e3f9d..9b23a6e56 100644
--- a/servers/zms/src/test/java/com/yahoo/athenz/zms/notification/GroupMemberExpiryNotificationTaskTest.java
+++ b/servers/zms/src/test/java/com/yahoo/athenz/zms/notification/GroupMemberExpiryNotificationTaskTest.java
@@ -33,6 +33,7 @@ import java.util.stream.Collectors;
 import static com.yahoo.athenz.common.ServerCommonConsts.USER_DOMAIN_PREFIX;
 import static com.yahoo.athenz.common.server.notification.NotificationServiceConstants.*;
 import static com.yahoo.athenz.common.server.notification.impl.MetricNotificationService.*;
+import static com.yahoo.athenz.zms.notification.GroupMemberExpiryNotificationTask.NOTIFY_SKIP_PRINCIPAL;
 import static com.yahoo.athenz.zms.notification.ZMSNotificationManagerTest.getNotificationManager;
 import static org.mockito.ArgumentMatchers.any;
 import static org.testng.Assert.*;
@@ -745,8 +746,9 @@ public class GroupMemberExpiryNotificationTaskTest {
         members.put("weather.api", domainGroupMember);
 
         Map<String, DomainGroupMember> consolidatedMembers = task.consolidateGroupMembers(members);
-        assertEquals(consolidatedMembers.size(), 1);
+        assertEquals(consolidatedMembers.size(), 2);
         assertNotNull(consolidatedMembers.get("user.joe"));
+        assertNotNull(consolidatedMembers.get(NOTIFY_SKIP_PRINCIPAL));
     }
 
     @Test
@@ -1353,4 +1355,67 @@ public class GroupMemberExpiryNotificationTaskTest {
         Mockito.verify(notificationObjectStore, Mockito.times(1)).registerReviewObjects(Mockito.eq("user.joe"),
                 Mockito.anyList());
     }
+
+    @Test
+    public void testGetConsolidatedNotificationDetailsWithoutDomainAdmins() {
+
+        // generate our data set
+
+        Map<String, DomainGroupMember> members = new HashMap<>();
+        Timestamp currentTime = Timestamp.fromCurrentTime();
+
+        DomainGroupMember domainGroupMember = new DomainGroupMember().setMemberName("sports.api");
+        List<GroupMember> memberGroups = new ArrayList<>();
+        memberGroups.add(new GroupMember().setGroupName("deployment").setDomainName("athenz")
+                .setMemberName("sports.api").setExpiration(currentTime));
+        domainGroupMember.setMemberGroups(memberGroups);
+        members.put("sports.api", domainGroupMember);
+
+        DBService dbsvc = Mockito.mock(DBService.class);
+        Role roleSports = new Role().setName("sports:role.admin").setRoleMembers(Collections.emptyList());
+        Mockito.when(dbsvc.getRole("sports", "admin", Boolean.FALSE, Boolean.TRUE, Boolean.FALSE))
+                .thenReturn(roleSports);
+        Role roleAthenz = new Role().setName("athenz:role.admin");
+        roleAthenz.setRoleMembers(Arrays.asList(new RoleMember().setMemberName("user.joe")));
+        Mockito.when(dbsvc.getRole("athenz", "admin", Boolean.FALSE, Boolean.TRUE, Boolean.FALSE))
+                .thenReturn(roleAthenz);
+
+        GroupMemberExpiryNotificationTask task = new GroupMemberExpiryNotificationTask(
+                dbsvc, USER_DOMAIN_PREFIX, new NotificationConverterCommon(null));
+
+        UserAuthority userAuthority = Mockito.mock(UserAuthority.class);
+
+        NotificationConverterCommon notificationConverterCommon = new NotificationConverterCommon(userAuthority);
+
+        RoleMemberNotificationCommon.DisableRoleMemberNotificationFilter disableRoleMemberNotificationFilter =
+                Mockito.mock(RoleMemberNotificationCommon.DisableRoleMemberNotificationFilter.class);
+        Mockito.when(disableRoleMemberNotificationFilter.getDisabledNotificationState(any()))
+                .thenReturn(DisableNotificationEnum.getEnumSet(0));
+
+        List<Notification> notifications = task.getNotificationDetails(
+                members, Notification.ConsolidatedBy.PRINCIPAL,
+                new GroupMemberExpiryNotificationTask.GroupExpiryPrincipalNotificationToEmailConverter(notificationConverterCommon),
+                new GroupMemberExpiryNotificationTask.GroupExpiryDomainNotificationToEmailConverter(notificationConverterCommon),
+                new GroupMemberExpiryNotificationTask.GroupExpiryPrincipalNotificationToToMetricConverter(),
+                new GroupMemberExpiryNotificationTask.GroupExpiryDomainNotificationToMetricConverter(),
+                new GroupMemberExpiryNotificationTask.GroupExpiryPrincipalNotificationToSlackConverter(notificationConverterCommon),
+                new GroupMemberExpiryNotificationTask.GroupExpiryDomainNotificationToSlackConverter(notificationConverterCommon),
+                null);
+
+        // we're supposed to get 1 notification back - only for user.joe since
+        // sports has no admins so no notifications for the sports admins
+
+        assertEquals(notifications.size(), 1);
+
+        // get the notification for user.joe as the admin of the domains
+
+        Notification notification = getNotification(notifications, "user.joe", NOTIFICATION_DETAILS_MEMBERS_LIST);
+        assertNotNull(notification);
+
+        assertEquals(notification.getRecipients().size(), 1);
+        assertEquals(notification.getDetails().size(), 1);
+        assertEquals(notification.getDetails().get(NOTIFICATION_DETAILS_MEMBERS_LIST),
+                "athenz;deployment;sports.api;" + currentTime + ";"
+        );
+    }
 }
diff --git a/servers/zms/src/test/java/com/yahoo/athenz/zms/notification/RoleMemberNotificationCommonTest.java b/servers/zms/src/test/java/com/yahoo/athenz/zms/notification/RoleMemberNotificationCommonTest.java
index f6cbd90d6..ce450a71a 100644
--- a/servers/zms/src/test/java/com/yahoo/athenz/zms/notification/RoleMemberNotificationCommonTest.java
+++ b/servers/zms/src/test/java/com/yahoo/athenz/zms/notification/RoleMemberNotificationCommonTest.java
@@ -34,6 +34,7 @@ import java.util.stream.Collectors;
 import static com.yahoo.athenz.common.ServerCommonConsts.USER_DOMAIN_PREFIX;
 import static com.yahoo.athenz.common.server.notification.NotificationServiceConstants.*;
 import static com.yahoo.athenz.zms.ResourceException.NOT_FOUND;
+import static com.yahoo.athenz.zms.notification.RoleMemberNotificationCommon.NOTIFY_SKIP_PRINCIPAL;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.testng.Assert.*;
@@ -617,8 +618,9 @@ public class RoleMemberNotificationCommonTest {
         members.put("weather.api", domainRoleMember);
 
         Map<String, DomainRoleMember> consolidatedMembers = task.consolidateRoleMembers(members);
-        assertEquals(consolidatedMembers.size(), 1);
+        assertEquals(consolidatedMembers.size(), 2);
         assertNotNull(consolidatedMembers.get("user.joe"));
+        assertNotNull(consolidatedMembers.get(NOTIFY_SKIP_PRINCIPAL));
     }
 
     @Test
@@ -1396,4 +1398,69 @@ public class RoleMemberNotificationCommonTest {
         Mockito.verify(notificationObjectStore, Mockito.times(1)).registerReviewObjects(Mockito.eq("user.joe"),
                 Mockito.anyList());
     }
+
+    @Test
+    public void testExpiryPrincipalGetNotificationDetailsWithOutDomainAdmins() {
+
+        DBService dbsvc = Mockito.mock(DBService.class);
+        Role athenzAdmin = new Role().setName("athenz:role.admin")
+                .setRoleMembers(Collections.singletonList(new RoleMember().setMemberName("user.testadmin")));
+        Role cicdAdmin = new Role().setName("ci-cd.admin").setRoleMembers(Collections.emptyList());
+        Mockito.when(dbsvc.getRole("ci-cd", "admin", Boolean.FALSE, Boolean.TRUE, Boolean.FALSE))
+                .thenReturn(cicdAdmin);
+        Mockito.when(dbsvc.getRole("athenz", "admin", Boolean.FALSE, Boolean.TRUE, Boolean.FALSE))
+                .thenReturn(athenzAdmin);
+
+        RoleMemberNotificationCommon roleMemberNotificationCommon = new RoleMemberNotificationCommon(dbsvc,
+                USER_DOMAIN_PREFIX);
+        NotificationConverterCommon notificationConverterCommon = new NotificationConverterCommon(null);
+
+        DomainRoleMember domainRoleMember = new DomainRoleMember();
+        domainRoleMember.setMemberName("ci-cd.project-123");
+
+        final Timestamp expirationTs = Timestamp.fromMillis(100);
+
+        List<MemberRole> memberRoles = new ArrayList<>();
+        memberRoles.add(new MemberRole().setMemberName("ci-cd.project-123").setRoleName("writers")
+                .setDomainName("athenz").setExpiration(expirationTs));
+        domainRoleMember.setMemberRoles(memberRoles);
+
+        Map<String, DomainRoleMember> members = new HashMap<>();
+        members.put("ci-cd.project-123", domainRoleMember);
+
+        List<Notification> notifications = roleMemberNotificationCommon.getNotificationDetails(
+                Notification.Type.ROLE_MEMBER_EXPIRY, Notification.ConsolidatedBy.DOMAIN, members,
+                new RoleMemberExpiryNotificationTask.RoleExpiryPrincipalNotificationToEmailConverter(notificationConverterCommon),
+                new RoleMemberExpiryNotificationTask.RoleExpiryDomainNotificationToEmailConverter(notificationConverterCommon),
+                new RoleMemberExpiryNotificationTask.ExpiryRoleMemberDetailStringer(),
+                new RoleMemberExpiryNotificationTask.RoleExpiryPrincipalNotificationToMetricConverter(),
+                new RoleMemberExpiryNotificationTask.RoleExpiryDomainNotificationToMetricConverter(),
+                memberRole -> DisableNotificationEnum.getEnumSet(0),
+                new RoleMemberExpiryNotificationTask.RoleExpiryPrincipalNotificationToSlackConverter(notificationConverterCommon),
+                new RoleMemberExpiryNotificationTask.RoleExpiryDomainNotificationToSlackConverter(notificationConverterCommon),
+                null);
+
+        // since the domain has no admins, we should have no notifications
+
+        assertEquals(notifications.size(), 0);
+
+        notifications = roleMemberNotificationCommon.getNotificationDetails(
+                Notification.Type.ROLE_MEMBER_EXPIRY, Notification.ConsolidatedBy.PRINCIPAL, members,
+                new RoleMemberExpiryNotificationTask.RoleExpiryPrincipalNotificationToEmailConverter(notificationConverterCommon),
+                new RoleMemberExpiryNotificationTask.RoleExpiryDomainNotificationToEmailConverter(notificationConverterCommon),
+                new RoleMemberExpiryNotificationTask.ExpiryRoleMemberDetailStringer(),
+                new RoleMemberExpiryNotificationTask.RoleExpiryPrincipalNotificationToMetricConverter(),
+                new RoleMemberExpiryNotificationTask.RoleExpiryDomainNotificationToMetricConverter(),
+                memberRole -> DisableNotificationEnum.getEnumSet(0),
+                new RoleMemberExpiryNotificationTask.RoleExpiryPrincipalNotificationToSlackConverter(notificationConverterCommon),
+                new RoleMemberExpiryNotificationTask.RoleExpiryDomainNotificationToSlackConverter(notificationConverterCommon),
+                null);
+
+        assertEquals(notifications.size(), 1);
+        assertEquals(notifications.get(0).getDetails().size(), 1);
+        assertEquals(notifications.get(0).getRecipients().size(), 1);
+        assertTrue(notifications.get(0).getRecipients().contains("user.testadmin"));
+        assertEquals(notifications.get(0).getDetails().get(NOTIFICATION_DETAILS_MEMBERS_LIST),
+                "athenz;writers;ci-cd.project-123;" + expirationTs + ";");
+    }
 }