diff --git a/build.gradle.kts b/build.gradle.kts index 3c3b04d1..c4de61ad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-data-jdbc") + implementation("org.springframework.boot:spring-boot-starter-aop") implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") implementation("org.postgresql:postgresql:42.7.2") implementation("org.flywaydb:flyway-core") diff --git a/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java b/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java index cab14059..e85724e1 100644 --- a/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java +++ b/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java @@ -9,6 +9,7 @@ import com.wcc.platform.domain.exceptions.DuplicatedMemberException; import com.wcc.platform.domain.exceptions.EmailSendException; import com.wcc.platform.domain.exceptions.ErrorDetails; +import com.wcc.platform.domain.exceptions.ForbiddenException; import com.wcc.platform.domain.exceptions.InvalidProgramTypeException; import com.wcc.platform.domain.exceptions.MemberNotFoundException; import com.wcc.platform.domain.exceptions.MenteeNotSavedException; @@ -129,4 +130,15 @@ public ResponseEntity handleMethodArgumentNotValidException( HttpStatus.BAD_REQUEST.value(), errorMessage, request.getDescription(false)); return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST); } + + /** Return 403 Forbidden for ForbiddenException. */ + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handleForbiddenException( + final ForbiddenException ex, final WebRequest request) { + final var errorResponse = + new ErrorDetails( + HttpStatus.FORBIDDEN.value(), ex.getMessage(), request.getDescription(false)); + + return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN); + } } diff --git a/src/main/java/com/wcc/platform/configuration/TokenAuthFilter.java b/src/main/java/com/wcc/platform/configuration/TokenAuthFilter.java index 9d0b92a6..c9503252 100644 --- a/src/main/java/com/wcc/platform/configuration/TokenAuthFilter.java +++ b/src/main/java/com/wcc/platform/configuration/TokenAuthFilter.java @@ -1,12 +1,15 @@ package com.wcc.platform.configuration; +import com.wcc.platform.domain.auth.UserAccount; +import com.wcc.platform.domain.auth.UserAccount.User; import com.wcc.platform.service.AuthService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.Objects; +import java.util.List; +import java.util.Optional; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -61,21 +64,16 @@ protected void doFilterInternal( final String authHeader = request.getHeader(AUTHORIZATION); if (StringUtils.hasText(authHeader) && authHeader.startsWith(BEARER)) { final String token = authHeader.substring(AUTH_TOKEN_START); - authService - .authenticateByToken(token) - .ifPresent( - user -> { - final var authorities = - user.getRoles().stream() - .filter(Objects::nonNull) - .map(role -> new SimpleGrantedAuthority(role.name())) - .toList(); + final Optional userOpt = authService.authenticateByTokenWithMember(token); + if (userOpt.isPresent()) { + final UserAccount.User user = userOpt.get(); - SecurityContextHolder.getContext() - .setAuthentication( - new UsernamePasswordAuthenticationToken( - user.getEmail(), null, authorities)); - }); + final var authorities = List.of(new SimpleGrantedAuthority(user.getPrimaryRole().name())); + final UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(user, null, authorities); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } } filterChain.doFilter(request, response); diff --git a/src/main/java/com/wcc/platform/configuration/security/AuthorizationAspect.java b/src/main/java/com/wcc/platform/configuration/security/AuthorizationAspect.java new file mode 100644 index 00000000..4661267e --- /dev/null +++ b/src/main/java/com/wcc/platform/configuration/security/AuthorizationAspect.java @@ -0,0 +1,56 @@ +package com.wcc.platform.configuration.security; + +import com.wcc.platform.domain.auth.Permission; +import com.wcc.platform.domain.platform.type.RoleType; +import com.wcc.platform.service.AuthService; +import lombok.AllArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@Order(1) +@AllArgsConstructor +public class AuthorizationAspect { + + private final AuthService authService; + + @Around("@annotation(requiresPermission)") + public Object checkPermission( + final ProceedingJoinPoint joinPoint, final RequiresPermission requiresPermission) + throws Throwable { + + final Permission[] permissions = requiresPermission.value(); + + if (permissions.length == 0) { + throw new IllegalArgumentException( + "@RequiresPermission must specify at least one permission"); + } + + if (requiresPermission.operator() == LogicalOperator.AND) { + authService.requireAllPermissions(permissions); + } else { + authService.requireAnyPermission(permissions); + } + + return joinPoint.proceed(); + } + + @Around("@annotation(requiresRole)") + public Object checkRole(final ProceedingJoinPoint joinPoint, final RequiresRole requiresRole) + throws Throwable { + + final RoleType[] roles = requiresRole.value(); + + if (roles.length == 0) { + throw new IllegalArgumentException("@RequiresRole must specify at least one role"); + } + + authService.requireRole(roles); + + return joinPoint.proceed(); + } +} diff --git a/src/main/java/com/wcc/platform/configuration/security/LogicalOperator.java b/src/main/java/com/wcc/platform/configuration/security/LogicalOperator.java new file mode 100644 index 00000000..3c34e04b --- /dev/null +++ b/src/main/java/com/wcc/platform/configuration/security/LogicalOperator.java @@ -0,0 +1,6 @@ +package com.wcc.platform.configuration.security; + +public enum LogicalOperator { + AND, + OR +} diff --git a/src/main/java/com/wcc/platform/configuration/security/RequiresPermission.java b/src/main/java/com/wcc/platform/configuration/security/RequiresPermission.java new file mode 100644 index 00000000..440b61bc --- /dev/null +++ b/src/main/java/com/wcc/platform/configuration/security/RequiresPermission.java @@ -0,0 +1,15 @@ +package com.wcc.platform.configuration.security; + +import com.wcc.platform.domain.auth.Permission; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +public @interface RequiresPermission { + Permission[] value(); + + LogicalOperator operator() default LogicalOperator.AND; +} diff --git a/src/main/java/com/wcc/platform/configuration/security/RequiresRole.java b/src/main/java/com/wcc/platform/configuration/security/RequiresRole.java new file mode 100644 index 00000000..86497023 --- /dev/null +++ b/src/main/java/com/wcc/platform/configuration/security/RequiresRole.java @@ -0,0 +1,15 @@ +package com.wcc.platform.configuration.security; + +import com.wcc.platform.domain.platform.type.RoleType; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +public @interface RequiresRole { + RoleType[] value(); + + LogicalOperator operator() default LogicalOperator.AND; +} diff --git a/src/main/java/com/wcc/platform/controller/MentorshipApplicationController.java b/src/main/java/com/wcc/platform/controller/MentorshipApplicationController.java index a20e2c49..0db6b043 100644 --- a/src/main/java/com/wcc/platform/controller/MentorshipApplicationController.java +++ b/src/main/java/com/wcc/platform/controller/MentorshipApplicationController.java @@ -1,5 +1,8 @@ package com.wcc.platform.controller; +import com.wcc.platform.configuration.security.LogicalOperator; +import com.wcc.platform.configuration.security.RequiresPermission; +import com.wcc.platform.domain.auth.Permission; import com.wcc.platform.domain.platform.mentorship.ApplicationAcceptRequest; import com.wcc.platform.domain.platform.mentorship.ApplicationDeclineRequest; import com.wcc.platform.domain.platform.mentorship.ApplicationStatus; @@ -36,7 +39,7 @@ public class MentorshipApplicationController { private final MenteeWorkflowService applicationService; - + /** * API to get all applications submitted by a mentee for a specific cycle. * @@ -81,6 +84,9 @@ public ResponseEntity withdrawApplication( * @return List of applications */ @GetMapping("/mentors/{mentorId}/applications") + @RequiresPermission( + value = {Permission.MENTOR_APPL_READ, Permission.MENTOR_APPROVE}, + operator = LogicalOperator.OR) @Operation(summary = "Get applications received by a mentor") @ResponseStatus(HttpStatus.OK) public ResponseEntity> getMentorApplications( diff --git a/src/main/java/com/wcc/platform/domain/auth/MemberTypeRoleMapper.java b/src/main/java/com/wcc/platform/domain/auth/MemberTypeRoleMapper.java new file mode 100644 index 00000000..1c4a152f --- /dev/null +++ b/src/main/java/com/wcc/platform/domain/auth/MemberTypeRoleMapper.java @@ -0,0 +1,122 @@ +package com.wcc.platform.domain.auth; + +import com.wcc.platform.domain.platform.type.MemberType; +import com.wcc.platform.domain.platform.type.RoleType; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public final class MemberTypeRoleMapper { + + private static final Map MEMBER_TYPE_TO_ROLE = + Map.of( + MemberType.DIRECTOR, RoleType.ADMIN, + MemberType.LEADER, RoleType.LEADER, + MemberType.MENTOR, RoleType.MENTOR, + MemberType.MENTEE, RoleType.MENTEE, + MemberType.COLLABORATOR, RoleType.CONTRIBUTOR, + MemberType.EVANGELIST, RoleType.CONTRIBUTOR, + MemberType.SPEAKER, RoleType.CONTRIBUTOR, + MemberType.VOLUNTEER, RoleType.VIEWER, + MemberType.MEMBER, RoleType.VIEWER, + MemberType.PARTNER, RoleType.VIEWER); + + // Role hierarchy: higher number = more privileged + private static final Map ROLE_HIERARCHY = + Map.of( + RoleType.ADMIN, 100, + RoleType.LEADER, 80, + RoleType.MENTOR, 60, + RoleType.CONTRIBUTOR, 50, + RoleType.MENTEE, 40, + RoleType.VIEWER, 20); + + private MemberTypeRoleMapper() { + // Utility class + } + + /** Get role for a single MemberType. */ + public static RoleType getRoleForMemberType(final MemberType memberType) { + if (memberType == null) { + throw new IllegalArgumentException("MemberType cannot be null"); + } + return MEMBER_TYPE_TO_ROLE.getOrDefault(memberType, RoleType.VIEWER); + } + + /** + * Get all roles for a list of MemberTypes. + * + * @param memberTypes list of member types + * @return set of all roles corresponding to the member types + */ + public static Set getRolesForMemberTypes(final List memberTypes) { + if (memberTypes == null || memberTypes.isEmpty()) { + return Set.of(RoleType.VIEWER); + } + + return memberTypes.stream() + .map(MemberTypeRoleMapper::getRoleForMemberType) + .collect(Collectors.toSet()); + } + + /** + * Get the highest privilege role from a list of MemberTypes. This determines the "primary" role + * when a member has multiple types. + * + * @param memberTypes list of member types + * @return the role with highest privilege level + */ + public static RoleType getHighestRole(final List memberTypes) { + if (memberTypes == null || memberTypes.isEmpty()) { + return RoleType.VIEWER; + } + + return memberTypes.stream() + .map(MemberTypeRoleMapper::getRoleForMemberType) + .max(Comparator.comparing(role -> ROLE_HIERARCHY.getOrDefault(role, 0))) + .orElse(RoleType.VIEWER); + } + + /** + * Get all permissions from multiple MemberTypes (union of all permissions). + * + * @param memberTypes list of member types + * @return set of all unique permissions + */ + public static Set getAllPermissionsForMemberTypes( + final List memberTypes) { + if (memberTypes == null || memberTypes.isEmpty()) { + return RoleType.VIEWER.getPermissions(); + } + + return memberTypes.stream() + .map(MemberTypeRoleMapper::getRoleForMemberType) + .flatMap(role -> role.getPermissions().stream()) + .collect(Collectors.toSet()); + } + + /** Check if any of the member types maps to SUPER_ADMIN. */ + public static boolean isSuperAdmin(final List memberTypes) { + return memberTypes != null + && memberTypes.stream().anyMatch(type -> getRoleForMemberType(type) == RoleType.ADMIN); + } + + /** Check if any of the member types maps to ADMIN or SUPER_ADMIN. */ + public static boolean isAdmin(final List memberTypes) { + return memberTypes != null + && memberTypes.stream() + .map(MemberTypeRoleMapper::getRoleForMemberType) + .anyMatch(role -> role == RoleType.LEADER || role == RoleType.ADMIN); + } + + /** Check if member has a specific role through any of their member types. */ + public static boolean hasRole(final List memberTypes, final RoleType targetRole) { + return memberTypes != null + && targetRole != null + && memberTypes.stream() + .map(MemberTypeRoleMapper::getRoleForMemberType) + .anyMatch(role -> role == targetRole); + } +} diff --git a/src/main/java/com/wcc/platform/domain/auth/Permission.java b/src/main/java/com/wcc/platform/domain/auth/Permission.java new file mode 100644 index 00000000..747b6f85 --- /dev/null +++ b/src/main/java/com/wcc/platform/domain/auth/Permission.java @@ -0,0 +1,29 @@ +package com.wcc.platform.domain.auth; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Permission { + // User Management + USER_READ("user:read", "View user information"), + USER_WRITE("user:write", "Create and update users"), + USER_DELETE("user:delete", "Delete users"), + + // Mentor Operations + MENTOR_APPL_READ("mentor:application:read", "View mentor applications"), + MENTOR_APPL_WRITE("mentor:application:write", "Accept/decline mentee applications"), + MENTOR_PROFILE_UPDATE("mentor:profile:update", "Update mentor profile"), + + // Mentee Operations + MENTEE_APPL_SUBMIT("mentee:application:submit", "Submit mentee applications"), + MENTEE_APPL_READ("mentee:application:read", "View own application status"), + + // Admin Operations + MENTOR_APPROVE("admin:mentor:approve", "Approve/reject mentors"), + MENTEE_APPROVE("admin:mentee:approve", "Approve/reject mentees"), + CYCLE_EMAIL_SEND("admin:cycle:email", "Send cycle emails"), + MATCH_MANAGE("admin:match:manage", "Manage mentor-mentee matches"); + + private final String code; + private final String description; +} diff --git a/src/main/java/com/wcc/platform/domain/auth/UserAccount.java b/src/main/java/com/wcc/platform/domain/auth/UserAccount.java index be77e878..31942e18 100644 --- a/src/main/java/com/wcc/platform/domain/auth/UserAccount.java +++ b/src/main/java/com/wcc/platform/domain/auth/UserAccount.java @@ -2,11 +2,16 @@ import com.wcc.platform.domain.platform.member.Member; import com.wcc.platform.domain.platform.type.RoleType; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import org.springframework.util.CollectionUtils; /** Domain object representing an application user linked to a Member. */ @Data @@ -21,6 +26,17 @@ public class UserAccount { private List roles; private boolean enabled; + /** Get all permissions from all assigned roles in UserAccount. */ + public Set getPermissions() { + if (roles == null || CollectionUtils.isEmpty(roles)) { + return Set.of(); + } + + return roles.stream() + .flatMap(role -> role.getPermissions().stream()) + .collect(Collectors.toSet()); + } + /** * A record that encapsulates a User within the platform. * @@ -28,5 +44,72 @@ public class UserAccount { * A User consists of a user account for authentication purposes and a member entity containing * personal and community-related details. */ - public record User(UserAccount userAccount, Member member) {} + public record User(UserAccount userAccount, Member member) { + + /** + * Get the primary role based on member types. Returns the highest privilege role when member + * has multiple types. + * + *

For example, if a member is both MENTEE and MENTOR, their primary role will be MENTOR + * (since MENTOR has higher privileges). + */ + public RoleType getPrimaryRole() { + if (member == null || CollectionUtils.isEmpty(member.getMemberTypes())) { + return RoleType.VIEWER; + } + return MemberTypeRoleMapper.getHighestRole(member.getMemberTypes()); + } + + /** + * Get all roles derived from member types. A member can have multiple roles if they have + * multiple member types. + * + *

For example, a member who is both MENTOR and COLLABORATOR will have both MENTOR_ROLE and + * CONTRIBUTOR roles. + */ + public Set getAllMemberRoles() { + if (member == null || CollectionUtils.isEmpty(member.getMemberTypes())) { + return Set.of(RoleType.VIEWER); + } + return MemberTypeRoleMapper.getRolesForMemberTypes(member.getMemberTypes()); + } + + /** + * Get all roles including both: 1. Roles derived from member types 2. Roles explicitly assigned + * in UserAccount + */ + @SuppressWarnings("PMD.UseEnumCollections") + public Set getAllRoles() { + final Set allRoles = new HashSet<>(getAllMemberRoles()); + if (userAccount.getRoles() != null) { + allRoles.addAll(userAccount.getRoles()); + } + return allRoles; + } + + /** + * Get all permissions including those from: 1. All member types (union of permissions) 2. + * Explicitly assigned roles in UserAccount + */ + @SuppressWarnings("PMD.UseEnumCollections") + public Set getAllPermissions() { + final Set permissions = new HashSet<>(); + + // Add permissions from member types + if (member != null && member.getMemberTypes() != null) { + permissions.addAll( + MemberTypeRoleMapper.getAllPermissionsForMemberTypes(member.getMemberTypes())); + } + + permissions.addAll(userAccount.getPermissions()); + + return permissions; + } + + /** Check if user has any of the specified roles. */ + public boolean hasAnyRole(final RoleType... roles) { + final Set userRoles = getAllRoles(); + return Arrays.stream(roles).anyMatch(userRoles::contains); + } + } } diff --git a/src/main/java/com/wcc/platform/domain/exceptions/ForbiddenException.java b/src/main/java/com/wcc/platform/domain/exceptions/ForbiddenException.java new file mode 100644 index 00000000..9a222af0 --- /dev/null +++ b/src/main/java/com/wcc/platform/domain/exceptions/ForbiddenException.java @@ -0,0 +1,8 @@ +package com.wcc.platform.domain.exceptions; + +public class ForbiddenException extends RuntimeException { + + public ForbiddenException(final String message) { + super(message); + } +} diff --git a/src/main/java/com/wcc/platform/domain/platform/type/RoleType.java b/src/main/java/com/wcc/platform/domain/platform/type/RoleType.java index 2f9c8f94..77e8d773 100644 --- a/src/main/java/com/wcc/platform/domain/platform/type/RoleType.java +++ b/src/main/java/com/wcc/platform/domain/platform/type/RoleType.java @@ -1,5 +1,7 @@ package com.wcc.platform.domain.platform.type; +import com.wcc.platform.domain.auth.Permission; +import java.util.Set; import lombok.AllArgsConstructor; import lombok.Getter; @@ -7,24 +9,33 @@ @Getter @AllArgsConstructor public enum RoleType { - ADMIN(1, "Platform Administrator"), - MEMBER(2, "Community Member"), - - MENTORSHIP_ADMIN(20, "Mentorship Administrator"), - MENTORSHIP_EDITOR(21, "Mentorship Team"), - - MAIL_ADMIN(30, "Newsletter Administrator"), - MAIL_EDITOR(31, "Newsletter Editor"), - MAIL_PUBLISHER(33, "Newsletter Publisher"), - MAIL_SUBSCRIBER(32, "Newsletter Subscriber Coordinator"), - MAIL_VIEWER(34, "Newsletter Viewer"), - - CONTENT_ADMIN(40, "Website Content Administrator"), - CONTENT_EDITOR(41, "Website Content Editor"), - CONTENT_VIEWER(42, "Website Content Viewer"); + ADMIN(1, "Platform Administrator", Set.of(Permission.values())), + LEADER( + 4, + "Platform Leader", + Set.of( + Permission.USER_READ, + Permission.MENTOR_APPROVE, + Permission.MENTEE_APPROVE, + Permission.CYCLE_EMAIL_SEND, + Permission.MATCH_MANAGE, + Permission.MENTOR_APPL_READ)), + MENTEE( + 5, "Mentee In Community", Set.of(Permission.MENTEE_APPL_SUBMIT, Permission.MENTEE_APPL_READ)), + MENTOR( + 6, + "Mentor In Community", + Set.of( + Permission.MENTOR_APPL_READ, + Permission.MENTOR_APPL_WRITE, + Permission.MENTOR_PROFILE_UPDATE)), + + CONTRIBUTOR(2, "Contributor In Community", Set.of(Permission.USER_READ)), + VIEWER(7, "Member In Community", Set.of(Permission.USER_READ)); private final int typeId; private final String description; + private final Set permissions; /** * Retrieves the corresponding {@code MemberType} enum value based on a given type ID. If no match @@ -40,7 +51,7 @@ public static RoleType fromId(final int typeId) { return type; } } - return MEMBER; + return VIEWER; } @Override diff --git a/src/main/java/com/wcc/platform/service/AuthService.java b/src/main/java/com/wcc/platform/service/AuthService.java index 6878795a..fbb7703d 100644 --- a/src/main/java/com/wcc/platform/service/AuthService.java +++ b/src/main/java/com/wcc/platform/service/AuthService.java @@ -1,17 +1,25 @@ package com.wcc.platform.service; +import com.wcc.platform.domain.auth.Permission; import com.wcc.platform.domain.auth.UserAccount; import com.wcc.platform.domain.auth.UserToken; +import com.wcc.platform.domain.exceptions.ForbiddenException; import com.wcc.platform.domain.platform.member.Member; import com.wcc.platform.domain.platform.member.MemberDto; +import com.wcc.platform.domain.platform.type.RoleType; import com.wcc.platform.repository.MemberRepository; import com.wcc.platform.repository.UserAccountRepository; import com.wcc.platform.repository.UserTokenRepository; import java.security.SecureRandom; import java.time.OffsetDateTime; +import java.util.Arrays; import java.util.Base64; import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -20,35 +28,18 @@ * authentication, token management, and member retrieval. */ @Service +@RequiredArgsConstructor +@SuppressWarnings("PMD.TooManyMethods") public class AuthService { private static final SecureRandom RANDOM = new SecureRandom(); private final UserAccountRepository userAccountRepository; private final UserTokenRepository userTokenRepository; private final MemberRepository memberRepository; - private final int tokenTtlMinutes; private final PasswordEncoder passwordEncoder; - /** - * Constructor for the AuthService class. - * - * @param userAccountRepository the repository for managing user account entities - * @param userTokenRepository the repository for managing user token entities - * @param memberRepository the repository for managing member entities - * @param tokenTtlMinutes the time-to-live value for tokens, in minutes - */ - public AuthService( - final UserAccountRepository userAccountRepository, - final UserTokenRepository userTokenRepository, - final MemberRepository memberRepository, - final @Value("${security.token.ttl-minutes}") int tokenTtlMinutes, - final PasswordEncoder passwordEncoder) { - this.userAccountRepository = userAccountRepository; - this.userTokenRepository = userTokenRepository; - this.memberRepository = memberRepository; - this.tokenTtlMinutes = tokenTtlMinutes; - this.passwordEncoder = passwordEncoder; - } + @Value("${security.token.ttl-minutes}") + private int tokenTtlMinutes; public Optional findUserByEmail(final String email) { return userAccountRepository.findByEmail(email); @@ -134,4 +125,128 @@ private UserToken generateUserToken(final UserAccount user) { userTokenRepository.create(userToken); return userToken; } + + /** + * Retrieves the complete User (UserAccount + Member) for a given user account ID. This is used + * for RBAC permission checking. + * + * @param userId the ID of the user account + * @return an {@code Optional} containing the user and member if found + */ + public Optional getUserWithMember(final Integer userId) { + if (userId == null) { + return Optional.empty(); + } + + final Optional userAccountOpt = userAccountRepository.findById(userId); + if (userAccountOpt.isEmpty()) { + return Optional.empty(); + } + + final UserAccount userAccount = userAccountOpt.get(); + if (userAccount.getMemberId() == null) { + return Optional.empty(); + } + + final Optional memberOpt = memberRepository.findById(userAccount.getMemberId()); + return memberOpt.map(member -> new UserAccount.User(userAccount, member)); + } + + /** + * Authenticates a user based on the provided token and returns the complete User (UserAccount + + * Member). This is the preferred method for RBAC-enabled authentication. + * + * @param token the authentication token provided by the user + * @return an {@code Optional} containing the user and member if the token is + * valid and associated with an existing user, or an empty {@code Optional} if the token is + * invalid or no user account is found + */ + public Optional authenticateByTokenWithMember(final String token) { + final Optional tokenOpt = + userTokenRepository.findValidByToken(token, OffsetDateTime.now()); + if (tokenOpt.isEmpty()) { + return Optional.empty(); + } + + final UserToken retrievedToken = tokenOpt.get(); + return getUserWithMember(retrievedToken.getUserId()); + } + + /** + * Get the current authenticated user with member information from SecurityContext. + * + * @return the authenticated User (UserAccount + Member) + * @throws ForbiddenException if user is not authenticated or principal is invalid + */ + public UserAccount.User getCurrentUser() { + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + throw new ForbiddenException("User is not authenticated"); + } + + final Object principal = authentication.getPrincipal(); + + if (principal instanceof UserAccount.User) { + return (UserAccount.User) principal; + } + + throw new ForbiddenException("Invalid authentication principal"); + } + + /** + * Require any of the specified permissions (OR logic). + * + * @param permissions the permissions (user needs at least one) + * @throws ForbiddenException if user doesn't have any of the permissions + */ + public void requireAnyPermission(final Permission... permissions) { + final UserAccount.User user = getCurrentUser(); + final Set userPermissions = user.getAllPermissions(); + + final boolean hasAny = Arrays.stream(permissions).anyMatch(userPermissions::contains); + + if (!hasAny) { + throw new ForbiddenException( + String.format("Permission denied. Required any of: %s", Arrays.toString(permissions))); + } + } + + /** + * Require all of the specified permissions (AND logic). + * + * @param permissions the permissions (user needs all of them) + * @throws ForbiddenException if user doesn't have all the permissions + */ + public void requireAllPermissions(final Permission... permissions) { + final UserAccount.User user = getCurrentUser(); + final Set userPermissions = user.getAllPermissions(); + + final boolean hasAll = Arrays.stream(permissions).allMatch(userPermissions::contains); + + if (!hasAll) { + throw new ForbiddenException( + String.format("Permission denied. Required all of: %s", Arrays.toString(permissions))); + } + } + + /** + * Require specific role(s). User needs at least one of the specified roles (from member types or + * assigned roles). + * + * @param allowedRoles the allowed roles (user needs one of them) + * @throws ForbiddenException if user doesn't have any of the roles + */ + public void requireRole(final RoleType... allowedRoles) { + final UserAccount.User user = getCurrentUser(); + + if (user.hasAnyRole(allowedRoles)) { + return; + } + + throw new ForbiddenException( + String.format( + "Role denied. User roles: %s, Required any of: %s", + user.getAllRoles(), Arrays.toString(allowedRoles))); + } } diff --git a/src/main/resources/db/migration/V23__20260120__update_user_auth_tables.sql b/src/main/resources/db/migration/V23__20260120__update_user_auth_tables.sql new file mode 100644 index 00000000..dee3abb9 --- /dev/null +++ b/src/main/resources/db/migration/V23__20260120__update_user_auth_tables.sql @@ -0,0 +1,30 @@ +UPDATE role_types +SET name = 'CONTRIBUTOR', + description = 'Collaborator In Community' +WHERE id = 2; + +-- Insert Role Types +INSERT INTO role_types (id, name, description) +VALUES (4, 'LEADER', 'Leader In Community'), + (5, 'MENTEE', 'Mentee In Community'), + (6, 'MENTOR', 'Mentor In Community'), + (7, 'MEMBER', 'Member In Community'); + +-- Insert a member +INSERT INTO members (full_name, slack_name, position, company_name, email, city, country_id, + status_id, bio, years_experience, spoken_language) +values ('Sonali Goel', 'sonaligoel', 'Senior Software Engineer', 'Tesco Technology', + 'sonali.learn.ai@gmail.com', 'London', 234, 1, + 'Passionate about technology and community building.', 10, 'English, Hindi'); + +-- Insert user account for the new member +INSERT INTO user_accounts (member_id, email, password_hash, enabled) +VALUES ((SELECT id FROM members WHERE members.email = 'sonali.learn.ai@gmail.com'), + 'sonali.learn.ai@gmail.com', + '$argon2id$v=19$m=65536,t=3,p=1$49crq1CpGyILHW2LRfdpRg$mIaxgAa7ksupTF49pjkONlD2U3i48m2jmbeXeWvRJno', + TRUE); + +-- Link the new member to the LEADER role +INSERT INTO user_roles (user_id, role_id) +VALUES ((SELECT id FROM user_accounts WHERE email = 'sonali.learn.ai@gmail.com'), + (SELECT id FROM role_types WHERE name = 'LEADER')); \ No newline at end of file diff --git a/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java b/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java index f2c3c5e1..992f25c3 100644 --- a/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java @@ -5,9 +5,11 @@ import static org.mockito.Mockito.when; import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.FORBIDDEN; import com.wcc.platform.domain.exceptions.DuplicatedMemberException; import com.wcc.platform.domain.exceptions.ErrorDetails; +import com.wcc.platform.domain.exceptions.ForbiddenException; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -64,4 +66,15 @@ void shouldReturnNotAcceptableForMethodArgumentNotValidException() { assertEquals(BAD_REQUEST, response.getStatusCode()); assertEquals(expectation, response.getBody()); } + + @Test + void shouldReturnForbiddenForAccessDeniedException() { + var exception = new ForbiddenException("Error"); + + var response = globalExceptionHandler.handleForbiddenException(exception, webRequest); + + var expectation = new ErrorDetails(FORBIDDEN.value(), "Error", DETAILS); + assertEquals(FORBIDDEN, response.getStatusCode()); + assertEquals(expectation, response.getBody()); + } } diff --git a/src/test/java/com/wcc/platform/configuration/TokenAuthFilterTest.java b/src/test/java/com/wcc/platform/configuration/TokenAuthFilterTest.java index bd156f82..5894b280 100644 --- a/src/test/java/com/wcc/platform/configuration/TokenAuthFilterTest.java +++ b/src/test/java/com/wcc/platform/configuration/TokenAuthFilterTest.java @@ -9,6 +9,8 @@ import static org.mockito.Mockito.when; import com.wcc.platform.domain.auth.UserAccount; +import com.wcc.platform.domain.platform.member.Member; +import com.wcc.platform.domain.platform.type.MemberType; import com.wcc.platform.domain.platform.type.RoleType; import com.wcc.platform.service.AuthService; import jakarta.servlet.FilterChain; @@ -54,18 +56,25 @@ void givenValidBearerTokenWhenDoFilterInternalThenAuthenticationIsSet() UserAccount mockUser = UserAccount.builder().email("admin@wcc.dev").roles(List.of(RoleType.ADMIN)).build(); + Member member = + Member.builder() + .id(1L) + .fullName("Admin WCC") + .memberTypes(List.of(MemberType.DIRECTOR)) + .build(); + UserAccount.User user = new UserAccount.User(mockUser, member); - when(authService.authenticateByToken(token)).thenReturn(Optional.of(mockUser)); + when(authService.authenticateByTokenWithMember(token)).thenReturn(Optional.of(user)); tokenAuthFilter.doFilterInternal(request, response, filterChain); var authentication = SecurityContextHolder.getContext().getAuthentication(); - verify(authService).authenticateByToken(token); + verify(authService).authenticateByTokenWithMember(token); verify(filterChain).doFilter(request, response); assertNotNull(authentication); assertTrue(authentication.isAuthenticated()); - assertEquals("admin@wcc.dev", authentication.getName()); + assertEquals(user, authentication.getPrincipal()); final var authorities = authentication.getAuthorities(); assertTrue(authorities.stream().anyMatch(a -> a.getAuthority().equals("ADMIN"))); } @@ -79,7 +88,7 @@ void givenMissingAuthorizationHeaderWhenDoFilterInternalThenNoAuthenticationAtte tokenAuthFilter.doFilterInternal(request, response, filterChain); - verify(authService, never()).authenticateByToken(anyString()); + verify(authService, never()).authenticateByTokenWithMember(anyString()); verify(filterChain).doFilter(request, response); assert SecurityContextHolder.getContext().getAuthentication() == null; } @@ -92,11 +101,11 @@ void givenInvalidBearerTokenWhenDoFilterInternalThenAuthenticationNotSet() throws ServletException, IOException { final String invalidToken = "invalidToken123"; when(request.getHeader("Authorization")).thenReturn("Bearer " + invalidToken); - when(authService.authenticateByToken(invalidToken)).thenReturn(Optional.empty()); + when(authService.authenticateByTokenWithMember(invalidToken)).thenReturn(Optional.empty()); tokenAuthFilter.doFilterInternal(request, response, filterChain); - verify(authService).authenticateByToken(invalidToken); + verify(authService).authenticateByTokenWithMember(invalidToken); verify(filterChain).doFilter(request, response); assert SecurityContextHolder.getContext().getAuthentication() == null; } @@ -111,7 +120,7 @@ void givenMalformedHeaderWithoutBearerWhenDoFilterInternalThenNoAuthenticationAt tokenAuthFilter.doFilterInternal(request, response, filterChain); - verify(authService, never()).authenticateByToken(anyString()); + verify(authService, never()).authenticateByTokenWithMember(anyString()); verify(filterChain).doFilter(request, response); assert SecurityContextHolder.getContext().getAuthentication() == null; } diff --git a/src/test/java/com/wcc/platform/configuration/security/AuthorizationAspectTest.java b/src/test/java/com/wcc/platform/configuration/security/AuthorizationAspectTest.java new file mode 100644 index 00000000..668b2cf4 --- /dev/null +++ b/src/test/java/com/wcc/platform/configuration/security/AuthorizationAspectTest.java @@ -0,0 +1,120 @@ +package com.wcc.platform.configuration.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.wcc.platform.domain.auth.Permission; +import com.wcc.platform.domain.exceptions.ForbiddenException; +import com.wcc.platform.domain.platform.type.RoleType; +import com.wcc.platform.service.AuthService; +import org.aspectj.lang.ProceedingJoinPoint; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AuthorizationAspectTest { + + @Mock private AuthService authService; + @Mock private ProceedingJoinPoint joinPoint; + @Mock private RequiresPermission requiresPermission; + @Mock private RequiresRole requiresRole; + + private AuthorizationAspect authorizationAspect; + + @BeforeEach + void setUp() { + authorizationAspect = new AuthorizationAspect(authService); + } + + @Test + void testCheckPermission_andOperator_allPermissionsGranted_proceedsWithMethod() throws Throwable { + Permission[] permissions = {Permission.MENTOR_APPROVE, Permission.MENTEE_APPROVE}; + when(requiresPermission.value()).thenReturn(permissions); + when(requiresPermission.operator()).thenReturn(LogicalOperator.AND); + when(joinPoint.proceed()).thenReturn("success"); + + Object result = authorizationAspect.checkPermission(joinPoint, requiresPermission); + + assertEquals("success", result); + verify(authService).requireAllPermissions(permissions); + verify(joinPoint).proceed(); + } + + @Test + void testCheckPermission_andOperator_permissionDenied_throwsForbiddenException() + throws Throwable { + Permission[] permissions = {Permission.MENTOR_APPROVE, Permission.MENTEE_APPROVE}; + when(requiresPermission.value()).thenReturn(permissions); + when(requiresPermission.operator()).thenReturn(LogicalOperator.AND); + doThrow(new ForbiddenException("Permission denied")) + .when(authService) + .requireAllPermissions(permissions); + + assertThrows( + ForbiddenException.class, + () -> authorizationAspect.checkPermission(joinPoint, requiresPermission)); + + verify(authService).requireAllPermissions(permissions); + verify(joinPoint, never()).proceed(); + } + + @Test + void testCheckPermission_orOperator_anyPermissionGranted_proceedsWithMethod() throws Throwable { + Permission[] permissions = {Permission.MENTOR_APPROVE, Permission.MENTEE_APPROVE}; + when(requiresPermission.value()).thenReturn(permissions); + when(requiresPermission.operator()).thenReturn(LogicalOperator.OR); + when(joinPoint.proceed()).thenReturn("success"); + + Object result = authorizationAspect.checkPermission(joinPoint, requiresPermission); + + assertEquals("success", result); + verify(authService).requireAnyPermission(permissions); + verify(joinPoint).proceed(); + } + + @Test + void testCheckRole_singleRoleGranted_proceedsWithMethod() throws Throwable { + RoleType[] roles = {RoleType.ADMIN}; + when(requiresRole.value()).thenReturn(roles); + when(joinPoint.proceed()).thenReturn("success"); + + Object result = authorizationAspect.checkRole(joinPoint, requiresRole); + + assertEquals("success", result); + verify(authService).requireRole(roles); + verify(joinPoint).proceed(); + } + + @Test + void testCheckRole_multipleRolesGranted_proceedsWithMethod() throws Throwable { + RoleType[] roles = {RoleType.ADMIN, RoleType.LEADER, RoleType.MENTOR}; + when(requiresRole.value()).thenReturn(roles); + when(joinPoint.proceed()).thenReturn("success"); + + Object result = authorizationAspect.checkRole(joinPoint, requiresRole); + + assertEquals("success", result); + verify(authService).requireRole(roles); + verify(joinPoint).proceed(); + } + + @Test + void testCheckRole_roleDenied_throwsForbiddenException() throws Throwable { + RoleType[] roles = {RoleType.ADMIN}; + when(requiresRole.value()).thenReturn(roles); + doThrow(new ForbiddenException("Role not granted")).when(authService).requireRole(roles); + + assertThrows( + ForbiddenException.class, () -> authorizationAspect.checkRole(joinPoint, requiresRole)); + + verify(authService).requireRole(roles); + verify(joinPoint, never()).proceed(); + } +} diff --git a/src/test/java/com/wcc/platform/domain/auth/MemberTypeRoleMapperTest.java b/src/test/java/com/wcc/platform/domain/auth/MemberTypeRoleMapperTest.java new file mode 100644 index 00000000..cd0a1ff8 --- /dev/null +++ b/src/test/java/com/wcc/platform/domain/auth/MemberTypeRoleMapperTest.java @@ -0,0 +1,108 @@ +package com.wcc.platform.domain.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.wcc.platform.domain.platform.type.MemberType; +import com.wcc.platform.domain.platform.type.RoleType; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class MemberTypeRoleMapperTest { + + @Test + void getRoleForMemberType_returnsMappedRole() { + assertEquals(RoleType.ADMIN, MemberTypeRoleMapper.getRoleForMemberType(MemberType.DIRECTOR)); + assertEquals(RoleType.LEADER, MemberTypeRoleMapper.getRoleForMemberType(MemberType.LEADER)); + assertEquals(RoleType.MENTOR, MemberTypeRoleMapper.getRoleForMemberType(MemberType.MENTOR)); + assertEquals(RoleType.MENTEE, MemberTypeRoleMapper.getRoleForMemberType(MemberType.MENTEE)); + assertEquals( + RoleType.CONTRIBUTOR, MemberTypeRoleMapper.getRoleForMemberType(MemberType.COLLABORATOR)); + assertEquals(RoleType.VIEWER, MemberTypeRoleMapper.getRoleForMemberType(MemberType.MEMBER)); + } + + @Test + void getRoleForMemberType_null_throwsIllegalArgumentException() { + assertThrows( + IllegalArgumentException.class, () -> MemberTypeRoleMapper.getRoleForMemberType(null)); + } + + @Test + void getRolesForMemberTypes_nullOrEmpty_returnsViewer() { + Set fromNull = MemberTypeRoleMapper.getRolesForMemberTypes(null); + assertEquals(1, fromNull.size()); + assertTrue(fromNull.contains(RoleType.VIEWER)); + + Set fromEmpty = MemberTypeRoleMapper.getRolesForMemberTypes(List.of()); + assertEquals(1, fromEmpty.size()); + assertTrue(fromEmpty.contains(RoleType.VIEWER)); + } + + @Test + void getRolesForMemberTypes_collectsRoles() { + Set roles = + MemberTypeRoleMapper.getRolesForMemberTypes( + List.of(MemberType.LEADER, MemberType.MENTEE, MemberType.SPEAKER)); + assertTrue(roles.contains(RoleType.LEADER)); + assertTrue(roles.contains(RoleType.MENTEE)); + assertTrue(roles.contains(RoleType.CONTRIBUTOR)); + } + + @Test + void getHighestRole_returnsMostPrivileged() { + // Director maps to SUPER_ADMIN (highest), Member maps to VIEWER (low) + RoleType highest = + MemberTypeRoleMapper.getHighestRole(List.of(MemberType.MEMBER, MemberType.DIRECTOR)); + assertEquals(RoleType.ADMIN, highest); + + // Leader(Admin) vs Mentor -> ADMIN is higher in defined hierarchy + highest = MemberTypeRoleMapper.getHighestRole(List.of(MemberType.MENTOR, MemberType.LEADER)); + assertEquals(RoleType.LEADER, highest); + } + + @Test + void getHighestRole_nullOrEmpty_returnsViewer() { + assertEquals(RoleType.VIEWER, MemberTypeRoleMapper.getHighestRole(null)); + assertEquals(RoleType.VIEWER, MemberTypeRoleMapper.getHighestRole(List.of())); + } + + @Test + void getAllPermissionsForMemberTypes_nullOrEmpty_returnsViewerPermissions() { + Set viewerPerms = RoleType.VIEWER.getPermissions(); + + Set fromNull = MemberTypeRoleMapper.getAllPermissionsForMemberTypes(null); + assertEquals(viewerPerms, fromNull); + + Set fromEmpty = MemberTypeRoleMapper.getAllPermissionsForMemberTypes(List.of()); + assertEquals(viewerPerms, fromEmpty); + } + + @Test + void isSuperAdmin_checksCorrectly() { + assertTrue(MemberTypeRoleMapper.isSuperAdmin(List.of(MemberType.DIRECTOR))); + assertFalse(MemberTypeRoleMapper.isSuperAdmin(List.of(MemberType.MEMBER, MemberType.MENTEE))); + assertFalse(MemberTypeRoleMapper.isSuperAdmin(null)); + } + + @Test + void isAdmin_checksCorrectly() { + assertTrue(MemberTypeRoleMapper.isAdmin(List.of(MemberType.LEADER))); + // Director (SUPER_ADMIN) should also count as admin + assertTrue(MemberTypeRoleMapper.isAdmin(List.of(MemberType.DIRECTOR))); + assertFalse(MemberTypeRoleMapper.isAdmin(List.of(MemberType.MEMBER))); + assertFalse(MemberTypeRoleMapper.isAdmin(null)); + } + + @Test + void hasRole_checksCorrectly() { + assertTrue( + MemberTypeRoleMapper.hasRole( + List.of(MemberType.LEADER, MemberType.MEMBER), RoleType.LEADER)); + assertFalse(MemberTypeRoleMapper.hasRole(List.of(MemberType.MEMBER), RoleType.LEADER)); + assertFalse(MemberTypeRoleMapper.hasRole(null, RoleType.LEADER)); + assertFalse(MemberTypeRoleMapper.hasRole(List.of(MemberType.LEADER), null)); + } +} diff --git a/src/test/java/com/wcc/platform/domain/auth/UserAccountTest.java b/src/test/java/com/wcc/platform/domain/auth/UserAccountTest.java new file mode 100644 index 00000000..de7d6059 --- /dev/null +++ b/src/test/java/com/wcc/platform/domain/auth/UserAccountTest.java @@ -0,0 +1,322 @@ +package com.wcc.platform.domain.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.wcc.platform.domain.platform.member.Member; +import com.wcc.platform.domain.platform.type.MemberType; +import com.wcc.platform.domain.platform.type.RoleType; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class UserAccountTest { + + @Test + void testGetPermissions_withMultipleRoles_returnsAggregatedPermissions() { + UserAccount userAccount = + UserAccount.builder() + .id(1) + .email("user@example.com") + .roles(List.of(RoleType.ADMIN, RoleType.MENTOR)) + .enabled(true) + .build(); + + Set permissions = userAccount.getPermissions(); + + assertNotNull(permissions); + assertFalse(permissions.isEmpty()); + } + + @Test + void testGetPermissions_withSingleRole_returnsRolePermissions() { + UserAccount userAccount = + UserAccount.builder() + .id(1) + .email("user@example.com") + .roles(List.of(RoleType.ADMIN)) + .enabled(true) + .build(); + + Set permissions = userAccount.getPermissions(); + + assertNotNull(permissions); + assertFalse(permissions.isEmpty()); + assertEquals(RoleType.ADMIN.getPermissions(), permissions); + } + + @Test + void testGetPermissions_withNullRoles_returnsEmptySet() { + UserAccount userAccount = + UserAccount.builder().id(1).email("user@example.com").roles(null).enabled(true).build(); + + Set permissions = userAccount.getPermissions(); + + assertNotNull(permissions); + assertTrue(permissions.isEmpty()); + } + + @Test + void testGetPrimaryRole_memberWithSingleType_returnsCorrespondingRole() { + Member member = + Member.builder() + .id(1L) + .fullName("John Doe") + .memberTypes(List.of(MemberType.MENTOR)) + .build(); + + UserAccount userAccount = + UserAccount.builder().id(1).memberId(1L).email("john@example.com").roles(List.of()).build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + + assertEquals(RoleType.MENTOR, user.getPrimaryRole()); + } + + @Test + void testGetPrimaryRole_memberWithMultipleTypes_returnsHighestPrivilegeRole() { + // DIRECTOR is higher privilege than MENTEE + Member member = + Member.builder() + .id(1L) + .fullName("John Doe") + .memberTypes(List.of(MemberType.DIRECTOR, MemberType.MENTEE)) + .build(); + + UserAccount userAccount = + UserAccount.builder().id(1).memberId(1L).email("john@example.com").roles(List.of()).build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + + assertEquals(RoleType.ADMIN, user.getPrimaryRole()); + } + + @Test + void testGetAllMemberRoles_memberWithSingleType_returnsSingleRole() { + Member member = + Member.builder() + .id(1L) + .fullName("John Doe") + .memberTypes(List.of(MemberType.MENTOR)) + .build(); + + UserAccount userAccount = + UserAccount.builder().id(1).memberId(1L).email("john@example.com").roles(List.of()).build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + Set roles = user.getAllMemberRoles(); + + assertEquals(1, roles.size()); + assertTrue(roles.contains(RoleType.MENTOR)); + } + + @Test + void testGetAllMemberRoles_memberWithMultipleTypes_returnsMultipleRoles() { + Member member = + Member.builder() + .id(1L) + .fullName("John Doe") + .memberTypes(List.of(MemberType.MENTOR, MemberType.COLLABORATOR)) + .build(); + + UserAccount userAccount = + UserAccount.builder().id(1).memberId(1L).email("john@example.com").roles(List.of()).build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + Set roles = user.getAllMemberRoles(); + + assertEquals(2, roles.size()); + assertTrue(roles.contains(RoleType.MENTOR)); + assertTrue(roles.contains(RoleType.CONTRIBUTOR)); + } + + @Test + void testGetAllRoles_onlyMemberRoles_returnsOnlyMemberRoles() { + Member member = + Member.builder() + .id(1L) + .fullName("John Doe") + .memberTypes(List.of(MemberType.MENTOR)) + .build(); + + UserAccount userAccount = + UserAccount.builder().id(1).memberId(1L).email("john@example.com").roles(List.of()).build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + Set roles = user.getAllRoles(); + + assertEquals(1, roles.size()); + assertTrue(roles.contains(RoleType.MENTOR)); + } + + @Test + void testGetAllRoles_onlyUserRoles_returnsOnlyUserRoles() { + Member member = Member.builder().id(1L).fullName("John Doe").memberTypes(List.of()).build(); + + UserAccount userAccount = + UserAccount.builder() + .id(1) + .memberId(1L) + .email("john@example.com") + .roles(List.of(RoleType.LEADER)) + .build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + Set roles = user.getAllRoles(); + + assertEquals(2, roles.size()); + assertTrue(roles.contains(RoleType.LEADER)); + } + + @Test + void testGetAllRoles_combinedMemberAndUserRoles_returnsAggregatedRoles() { + Member member = + Member.builder() + .id(1L) + .fullName("John Doe") + .memberTypes(List.of(MemberType.MENTOR)) + .build(); + + UserAccount userAccount = + UserAccount.builder() + .id(1) + .memberId(1L) + .email("john@example.com") + .roles(List.of(RoleType.ADMIN)) + .build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + Set roles = user.getAllRoles(); + + assertEquals(2, roles.size()); + assertTrue(roles.contains(RoleType.MENTOR)); + assertTrue(roles.contains(RoleType.ADMIN)); + } + + @Test + void testGetAllPermissions_fromMemberTypes_includesMemberTypePermissions() { + Member member = + Member.builder() + .id(1L) + .fullName("John Doe") + .memberTypes(List.of(MemberType.MENTOR)) + .build(); + + UserAccount userAccount = + UserAccount.builder().id(1).memberId(1L).email("john@example.com").roles(List.of()).build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + Set permissions = user.getAllPermissions(); + + assertNotNull(permissions); + assertFalse(permissions.isEmpty()); + } + + @Test + void testGetAllPermissions_fromUserRoles_includesUserRolePermissions() { + Member member = Member.builder().id(1L).fullName("John Doe").memberTypes(List.of()).build(); + + UserAccount userAccount = + UserAccount.builder() + .id(1) + .memberId(1L) + .email("john@example.com") + .roles(List.of(RoleType.ADMIN)) + .build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + Set permissions = user.getAllPermissions(); + + assertNotNull(permissions); + assertFalse(permissions.isEmpty()); + } + + @Test + void testGetAllPermissions_combinedPermissions_aggregatesFromBothSources() { + Member member = + Member.builder() + .id(1L) + .fullName("John Doe") + .memberTypes(List.of(MemberType.MENTOR)) + .build(); + + UserAccount userAccount = + UserAccount.builder() + .id(1) + .memberId(1L) + .email("john@example.com") + .roles(List.of(RoleType.ADMIN)) + .build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + Set permissions = user.getAllPermissions(); + + assertNotNull(permissions); + assertFalse(permissions.isEmpty()); + // Should include permissions from both MENTOR role and ADMIN role + assertTrue(permissions.size() >= 2); + } + + @Test + void testHasAnyRole_userHasOneOfRequiredRoles_returnsTrue() { + Member member = + Member.builder() + .id(1L) + .fullName("John Doe") + .memberTypes(List.of(MemberType.MENTOR)) + .build(); + + UserAccount userAccount = + UserAccount.builder() + .id(1) + .memberId(1L) + .email("john@example.com") + .roles(List.of(RoleType.ADMIN)) + .build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + + assertTrue(user.hasAnyRole(RoleType.ADMIN, RoleType.VIEWER)); + } + + @Test + void testHasAnyRole_userHasMultipleRequiredRoles_returnsTrue() { + Member member = + Member.builder() + .id(1L) + .fullName("John Doe") + .memberTypes(List.of(MemberType.MENTOR)) + .build(); + + UserAccount userAccount = + UserAccount.builder() + .id(1) + .memberId(1L) + .email("john@example.com") + .roles(List.of(RoleType.ADMIN)) + .build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + + assertTrue(user.hasAnyRole(RoleType.ADMIN, RoleType.MENTOR)); + } + + @Test + void testHasAnyRole_userHasNoneOfRequiredRoles_returnsFalse() { + Member member = + Member.builder() + .id(1L) + .fullName("John Doe") + .memberTypes(List.of(MemberType.MENTEE)) + .build(); + + UserAccount userAccount = + UserAccount.builder().id(1).memberId(1L).email("john@example.com").roles(List.of()).build(); + + UserAccount.User user = new UserAccount.User(userAccount, member); + + assertFalse(user.hasAnyRole(RoleType.ADMIN, RoleType.MENTOR)); + } +} diff --git a/src/test/java/com/wcc/platform/service/AuthServiceTest.java b/src/test/java/com/wcc/platform/service/AuthServiceTest.java new file mode 100644 index 00000000..07e82508 --- /dev/null +++ b/src/test/java/com/wcc/platform/service/AuthServiceTest.java @@ -0,0 +1,592 @@ +package com.wcc.platform.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.wcc.platform.domain.auth.Permission; +import com.wcc.platform.domain.auth.UserAccount; +import com.wcc.platform.domain.auth.UserToken; +import com.wcc.platform.domain.exceptions.ForbiddenException; +import com.wcc.platform.domain.platform.member.Member; +import com.wcc.platform.domain.platform.type.MemberType; +import com.wcc.platform.domain.platform.type.RoleType; +import com.wcc.platform.repository.MemberRepository; +import com.wcc.platform.repository.UserAccountRepository; +import com.wcc.platform.repository.UserTokenRepository; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +class AuthServiceTest { + + @Mock private UserAccountRepository userAccountRepository; + @Mock private UserTokenRepository userTokenRepository; + @Mock private MemberRepository memberRepository; + @Mock private PasswordEncoder passwordEncoder; + @Mock private SecurityContext securityContext; + @Mock private Authentication authentication; + + private AuthService authService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + authService = + new AuthService( + userAccountRepository, userTokenRepository, memberRepository, passwordEncoder); + ReflectionTestUtils.setField(authService, "tokenTtlMinutes", 60); + } + + // ==================== findUserByEmail Tests ==================== + + @Test + void testFindUserByEmail_userExists_returnsUserAccount() { + var email = "user@example.com"; + UserAccount userAccount = + UserAccount.builder() + .id(1) + .email(email) + .memberId(1L) + .enabled(true) + .roles(List.of(RoleType.ADMIN)) + .build(); + + when(userAccountRepository.findByEmail(email)).thenReturn(Optional.of(userAccount)); + + Optional result = authService.findUserByEmail(email); + + assertTrue(result.isPresent()); + assertEquals(email, result.get().getEmail()); + verify(userAccountRepository).findByEmail(email); + } + + @Test + void testFindUserByEmail_userNotFound_returnsEmpty() { + var email = "notfound@example.com"; + when(userAccountRepository.findByEmail(email)).thenReturn(Optional.empty()); + + Optional result = authService.findUserByEmail(email); + + assertTrue(result.isEmpty()); + } + + // ==================== getMember Tests ==================== + + @Test + void testGetMember_memberExists_returnsMemberDto() { + Long memberId = 1L; + Member member = + Member.builder().id(memberId).fullName("John Doe").email("john@example.com").build(); + + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + + var result = authService.getMember(memberId); + + assertNotNull(result); + assertEquals("John Doe", result.getFullName()); + } + + @Test + void testGetMember_memberNotFound_returnsNull() { + Long memberId = 999L; + when(memberRepository.findById(memberId)).thenReturn(Optional.empty()); + + var result = authService.getMember(memberId); + + assertNull(result); + } + + @Test + void testGetMember_nullId_returnsNull() { + var result = authService.getMember(null); + + assertNull(result); + verify(memberRepository, never()).findById(any()); + } + + // ==================== authenticateAndIssueToken Tests ==================== + + @Test + void testAuthenticateAndIssueToken_validCredentials_returnsToken() { + String email = "user@example.com"; + String password = "password123"; + String passwordHash = "hashed_password"; + + UserAccount userAccount = + UserAccount.builder() + .id(1) + .email(email) + .passwordHash(passwordHash) + .enabled(true) + .roles(List.of(RoleType.ADMIN)) + .build(); + + when(userAccountRepository.findByEmail(email)).thenReturn(Optional.of(userAccount)); + when(passwordEncoder.matches(password, passwordHash)).thenReturn(true); + when(userTokenRepository.create(any(UserToken.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + Optional result = authService.authenticateAndIssueToken(email, password); + + assertTrue(result.isPresent()); + assertEquals(1, result.get().getUserId()); + assertFalse(result.get().isRevoked()); + } + + @Test + void testAuthenticateAndIssueToken_userNotFound_returnsEmpty() { + String email = "notfound@example.com"; + String password = "password123"; + + when(userAccountRepository.findByEmail(email)).thenReturn(Optional.empty()); + + Optional result = authService.authenticateAndIssueToken(email, password); + + assertTrue(result.isEmpty()); + } + + @Test + void testAuthenticateAndIssueToken_userDisabled_returnsEmpty() { + String email = "user@example.com"; + String password = "password123"; + + UserAccount userAccount = + UserAccount.builder().id(1).email(email).passwordHash("hash").enabled(false).build(); + + when(userAccountRepository.findByEmail(email)).thenReturn(Optional.of(userAccount)); + + Optional result = authService.authenticateAndIssueToken(email, password); + + assertTrue(result.isEmpty()); + } + + @Test + void testAuthenticateAndIssueToken_invalidPassword_returnsEmpty() { + String email = "user@example.com"; + String password = "wrongpassword"; + String passwordHash = "hashed_password"; + + UserAccount userAccount = + UserAccount.builder().id(1).email(email).passwordHash(passwordHash).enabled(true).build(); + + when(userAccountRepository.findByEmail(email)).thenReturn(Optional.of(userAccount)); + when(passwordEncoder.matches(password, passwordHash)).thenReturn(false); + + Optional result = authService.authenticateAndIssueToken(email, password); + + assertTrue(result.isEmpty()); + } + + // ==================== authenticateByToken Tests ==================== + + @Test + void testAuthenticateByToken_validToken_returnsUserAccount() { + String token = "valid-token"; + Integer userId = 1; + + UserToken userToken = + UserToken.builder() + .token(token) + .userId(userId) + .issuedAt(OffsetDateTime.now()) + .expiresAt(OffsetDateTime.now().plusHours(1)) + .revoked(false) + .build(); + + UserAccount userAccount = + UserAccount.builder() + .id(userId) + .email("user@example.com") + .enabled(true) + .roles(List.of(RoleType.ADMIN)) + .build(); + + when(userTokenRepository.findValidByToken(eq(token), any(OffsetDateTime.class))) + .thenReturn(Optional.of(userToken)); + when(userAccountRepository.findById(userId)).thenReturn(Optional.of(userAccount)); + + Optional result = authService.authenticateByToken(token); + + assertTrue(result.isPresent()); + assertEquals("user@example.com", result.get().getEmail()); + } + + @Test + void testAuthenticateByToken_invalidToken_returnsEmpty() { + String token = "invalid-token"; + + when(userTokenRepository.findValidByToken(eq(token), any(OffsetDateTime.class))) + .thenReturn(Optional.empty()); + + Optional result = authService.authenticateByToken(token); + + assertTrue(result.isEmpty()); + } + + @Test + void testAuthenticateByToken_userNotFound_returnsEmpty() { + String token = "valid-token"; + Integer userId = 999; + + UserToken userToken = + UserToken.builder() + .token(token) + .userId(userId) + .issuedAt(OffsetDateTime.now()) + .expiresAt(OffsetDateTime.now().plusHours(1)) + .revoked(false) + .build(); + + when(userTokenRepository.findValidByToken(eq(token), any(OffsetDateTime.class))) + .thenReturn(Optional.of(userToken)); + when(userAccountRepository.findById(userId)).thenReturn(Optional.empty()); + + Optional result = authService.authenticateByToken(token); + + assertTrue(result.isEmpty()); + } + + // ==================== getUserWithMember Tests ==================== + + @Test + void testGetUserWithMember_userAndMemberExist_returnsUser() { + Integer userId = 1; + Long memberId = 1L; + + UserAccount userAccount = + UserAccount.builder() + .id(userId) + .memberId(memberId) + .email("user@example.com") + .roles(List.of(RoleType.ADMIN)) + .build(); + + Member member = + Member.builder() + .id(memberId) + .fullName("John Doe") + .memberTypes(List.of(MemberType.LEADER)) + .build(); + + when(userAccountRepository.findById(userId)).thenReturn(Optional.of(userAccount)); + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + + Optional result = authService.getUserWithMember(userId); + + assertTrue(result.isPresent()); + assertEquals("user@example.com", result.get().userAccount().getEmail()); + assertEquals("John Doe", result.get().member().getFullName()); + } + + @Test + void testGetUserWithMember_userHasNoMemberId_returnsEmpty() { + Integer userId = 1; + + UserAccount userAccount = UserAccount.builder().id(userId).memberId(null).build(); + + when(userAccountRepository.findById(userId)).thenReturn(Optional.of(userAccount)); + + Optional result = authService.getUserWithMember(userId); + + assertTrue(result.isEmpty()); + } + + // ==================== authenticateByTokenWithMember Tests ==================== + + @Test + void testAuthenticateByTokenWithMember_validToken_returnsUserWithMember() { + String token = "valid-token"; + Integer userId = 1; + Long memberId = 1L; + + UserToken userToken = + UserToken.builder() + .token(token) + .userId(userId) + .issuedAt(OffsetDateTime.now()) + .expiresAt(OffsetDateTime.now().plusHours(1)) + .revoked(false) + .build(); + + UserAccount userAccount = + UserAccount.builder() + .id(userId) + .memberId(memberId) + .email("user@example.com") + .roles(List.of(RoleType.ADMIN)) + .build(); + + Member member = Member.builder().id(memberId).fullName("John Doe").build(); + + when(userTokenRepository.findValidByToken(eq(token), any())).thenReturn(Optional.of(userToken)); + when(userAccountRepository.findById(userId)).thenReturn(Optional.of(userAccount)); + when(memberRepository.findById(memberId)).thenReturn(Optional.of(member)); + + Optional result = authService.authenticateByTokenWithMember(token); + + assertTrue(result.isPresent()); + assertEquals("user@example.com", result.get().userAccount().getEmail()); + } + + @Test + void testAuthenticateByTokenWithMember_invalidToken_returnsEmpty() { + var token = "invalid-token"; + + when(userTokenRepository.findValidByToken(eq(token), any(OffsetDateTime.class))) + .thenReturn(Optional.empty()); + + Optional result = authService.authenticateByTokenWithMember(token); + + assertTrue(result.isEmpty()); + } + + // ==================== getCurrentUser Tests ==================== + + @Test + void testGetCurrentUser_validAuthentication_returnsUser() { + var userId = 1; + var memberId = 1L; + + UserAccount userAccount = + UserAccount.builder() + .id(userId) + .memberId(memberId) + .email("user@example.com") + .roles(List.of(RoleType.ADMIN)) + .build(); + + Member member = Member.builder().id(memberId).fullName("John Doe").build(); + UserAccount.User user = new UserAccount.User(userAccount, member); + + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn(user); + + UserAccount.User result = authService.getCurrentUser(); + + assertEquals("user@example.com", result.userAccount().getEmail()); + } + + @Test + void testGetCurrentUser_notAuthenticated_throwsForbiddenException() { + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(null); + + assertThrows(ForbiddenException.class, () -> authService.getCurrentUser()); + } + + @Test + void testGetCurrentUser_invalidPrincipal_throwsForbiddenException() { + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn("invalid-principal"); + + assertThrows(ForbiddenException.class, () -> authService.getCurrentUser()); + } + + // ==================== requireAnyPermission Tests ==================== + + @Test + void testRequireAnyPermission_userHasPermission_succeeds() { + Integer userId = 1; + Long memberId = 1L; + + UserAccount userAccount = + UserAccount.builder() + .id(userId) + .memberId(memberId) + .email("user@example.com") + .roles(List.of(RoleType.ADMIN)) + .build(); + + Member member = Member.builder().id(memberId).fullName("John Doe").build(); + UserAccount.User user = new UserAccount.User(userAccount, member); + + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn(user); + + // ADMIN role has MENTOR_APPROVE permission + authService.requireAnyPermission(Permission.MENTOR_APPROVE); + + // Should not throw + } + + @Test + void testRequireAnyPermission_userLacksPermission_throwsForbiddenException() { + Integer userId = 1; + Long memberId = 1L; + + UserAccount userAccount = + UserAccount.builder() + .id(userId) + .memberId(memberId) + .email("user@example.com") + .roles(List.of(RoleType.VIEWER)) + .build(); + + Member member = Member.builder().id(memberId).fullName("John Doe").build(); + UserAccount.User user = new UserAccount.User(userAccount, member); + + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn(user); + + assertThrows( + ForbiddenException.class, + () -> authService.requireAnyPermission(Permission.MENTOR_APPROVE)); + } + + // ==================== requireAllPermissions Tests ==================== + + @Test + void testRequireAllPermissions_userHasAllPermissions_succeeds() { + Integer userId = 1; + Long memberId = 1L; + + UserAccount userAccount = + UserAccount.builder() + .id(userId) + .memberId(memberId) + .email("user@example.com") + .roles(List.of(RoleType.ADMIN)) + .build(); + + Member member = Member.builder().id(memberId).fullName("John Doe").build(); + UserAccount.User user = new UserAccount.User(userAccount, member); + + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn(user); + + // ADMIN has both permissions + authService.requireAllPermissions(Permission.MENTOR_APPROVE, Permission.MENTEE_APPROVE); + + // Should not throw + } + + @Test + void testRequireAllPermissions_userMissesOnePermission_throwsForbiddenException() { + Integer userId = 1; + Long memberId = 1L; + + UserAccount userAccount = + UserAccount.builder() + .id(userId) + .memberId(memberId) + .email("user@example.com") + .roles(List.of(RoleType.VIEWER)) + .build(); + + Member member = Member.builder().id(memberId).fullName("John Doe").build(); + UserAccount.User user = new UserAccount.User(userAccount, member); + + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn(user); + + assertThrows( + ForbiddenException.class, + () -> + authService.requireAllPermissions( + Permission.MENTOR_APPROVE, Permission.MENTEE_APPROVE)); + } + + // ==================== requireRole Tests ==================== + + @Test + void testRequireRole_userHasRequiredRole_succeeds() { + Integer userId = 1; + Long memberId = 1L; + + UserAccount userAccount = + UserAccount.builder() + .id(userId) + .memberId(memberId) + .email("user@example.com") + .roles(List.of(RoleType.ADMIN)) + .build(); + + Member member = Member.builder().id(memberId).fullName("John Doe").build(); + UserAccount.User user = new UserAccount.User(userAccount, member); + + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn(user); + + authService.requireRole(RoleType.ADMIN); + } + + @Test + void testRequireRole_userLacksRequiredRole_throwsForbiddenException() { + Integer userId = 1; + Long memberId = 1L; + + UserAccount userAccount = + UserAccount.builder() + .id(userId) + .memberId(memberId) + .email("user@example.com") + .roles(List.of(RoleType.VIEWER)) + .build(); + + Member member = Member.builder().id(memberId).fullName("John Doe").build(); + UserAccount.User user = new UserAccount.User(userAccount, member); + + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn(user); + + assertThrows(ForbiddenException.class, () -> authService.requireRole(RoleType.ADMIN)); + } + + @Test + void testRequireRole_userHasAnyOfMultipleRoles_succeeds() { + Integer userId = 1; + Long memberId = 1L; + + UserAccount userAccount = + UserAccount.builder() + .id(userId) + .memberId(memberId) + .email("user@example.com") + .roles(List.of(RoleType.MENTOR)) + .build(); + + Member member = Member.builder().id(memberId).fullName("John Doe").build(); + UserAccount.User user = new UserAccount.User(userAccount, member); + + SecurityContextHolder.setContext(securityContext); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn(user); + + authService.requireRole(RoleType.ADMIN, RoleType.MENTOR); + } +}