mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:19:35 +01:00
refactor(usermanagement): implement code review findings for User Management BC
Address all 18 findings from security code review (5 critical, 7 medium, 6 low): Domain: make User and Role immutable with wither-pattern, add status transition guards (ACTIVE->LOCKED, LOCKED->ACTIVE, ACTIVE|LOCKED->INACTIVE, INACTIVE->ACTIVE) Application: enforce authorization via AuthorizationPort in all use cases, add input validation, introduce LockUserCommand/UnlockUserCommand/RemoveRoleCommand, fix audit event on password change failure (K5), use flatMap/mapError chains Infrastructure: JWT blacklist with TTL and scheduled cleanup, login rate limiting (5 attempts/15min), configurable CORS, generic error messages, conditional Swagger, seed data context restriction Tests: unit tests for all 10 use cases, adapted domain and integration tests
This commit is contained in:
parent
a1161cfbad
commit
05878b1ce9
45 changed files with 1989 additions and 2207 deletions
|
|
@ -4,6 +4,7 @@ import de.effigenix.infrastructure.config.DatabaseProfileInitializer;
|
|||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* Main Application Class for Effigenix ERP System.
|
||||
|
|
@ -16,6 +17,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
|
|||
*/
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class EffigenixApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
|
|
|||
|
|
@ -6,12 +6,11 @@ import de.effigenix.domain.usermanagement.*;
|
|||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Assign a role to a user.
|
||||
*/
|
||||
|
|
@ -21,60 +20,61 @@ public class AssignRole {
|
|||
private final UserRepository userRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final AuditLogger auditLogger;
|
||||
private final AuthorizationPort authPort;
|
||||
|
||||
public AssignRole(
|
||||
UserRepository userRepository,
|
||||
RoleRepository roleRepository,
|
||||
AuditLogger auditLogger
|
||||
AuditLogger auditLogger,
|
||||
AuthorizationPort authPort
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.auditLogger = auditLogger;
|
||||
this.authPort = authPort;
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(AssignRoleCommand cmd, ActorId performedBy) {
|
||||
// 1. Find user
|
||||
UserId userId = UserId.of(cmd.userId());
|
||||
User user;
|
||||
switch (userRepository.findById(userId)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.UserNotFound(userId));
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
// 0. Authorization
|
||||
if (!authPort.can(performedBy, UserManagementAction.ROLE_ASSIGN)) {
|
||||
return Result.failure(new UserError.Unauthorized("Not authorized to assign roles"));
|
||||
}
|
||||
|
||||
// 2. Find role
|
||||
Role role;
|
||||
switch (roleRepository.findByName(cmd.roleName())) {
|
||||
case Failure<RepositoryError, Optional<Role>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<Role>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
// 1. Input validation
|
||||
if (cmd.userId() == null || cmd.userId().isBlank()) {
|
||||
return Result.failure(new UserError.InvalidInput("User ID must not be blank"));
|
||||
}
|
||||
if (cmd.roleName() == null) {
|
||||
return Result.failure(new UserError.InvalidInput("Role name must not be null"));
|
||||
}
|
||||
|
||||
UserId userId = UserId.of(cmd.userId());
|
||||
return findUser(userId).flatMap(user -> findRoleAndAssign(user, cmd, performedBy));
|
||||
}
|
||||
|
||||
private Result<UserError, UserDTO> findRoleAndAssign(User user, AssignRoleCommand cmd, ActorId performedBy) {
|
||||
return roleRepository.findByName(cmd.roleName())
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.flatMap(optRole -> {
|
||||
if (optRole.isEmpty()) {
|
||||
return Result.failure(new UserError.RoleNotFound(cmd.roleName()));
|
||||
}
|
||||
role = s.value().get();
|
||||
}
|
||||
Role role = optRole.get();
|
||||
return user.assignRole(role)
|
||||
.flatMap(updated -> userRepository.save(updated)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.map(ignored -> {
|
||||
auditLogger.log(AuditEvent.ROLE_ASSIGNED, updated.id().value(), "Role: " + role.name(), performedBy);
|
||||
return UserDTO.from(updated);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Assign role
|
||||
switch (user.assignRole(role)) {
|
||||
case Failure<UserError, Void> f -> { return Result.failure(f.error()); }
|
||||
case Success<UserError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
// 4. Audit log
|
||||
auditLogger.log(AuditEvent.ROLE_ASSIGNED, userId.value(), "Role: " + role.name(), performedBy);
|
||||
|
||||
return Result.success(UserDTO.from(user));
|
||||
private Result<UserError, User> findUser(UserId userId) {
|
||||
return userRepository.findById(userId)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.flatMap(opt -> opt
|
||||
.map(Result::<UserError, User>success)
|
||||
.orElse(Result.failure(new UserError.UserNotFound(userId))));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public enum AuditEvent {
|
|||
ROLE_REMOVED,
|
||||
|
||||
PASSWORD_CHANGED,
|
||||
PASSWORD_CHANGE_FAILED,
|
||||
PASSWORD_RESET,
|
||||
|
||||
LOGIN_SUCCESS,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package de.effigenix.application.usermanagement;
|
|||
import de.effigenix.application.usermanagement.command.AuthenticateCommand;
|
||||
import de.effigenix.application.usermanagement.dto.SessionToken;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
|
@ -11,8 +10,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Authenticate a user (login).
|
||||
*
|
||||
|
|
@ -41,19 +38,18 @@ public class AuthenticateUser {
|
|||
|
||||
public Result<UserError, SessionToken> execute(AuthenticateCommand cmd) {
|
||||
// 1. Find user by username
|
||||
User user;
|
||||
switch (userRepository.findByUsername(cmd.username())) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return userRepository.findByUsername(cmd.username())
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.flatMap(optUser -> {
|
||||
if (optUser.isEmpty()) {
|
||||
auditLogger.log(AuditEvent.LOGIN_FAILED, "Username not found: " + cmd.username());
|
||||
return Result.failure(new UserError.InvalidCredentials());
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
return authenticateUser(optUser.get(), cmd);
|
||||
});
|
||||
}
|
||||
|
||||
private Result<UserError, SessionToken> authenticateUser(User user, AuthenticateCommand cmd) {
|
||||
// 2. Check user status
|
||||
if (user.status() == UserStatus.LOCKED) {
|
||||
auditLogger.log(AuditEvent.LOGIN_BLOCKED, user.id().value(), ActorId.of(user.id().value()));
|
||||
|
|
@ -74,17 +70,13 @@ public class AuthenticateUser {
|
|||
// 4. Create JWT session
|
||||
SessionToken token = sessionManager.createSession(user);
|
||||
|
||||
// 5. Update last login timestamp
|
||||
user.updateLastLogin(LocalDateTime.now());
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
// 6. Audit log
|
||||
auditLogger.log(AuditEvent.LOGIN_SUCCESS, user.id().value(), ActorId.of(user.id().value()));
|
||||
|
||||
return Result.success(token);
|
||||
// 5. Update last login timestamp (immutable)
|
||||
return user.withLastLogin(LocalDateTime.now())
|
||||
.flatMap(updated -> userRepository.save(updated)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.map(ignored -> {
|
||||
auditLogger.log(AuditEvent.LOGIN_SUCCESS, updated.id().value(), ActorId.of(updated.id().value()));
|
||||
return token;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,16 @@ package de.effigenix.application.usermanagement;
|
|||
|
||||
import de.effigenix.application.usermanagement.command.ChangePasswordCommand;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Change user password.
|
||||
*
|
||||
* Requires current password verification for security.
|
||||
* Self-service: users can change their own password without PASSWORD_CHANGE permission.
|
||||
*/
|
||||
@Transactional
|
||||
public class ChangePassword {
|
||||
|
|
@ -22,61 +19,71 @@ public class ChangePassword {
|
|||
private final UserRepository userRepository;
|
||||
private final PasswordHasher passwordHasher;
|
||||
private final AuditLogger auditLogger;
|
||||
private final AuthorizationPort authPort;
|
||||
|
||||
public ChangePassword(
|
||||
UserRepository userRepository,
|
||||
PasswordHasher passwordHasher,
|
||||
AuditLogger auditLogger
|
||||
AuditLogger auditLogger,
|
||||
AuthorizationPort authPort
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordHasher = passwordHasher;
|
||||
this.auditLogger = auditLogger;
|
||||
this.authPort = authPort;
|
||||
}
|
||||
|
||||
public Result<UserError, Void> execute(ChangePasswordCommand cmd, ActorId performedBy) {
|
||||
// 1. Find user
|
||||
UserId userId = UserId.of(cmd.userId());
|
||||
User user;
|
||||
switch (userRepository.findById(userId)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.UserNotFound(userId));
|
||||
// 0. Input validation
|
||||
if (cmd.userId() == null || cmd.userId().isBlank()) {
|
||||
return Result.failure(new UserError.InvalidInput("User ID must not be blank"));
|
||||
}
|
||||
user = s.value().get();
|
||||
if (cmd.currentPassword() == null || cmd.currentPassword().isBlank()) {
|
||||
return Result.failure(new UserError.InvalidInput("Current password must not be blank"));
|
||||
}
|
||||
if (cmd.newPassword() == null || cmd.newPassword().isBlank()) {
|
||||
return Result.failure(new UserError.InvalidInput("New password must not be blank"));
|
||||
}
|
||||
|
||||
// 2. Verify current password
|
||||
// 1. Authorization: self-service allowed, otherwise need PASSWORD_CHANGE permission
|
||||
boolean isSelfService = performedBy.value().equals(cmd.userId());
|
||||
if (!isSelfService && !authPort.can(performedBy, UserManagementAction.PASSWORD_CHANGE)) {
|
||||
return Result.failure(new UserError.Unauthorized("Not authorized to change password for other users"));
|
||||
}
|
||||
|
||||
// 2. Find user
|
||||
UserId userId = UserId.of(cmd.userId());
|
||||
return findUser(userId).flatMap(user -> changeUserPassword(user, cmd, performedBy));
|
||||
}
|
||||
|
||||
private Result<UserError, Void> changeUserPassword(User user, ChangePasswordCommand cmd, ActorId performedBy) {
|
||||
// 3. Verify current password
|
||||
if (!passwordHasher.verify(cmd.currentPassword(), user.passwordHash())) {
|
||||
auditLogger.log(AuditEvent.PASSWORD_CHANGED, userId.value(), performedBy);
|
||||
auditLogger.log(AuditEvent.PASSWORD_CHANGE_FAILED, user.id().value(), performedBy);
|
||||
return Result.failure(new UserError.InvalidCredentials());
|
||||
}
|
||||
|
||||
// 3. Validate new password
|
||||
// 4. Validate new password
|
||||
if (!passwordHasher.isValidPassword(cmd.newPassword())) {
|
||||
return Result.failure(new UserError.InvalidPassword("Password must be at least 8 characters and contain uppercase, lowercase, digit, and special character"));
|
||||
}
|
||||
|
||||
// 4. Hash new password
|
||||
// 5. Hash and update (immutable)
|
||||
PasswordHash newPasswordHash = passwordHasher.hash(cmd.newPassword());
|
||||
|
||||
// 5. Update user
|
||||
switch (user.changePassword(newPasswordHash)) {
|
||||
case Failure<UserError, Void> f -> { return Result.failure(f.error()); }
|
||||
case Success<UserError, Void> ignored -> { }
|
||||
return user.changePassword(newPasswordHash)
|
||||
.flatMap(updated -> userRepository.save(updated)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.map(ignored -> {
|
||||
auditLogger.log(AuditEvent.PASSWORD_CHANGED, updated.id().value(), performedBy);
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
// 6. Audit log
|
||||
auditLogger.log(AuditEvent.PASSWORD_CHANGED, user.id().value(), performedBy);
|
||||
|
||||
return Result.success(null);
|
||||
private Result<UserError, User> findUser(UserId userId) {
|
||||
return userRepository.findById(userId)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.flatMap(opt -> opt
|
||||
.map(Result::<UserError, User>success)
|
||||
.orElse(Result.failure(new UserError.UserNotFound(userId))));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,20 +6,14 @@ import de.effigenix.domain.usermanagement.*;
|
|||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Create a new user account.
|
||||
*
|
||||
* Transaction Script Pattern (Generic Subdomain):
|
||||
* - Simple procedural logic
|
||||
* - No complex domain model
|
||||
* - Direct repository interaction
|
||||
*/
|
||||
@Transactional
|
||||
public class CreateUser {
|
||||
|
|
@ -28,57 +22,74 @@ public class CreateUser {
|
|||
private final RoleRepository roleRepository;
|
||||
private final PasswordHasher passwordHasher;
|
||||
private final AuditLogger auditLogger;
|
||||
private final AuthorizationPort authPort;
|
||||
|
||||
public CreateUser(
|
||||
UserRepository userRepository,
|
||||
RoleRepository roleRepository,
|
||||
PasswordHasher passwordHasher,
|
||||
AuditLogger auditLogger
|
||||
AuditLogger auditLogger,
|
||||
AuthorizationPort authPort
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.passwordHasher = passwordHasher;
|
||||
this.auditLogger = auditLogger;
|
||||
this.authPort = authPort;
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(CreateUserCommand cmd, ActorId performedBy) {
|
||||
// 1. Validate password
|
||||
// 0. Authorization
|
||||
if (!authPort.can(performedBy, UserManagementAction.USER_CREATE)) {
|
||||
return Result.failure(new UserError.Unauthorized("Not authorized to create users"));
|
||||
}
|
||||
|
||||
// 1. Input validation
|
||||
if (cmd.username() == null || cmd.username().isBlank()) {
|
||||
return Result.failure(new UserError.InvalidInput("Username must not be blank"));
|
||||
}
|
||||
if (cmd.email() == null || cmd.email().isBlank()) {
|
||||
return Result.failure(new UserError.InvalidInput("Email must not be blank"));
|
||||
}
|
||||
if (cmd.password() == null || cmd.password().isBlank()) {
|
||||
return Result.failure(new UserError.InvalidInput("Password must not be blank"));
|
||||
}
|
||||
if (cmd.roleNames() == null || cmd.roleNames().isEmpty()) {
|
||||
return Result.failure(new UserError.InvalidInput("At least one role must be specified"));
|
||||
}
|
||||
|
||||
// 2. Validate password
|
||||
if (!passwordHasher.isValidPassword(cmd.password())) {
|
||||
return Result.failure(new UserError.InvalidPassword("Password must be at least 8 characters and contain uppercase, lowercase, digit, and special character"));
|
||||
}
|
||||
|
||||
// 2. Check username uniqueness
|
||||
switch (userRepository.existsByUsername(cmd.username())) {
|
||||
case Failure<RepositoryError, Boolean> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Boolean> s -> {
|
||||
if (s.value()) {
|
||||
return Result.failure(new UserError.UsernameAlreadyExists(cmd.username()));
|
||||
}
|
||||
}
|
||||
// 3. Check username uniqueness
|
||||
return userRepository.existsByUsername(cmd.username())
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.flatMap(exists -> {
|
||||
if (exists) return Result.failure(new UserError.UsernameAlreadyExists(cmd.username()));
|
||||
return checkEmailAndCreate(cmd, performedBy);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Check email uniqueness
|
||||
switch (userRepository.existsByEmail(cmd.email())) {
|
||||
case Failure<RepositoryError, Boolean> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Boolean> s -> {
|
||||
if (s.value()) {
|
||||
return Result.failure(new UserError.EmailAlreadyExists(cmd.email()));
|
||||
}
|
||||
}
|
||||
private Result<UserError, UserDTO> checkEmailAndCreate(CreateUserCommand cmd, ActorId performedBy) {
|
||||
return userRepository.existsByEmail(cmd.email())
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.flatMap(exists -> {
|
||||
if (exists) return Result.failure(new UserError.EmailAlreadyExists(cmd.email()));
|
||||
return loadRolesAndCreate(cmd, performedBy);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Hash password (BCrypt)
|
||||
private Result<UserError, UserDTO> loadRolesAndCreate(CreateUserCommand cmd, ActorId performedBy) {
|
||||
PasswordHash passwordHash = passwordHasher.hash(cmd.password());
|
||||
|
||||
// 5. Load roles
|
||||
Set<Role> roles = new HashSet<>();
|
||||
for (RoleName roleName : cmd.roleNames()) {
|
||||
switch (roleRepository.findByName(roleName)) {
|
||||
case Failure<RepositoryError, java.util.Optional<Role>> f ->
|
||||
case Result.Failure<RepositoryError, java.util.Optional<Role>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, java.util.Optional<Role>> s -> {
|
||||
case Result.Success<RepositoryError, java.util.Optional<Role>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.RoleNotFound(roleName));
|
||||
}
|
||||
|
|
@ -87,25 +98,12 @@ public class CreateUser {
|
|||
}
|
||||
}
|
||||
|
||||
// 6. Create user entity (simple entity, not aggregate)
|
||||
switch (User.create(cmd.username(), cmd.email(), passwordHash, roles, cmd.branchId())) {
|
||||
case Failure<UserError, User> f ->
|
||||
{ return Result.failure(f.error()); }
|
||||
case Success<UserError, User> s -> {
|
||||
User user = s.value();
|
||||
|
||||
// 7. Save
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
}
|
||||
|
||||
// 8. Audit log (HACCP/GoBD compliance)
|
||||
return User.create(cmd.username(), cmd.email(), passwordHash, roles, cmd.branchId())
|
||||
.flatMap(user -> userRepository.save(user)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.map(ignored -> {
|
||||
auditLogger.log(AuditEvent.USER_CREATED, user.id().value(), performedBy);
|
||||
|
||||
return Result.success(UserDTO.from(user));
|
||||
}
|
||||
}
|
||||
return UserDTO.from(user);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,11 @@ package de.effigenix.application.usermanagement;
|
|||
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Get a single user by ID.
|
||||
*/
|
||||
|
|
@ -17,20 +14,24 @@ import static de.effigenix.shared.common.Result.*;
|
|||
public class GetUser {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final AuthorizationPort authPort;
|
||||
|
||||
public GetUser(UserRepository userRepository) {
|
||||
public GetUser(UserRepository userRepository, AuthorizationPort authPort) {
|
||||
this.userRepository = userRepository;
|
||||
this.authPort = authPort;
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(String userIdValue, ActorId performedBy) {
|
||||
// 0. Authorization
|
||||
if (!authPort.can(performedBy, UserManagementAction.USER_VIEW)) {
|
||||
return Result.failure(new UserError.Unauthorized("Not authorized to view users"));
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(String userIdValue) {
|
||||
UserId userId = UserId.of(userIdValue);
|
||||
return switch (userRepository.findById(userId)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
Result.failure(new UserError.RepositoryFailure(f.error().message()));
|
||||
case Success<RepositoryError, Optional<User>> s ->
|
||||
s.value()
|
||||
return userRepository.findById(userId)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.flatMap(opt -> opt
|
||||
.map(user -> Result.<UserError, UserDTO>success(UserDTO.from(user)))
|
||||
.orElse(Result.failure(new UserError.UserNotFound(userId)));
|
||||
};
|
||||
.orElse(Result.failure(new UserError.UserNotFound(userId))));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.domain.usermanagement.User;
|
||||
import de.effigenix.domain.usermanagement.UserError;
|
||||
import de.effigenix.domain.usermanagement.UserManagementAction;
|
||||
import de.effigenix.domain.usermanagement.UserRepository;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import de.effigenix.shared.security.BranchId;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: List all users (with optional branch filtering).
|
||||
*/
|
||||
|
|
@ -21,36 +20,36 @@ import static de.effigenix.shared.common.Result.*;
|
|||
public class ListUsers {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final AuthorizationPort authPort;
|
||||
|
||||
public ListUsers(UserRepository userRepository) {
|
||||
public ListUsers(UserRepository userRepository, AuthorizationPort authPort) {
|
||||
this.userRepository = userRepository;
|
||||
this.authPort = authPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all users (admin view).
|
||||
*/
|
||||
public Result<UserError, List<UserDTO>> execute() {
|
||||
return switch (userRepository.findAll()) {
|
||||
case Failure<RepositoryError, List<User>> f ->
|
||||
Result.failure(new UserError.RepositoryFailure(f.error().message()));
|
||||
case Success<RepositoryError, List<User>> s ->
|
||||
Result.success(s.value().stream()
|
||||
.map(UserDTO::from)
|
||||
.collect(Collectors.toList()));
|
||||
};
|
||||
public Result<UserError, List<UserDTO>> execute(ActorId performedBy) {
|
||||
if (!authPort.can(performedBy, UserManagementAction.USER_LIST)) {
|
||||
return Result.failure(new UserError.Unauthorized("Not authorized to list users"));
|
||||
}
|
||||
|
||||
return userRepository.findAll()
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.map(users -> users.stream().map(UserDTO::from).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists users for a specific branch (filtered view).
|
||||
*/
|
||||
public Result<UserError, List<UserDTO>> executeForBranch(BranchId branchId) {
|
||||
return switch (userRepository.findByBranchId(branchId.value())) {
|
||||
case Failure<RepositoryError, List<User>> f ->
|
||||
Result.failure(new UserError.RepositoryFailure(f.error().message()));
|
||||
case Success<RepositoryError, List<User>> s ->
|
||||
Result.success(s.value().stream()
|
||||
.map(UserDTO::from)
|
||||
.collect(Collectors.toList()));
|
||||
};
|
||||
public Result<UserError, List<UserDTO>> executeForBranch(BranchId branchId, ActorId performedBy) {
|
||||
if (!authPort.can(performedBy, UserManagementAction.USER_LIST)) {
|
||||
return Result.failure(new UserError.Unauthorized("Not authorized to list users"));
|
||||
}
|
||||
|
||||
return userRepository.findByBranchId(branchId.value())
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.map(users -> users.stream().map(UserDTO::from).collect(Collectors.toList()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.LockUserCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Lock a user account (prevent login).
|
||||
*/
|
||||
|
|
@ -19,36 +16,41 @@ public class LockUser {
|
|||
|
||||
private final UserRepository userRepository;
|
||||
private final AuditLogger auditLogger;
|
||||
private final AuthorizationPort authPort;
|
||||
|
||||
public LockUser(UserRepository userRepository, AuditLogger auditLogger) {
|
||||
public LockUser(UserRepository userRepository, AuditLogger auditLogger, AuthorizationPort authPort) {
|
||||
this.userRepository = userRepository;
|
||||
this.auditLogger = auditLogger;
|
||||
this.authPort = authPort;
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(String userIdValue, ActorId performedBy) {
|
||||
UserId userId = UserId.of(userIdValue);
|
||||
User user;
|
||||
switch (userRepository.findById(userId)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.UserNotFound(userId));
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
public Result<UserError, UserDTO> execute(LockUserCommand cmd, ActorId performedBy) {
|
||||
// 0. Authorization
|
||||
if (!authPort.can(performedBy, UserManagementAction.USER_LOCK)) {
|
||||
return Result.failure(new UserError.Unauthorized("Not authorized to lock users"));
|
||||
}
|
||||
|
||||
user.lock();
|
||||
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
// 1. Input validation
|
||||
if (cmd.userId() == null || cmd.userId().isBlank()) {
|
||||
return Result.failure(new UserError.InvalidInput("User ID must not be blank"));
|
||||
}
|
||||
|
||||
auditLogger.log(AuditEvent.USER_LOCKED, user.id().value(), performedBy);
|
||||
UserId userId = UserId.of(cmd.userId());
|
||||
return findUser(userId)
|
||||
.flatMap(User::lock)
|
||||
.flatMap(updated -> userRepository.save(updated)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.map(ignored -> {
|
||||
auditLogger.log(AuditEvent.USER_LOCKED, updated.id().value(), performedBy);
|
||||
return UserDTO.from(updated);
|
||||
}));
|
||||
}
|
||||
|
||||
return Result.success(UserDTO.from(user));
|
||||
private Result<UserError, User> findUser(UserId userId) {
|
||||
return userRepository.findById(userId)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.flatMap(opt -> opt
|
||||
.map(Result::<UserError, User>success)
|
||||
.orElse(Result.failure(new UserError.UserNotFound(userId))));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,15 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.RemoveRoleCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Remove a role from a user.
|
||||
*
|
||||
* Allows administrators to revoke roles from users.
|
||||
* Role removal is immediate and affects user's permissions.
|
||||
*/
|
||||
@Transactional
|
||||
public class RemoveRole {
|
||||
|
|
@ -23,65 +17,61 @@ public class RemoveRole {
|
|||
private final UserRepository userRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final AuditLogger auditLogger;
|
||||
private final AuthorizationPort authPort;
|
||||
|
||||
public RemoveRole(
|
||||
UserRepository userRepository,
|
||||
RoleRepository roleRepository,
|
||||
AuditLogger auditLogger
|
||||
AuditLogger auditLogger,
|
||||
AuthorizationPort authPort
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.auditLogger = auditLogger;
|
||||
this.authPort = authPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a role from a user.
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param roleName Role name to remove
|
||||
* @param performedBy Actor performing the action
|
||||
* @return Result with UserDTO or UserError
|
||||
*/
|
||||
public Result<UserError, UserDTO> execute(String userId, RoleName roleName, ActorId performedBy) {
|
||||
// 1. Find user
|
||||
UserId userIdObj = UserId.of(userId);
|
||||
User user;
|
||||
switch (userRepository.findById(userIdObj)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.UserNotFound(userIdObj));
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
public Result<UserError, UserDTO> execute(RemoveRoleCommand cmd, ActorId performedBy) {
|
||||
// 0. Authorization
|
||||
if (!authPort.can(performedBy, UserManagementAction.ROLE_REMOVE)) {
|
||||
return Result.failure(new UserError.Unauthorized("Not authorized to remove roles"));
|
||||
}
|
||||
|
||||
// 2. Find role
|
||||
Role role;
|
||||
switch (roleRepository.findByName(roleName)) {
|
||||
case Failure<RepositoryError, Optional<Role>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<Role>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.RoleNotFound(roleName));
|
||||
}
|
||||
role = s.value().get();
|
||||
// 1. Input validation
|
||||
if (cmd.userId() == null || cmd.userId().isBlank()) {
|
||||
return Result.failure(new UserError.InvalidInput("User ID must not be blank"));
|
||||
}
|
||||
if (cmd.roleName() == null) {
|
||||
return Result.failure(new UserError.InvalidInput("Role name must not be null"));
|
||||
}
|
||||
|
||||
// 3. Remove role
|
||||
user.removeRole(role);
|
||||
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
UserId userId = UserId.of(cmd.userId());
|
||||
return findUser(userId).flatMap(user -> findRoleAndRemove(user, cmd, performedBy));
|
||||
}
|
||||
|
||||
// 4. Audit log
|
||||
auditLogger.log(AuditEvent.ROLE_REMOVED, userId, "Role: " + roleName, performedBy);
|
||||
private Result<UserError, UserDTO> findRoleAndRemove(User user, RemoveRoleCommand cmd, ActorId performedBy) {
|
||||
return roleRepository.findByName(cmd.roleName())
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.flatMap(optRole -> {
|
||||
if (optRole.isEmpty()) {
|
||||
return Result.failure(new UserError.RoleNotFound(cmd.roleName()));
|
||||
}
|
||||
Role role = optRole.get();
|
||||
return user.removeRole(role)
|
||||
.flatMap(updated -> userRepository.save(updated)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.map(ignored -> {
|
||||
auditLogger.log(AuditEvent.ROLE_REMOVED, updated.id().value(), "Role: " + role.name(), performedBy);
|
||||
return UserDTO.from(updated);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
return Result.success(UserDTO.from(user));
|
||||
private Result<UserError, User> findUser(UserId userId) {
|
||||
return userRepository.findById(userId)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.flatMap(opt -> opt
|
||||
.map(Result::<UserError, User>success)
|
||||
.orElse(Result.failure(new UserError.UserNotFound(userId))));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.UnlockUserCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Unlock a user account (allow login).
|
||||
*/
|
||||
|
|
@ -19,36 +16,41 @@ public class UnlockUser {
|
|||
|
||||
private final UserRepository userRepository;
|
||||
private final AuditLogger auditLogger;
|
||||
private final AuthorizationPort authPort;
|
||||
|
||||
public UnlockUser(UserRepository userRepository, AuditLogger auditLogger) {
|
||||
public UnlockUser(UserRepository userRepository, AuditLogger auditLogger, AuthorizationPort authPort) {
|
||||
this.userRepository = userRepository;
|
||||
this.auditLogger = auditLogger;
|
||||
this.authPort = authPort;
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(String userIdValue, ActorId performedBy) {
|
||||
UserId userId = UserId.of(userIdValue);
|
||||
User user;
|
||||
switch (userRepository.findById(userId)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.UserNotFound(userId));
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
public Result<UserError, UserDTO> execute(UnlockUserCommand cmd, ActorId performedBy) {
|
||||
// 0. Authorization
|
||||
if (!authPort.can(performedBy, UserManagementAction.USER_UNLOCK)) {
|
||||
return Result.failure(new UserError.Unauthorized("Not authorized to unlock users"));
|
||||
}
|
||||
|
||||
user.unlock();
|
||||
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
// 1. Input validation
|
||||
if (cmd.userId() == null || cmd.userId().isBlank()) {
|
||||
return Result.failure(new UserError.InvalidInput("User ID must not be blank"));
|
||||
}
|
||||
|
||||
auditLogger.log(AuditEvent.USER_UNLOCKED, user.id().value(), performedBy);
|
||||
UserId userId = UserId.of(cmd.userId());
|
||||
return findUser(userId)
|
||||
.flatMap(User::unlock)
|
||||
.flatMap(updated -> userRepository.save(updated)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.map(ignored -> {
|
||||
auditLogger.log(AuditEvent.USER_UNLOCKED, updated.id().value(), performedBy);
|
||||
return UserDTO.from(updated);
|
||||
}));
|
||||
}
|
||||
|
||||
return Result.success(UserDTO.from(user));
|
||||
private Result<UserError, User> findUser(UserId userId) {
|
||||
return userRepository.findById(userId)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.flatMap(opt -> opt
|
||||
.map(Result::<UserError, User>success)
|
||||
.orElse(Result.failure(new UserError.UserNotFound(userId))));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,11 @@ package de.effigenix.application.usermanagement;
|
|||
import de.effigenix.application.usermanagement.command.UpdateUserCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static de.effigenix.shared.common.Result.*;
|
||||
|
||||
/**
|
||||
* Use Case: Update user details (email, branch).
|
||||
*/
|
||||
|
|
@ -20,61 +16,57 @@ public class UpdateUser {
|
|||
|
||||
private final UserRepository userRepository;
|
||||
private final AuditLogger auditLogger;
|
||||
private final AuthorizationPort authPort;
|
||||
|
||||
public UpdateUser(UserRepository userRepository, AuditLogger auditLogger) {
|
||||
public UpdateUser(UserRepository userRepository, AuditLogger auditLogger, AuthorizationPort authPort) {
|
||||
this.userRepository = userRepository;
|
||||
this.auditLogger = auditLogger;
|
||||
this.authPort = authPort;
|
||||
}
|
||||
|
||||
public Result<UserError, UserDTO> execute(UpdateUserCommand cmd, ActorId performedBy) {
|
||||
// 1. Find user
|
||||
UserId userId = UserId.of(cmd.userId());
|
||||
User user;
|
||||
switch (userRepository.findById(userId)) {
|
||||
case Failure<RepositoryError, Optional<User>> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Optional<User>> s -> {
|
||||
if (s.value().isEmpty()) {
|
||||
return Result.failure(new UserError.UserNotFound(userId));
|
||||
}
|
||||
user = s.value().get();
|
||||
}
|
||||
// 0. Authorization
|
||||
if (!authPort.can(performedBy, UserManagementAction.USER_UPDATE)) {
|
||||
return Result.failure(new UserError.Unauthorized("Not authorized to update users"));
|
||||
}
|
||||
|
||||
// 2. Update email if provided
|
||||
UserId userId = UserId.of(cmd.userId());
|
||||
return findUser(userId).flatMap(user -> updateUser(user, cmd, performedBy));
|
||||
}
|
||||
|
||||
private Result<UserError, UserDTO> updateUser(User user, UpdateUserCommand cmd, ActorId performedBy) {
|
||||
Result<UserError, User> current = Result.success(user);
|
||||
|
||||
// Update email if provided
|
||||
if (cmd.email() != null && !cmd.email().equals(user.email())) {
|
||||
// Check email uniqueness
|
||||
switch (userRepository.existsByEmail(cmd.email())) {
|
||||
case Failure<RepositoryError, Boolean> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Boolean> s -> {
|
||||
if (s.value()) {
|
||||
var emailExists = userRepository.existsByEmail(cmd.email())
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()));
|
||||
if (emailExists.isFailure()) return Result.failure(emailExists.unsafeGetError());
|
||||
if (emailExists.unsafeGetValue()) {
|
||||
return Result.failure(new UserError.EmailAlreadyExists(cmd.email()));
|
||||
}
|
||||
}
|
||||
current = current.flatMap(u -> u.updateEmail(cmd.email()));
|
||||
}
|
||||
|
||||
switch (user.updateEmail(cmd.email())) {
|
||||
case Failure<UserError, Void> f -> { return Result.failure(f.error()); }
|
||||
case Success<UserError, Void> ignored -> { }
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update branch if provided
|
||||
// Update branch if provided
|
||||
if (cmd.branchId() != null && !cmd.branchId().equals(user.branchId())) {
|
||||
user.updateBranch(cmd.branchId());
|
||||
current = current.flatMap(u -> u.updateBranch(cmd.branchId()));
|
||||
}
|
||||
|
||||
// 4. Save
|
||||
switch (userRepository.save(user)) {
|
||||
case Failure<RepositoryError, Void> f ->
|
||||
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
|
||||
case Success<RepositoryError, Void> ignored -> { }
|
||||
return current.flatMap(updated -> userRepository.save(updated)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.map(ignored -> {
|
||||
auditLogger.log(AuditEvent.USER_UPDATED, updated.id().value(), performedBy);
|
||||
return UserDTO.from(updated);
|
||||
}));
|
||||
}
|
||||
|
||||
// 5. Audit log
|
||||
auditLogger.log(AuditEvent.USER_UPDATED, user.id().value(), performedBy);
|
||||
|
||||
return Result.success(UserDTO.from(user));
|
||||
private Result<UserError, User> findUser(UserId userId) {
|
||||
return userRepository.findById(userId)
|
||||
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
|
||||
.flatMap(opt -> opt
|
||||
.map(Result::<UserError, User>success)
|
||||
.orElse(Result.failure(new UserError.UserNotFound(userId))));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package de.effigenix.application.usermanagement.command;
|
||||
|
||||
/**
|
||||
* Command for locking a user account.
|
||||
*/
|
||||
public record LockUserCommand(
|
||||
String userId
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package de.effigenix.application.usermanagement.command;
|
||||
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
|
||||
/**
|
||||
* Command for removing a role from a user.
|
||||
*/
|
||||
public record RemoveRoleCommand(
|
||||
String userId,
|
||||
RoleName roleName
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package de.effigenix.application.usermanagement.command;
|
||||
|
||||
/**
|
||||
* Command for unlocking a user account.
|
||||
*/
|
||||
public record UnlockUserCommand(
|
||||
String userId
|
||||
) {
|
||||
}
|
||||
|
|
@ -11,14 +11,15 @@ import java.util.Set;
|
|||
*
|
||||
* Roles are predefined and loaded from seed data.
|
||||
* Each Role has a set of Permissions that grant access to specific actions.
|
||||
* Immutable: all business methods return new instances via Result.
|
||||
*
|
||||
* Invariant: id is non-null, name is non-null
|
||||
*/
|
||||
public class Role {
|
||||
private final RoleId id;
|
||||
private final RoleName name;
|
||||
private Set<Permission> permissions;
|
||||
private String description;
|
||||
private final Set<Permission> permissions;
|
||||
private final String description;
|
||||
|
||||
private Role(
|
||||
RoleId id,
|
||||
|
|
@ -28,7 +29,7 @@ public class Role {
|
|||
) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.permissions = permissions != null ? new HashSet<>(permissions) : new HashSet<>();
|
||||
this.permissions = permissions != null ? Set.copyOf(permissions) : Set.of();
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
|
@ -63,24 +64,32 @@ public class Role {
|
|||
return new Role(id, name, permissions, description);
|
||||
}
|
||||
|
||||
// ==================== Business Methods ====================
|
||||
// ==================== Business Methods (Wither-Pattern) ====================
|
||||
|
||||
public Result<UserError, Void> addPermission(Permission permission) {
|
||||
public Result<UserError, Role> addPermission(Permission permission) {
|
||||
if (permission == null) {
|
||||
return Result.failure(new UserError.NullRole());
|
||||
return Result.failure(new UserError.NullPermission());
|
||||
}
|
||||
this.permissions.add(permission);
|
||||
return Result.success(null);
|
||||
Set<Permission> newPermissions = new HashSet<>(permissions);
|
||||
newPermissions.add(permission);
|
||||
return Result.success(new Role(id, name, newPermissions, description));
|
||||
}
|
||||
|
||||
public void removePermission(Permission permission) {
|
||||
this.permissions.remove(permission);
|
||||
public Result<UserError, Role> removePermission(Permission permission) {
|
||||
if (permission == null) {
|
||||
return Result.failure(new UserError.NullPermission());
|
||||
}
|
||||
Set<Permission> newPermissions = new HashSet<>(permissions);
|
||||
newPermissions.remove(permission);
|
||||
return Result.success(new Role(id, name, newPermissions, description));
|
||||
}
|
||||
|
||||
public void updateDescription(String newDescription) {
|
||||
this.description = newDescription;
|
||||
public Result<UserError, Role> updateDescription(String newDescription) {
|
||||
return Result.success(new Role(id, name, permissions, newDescription));
|
||||
}
|
||||
|
||||
// ==================== Query Methods ====================
|
||||
|
||||
public boolean hasPermission(Permission permission) {
|
||||
return permissions.contains(permission);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,24 +11,31 @@ import java.util.Set;
|
|||
* User Entity (Simple Entity, NOT an Aggregate).
|
||||
*
|
||||
* Generic Subdomain → Minimal DDD:
|
||||
* - Immutable: All business methods return new instances via Result
|
||||
* - Validation via Result type in factory method
|
||||
* - NO complex business logic
|
||||
* - Status transition guards enforce valid state machine
|
||||
* - NO domain events
|
||||
*
|
||||
* Invariant: username is non-blank, email is valid, passwordHash is non-null, status is non-null
|
||||
* Uniqueness (username, email) is enforced in the Application Layer (repository concern)
|
||||
*
|
||||
* Status transitions:
|
||||
* - ACTIVE → LOCKED (lock)
|
||||
* - LOCKED → ACTIVE (unlock)
|
||||
* - ACTIVE | LOCKED → INACTIVE (deactivate)
|
||||
* - INACTIVE → ACTIVE (activate)
|
||||
*/
|
||||
public class User {
|
||||
private final UserId id;
|
||||
private String username;
|
||||
private String email;
|
||||
private PasswordHash passwordHash;
|
||||
private Set<Role> roles;
|
||||
private String branchId;
|
||||
private UserStatus status;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime lastLogin;
|
||||
private final String username;
|
||||
private final String email;
|
||||
private final PasswordHash passwordHash;
|
||||
private final Set<Role> roles;
|
||||
private final String branchId;
|
||||
private final UserStatus status;
|
||||
private final LocalDateTime createdAt;
|
||||
private final LocalDateTime lastLogin;
|
||||
|
||||
// Invariant: all fields validated via create() or reconstitute()
|
||||
private User(
|
||||
UserId id,
|
||||
String username,
|
||||
|
|
@ -44,7 +51,7 @@ public class User {
|
|||
this.username = username;
|
||||
this.email = email;
|
||||
this.passwordHash = passwordHash;
|
||||
this.roles = roles != null ? new HashSet<>(roles) : new HashSet<>();
|
||||
this.roles = roles != null ? Set.copyOf(roles) : Set.of();
|
||||
this.branchId = branchId;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt != null ? createdAt : LocalDateTime.now();
|
||||
|
|
@ -101,63 +108,81 @@ public class User {
|
|||
return new User(id, username, email, passwordHash, roles, branchId, status, createdAt, lastLogin);
|
||||
}
|
||||
|
||||
// ==================== Business Methods ====================
|
||||
// ==================== Business Methods (Wither-Pattern) ====================
|
||||
|
||||
public void updateLastLogin(LocalDateTime timestamp) {
|
||||
this.lastLogin = timestamp;
|
||||
public Result<UserError, User> withLastLogin(LocalDateTime timestamp) {
|
||||
return Result.success(new User(id, username, email, passwordHash, roles, branchId, status, createdAt, timestamp));
|
||||
}
|
||||
|
||||
public Result<UserError, Void> changePassword(PasswordHash newPasswordHash) {
|
||||
public Result<UserError, User> changePassword(PasswordHash newPasswordHash) {
|
||||
if (newPasswordHash == null) {
|
||||
return Result.failure(new UserError.NullPasswordHash());
|
||||
}
|
||||
this.passwordHash = newPasswordHash;
|
||||
return Result.success(null);
|
||||
return Result.success(new User(id, username, email, newPasswordHash, roles, branchId, status, createdAt, lastLogin));
|
||||
}
|
||||
|
||||
public void lock() {
|
||||
this.status = UserStatus.LOCKED;
|
||||
public Result<UserError, User> lock() {
|
||||
if (status != UserStatus.ACTIVE) {
|
||||
return Result.failure(new UserError.InvalidStatusTransition(status, UserStatus.LOCKED));
|
||||
}
|
||||
return Result.success(new User(id, username, email, passwordHash, roles, branchId, UserStatus.LOCKED, createdAt, lastLogin));
|
||||
}
|
||||
|
||||
public void unlock() {
|
||||
this.status = UserStatus.ACTIVE;
|
||||
public Result<UserError, User> unlock() {
|
||||
if (status != UserStatus.LOCKED) {
|
||||
return Result.failure(new UserError.InvalidStatusTransition(status, UserStatus.ACTIVE));
|
||||
}
|
||||
return Result.success(new User(id, username, email, passwordHash, roles, branchId, UserStatus.ACTIVE, createdAt, lastLogin));
|
||||
}
|
||||
|
||||
public void deactivate() {
|
||||
this.status = UserStatus.INACTIVE;
|
||||
public Result<UserError, User> deactivate() {
|
||||
if (status != UserStatus.ACTIVE && status != UserStatus.LOCKED) {
|
||||
return Result.failure(new UserError.InvalidStatusTransition(status, UserStatus.INACTIVE));
|
||||
}
|
||||
return Result.success(new User(id, username, email, passwordHash, roles, branchId, UserStatus.INACTIVE, createdAt, lastLogin));
|
||||
}
|
||||
|
||||
public void activate() {
|
||||
this.status = UserStatus.ACTIVE;
|
||||
public Result<UserError, User> activate() {
|
||||
if (status != UserStatus.INACTIVE) {
|
||||
return Result.failure(new UserError.InvalidStatusTransition(status, UserStatus.ACTIVE));
|
||||
}
|
||||
return Result.success(new User(id, username, email, passwordHash, roles, branchId, UserStatus.ACTIVE, createdAt, lastLogin));
|
||||
}
|
||||
|
||||
public Result<UserError, Void> assignRole(Role role) {
|
||||
public Result<UserError, User> assignRole(Role role) {
|
||||
if (role == null) {
|
||||
return Result.failure(new UserError.NullRole());
|
||||
}
|
||||
this.roles.add(role);
|
||||
return Result.success(null);
|
||||
Set<Role> newRoles = new HashSet<>(roles);
|
||||
newRoles.add(role);
|
||||
return Result.success(new User(id, username, email, passwordHash, newRoles, branchId, status, createdAt, lastLogin));
|
||||
}
|
||||
|
||||
public void removeRole(Role role) {
|
||||
this.roles.remove(role);
|
||||
public Result<UserError, User> removeRole(Role role) {
|
||||
if (role == null) {
|
||||
return Result.failure(new UserError.NullRole());
|
||||
}
|
||||
Set<Role> newRoles = new HashSet<>(roles);
|
||||
newRoles.remove(role);
|
||||
return Result.success(new User(id, username, email, passwordHash, newRoles, branchId, status, createdAt, lastLogin));
|
||||
}
|
||||
|
||||
public Result<UserError, Void> updateEmail(String newEmail) {
|
||||
public Result<UserError, User> updateEmail(String newEmail) {
|
||||
if (newEmail == null || newEmail.isBlank()) {
|
||||
return Result.failure(new UserError.InvalidEmail("null or empty"));
|
||||
}
|
||||
if (!isValidEmail(newEmail)) {
|
||||
return Result.failure(new UserError.InvalidEmail(newEmail));
|
||||
}
|
||||
this.email = newEmail;
|
||||
return Result.success(null);
|
||||
return Result.success(new User(id, username, newEmail, passwordHash, roles, branchId, status, createdAt, lastLogin));
|
||||
}
|
||||
|
||||
public void updateBranch(String newBranchId) {
|
||||
this.branchId = newBranchId;
|
||||
public Result<UserError, User> updateBranch(String newBranchId) {
|
||||
return Result.success(new User(id, username, email, passwordHash, roles, newBranchId, status, createdAt, lastLogin));
|
||||
}
|
||||
|
||||
// ==================== Query Methods ====================
|
||||
|
||||
public boolean isActive() {
|
||||
return status == UserStatus.ACTIVE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,20 @@ public sealed interface UserError {
|
|||
@Override public String message() { return "Role cannot be null"; }
|
||||
}
|
||||
|
||||
record NullPermission() implements UserError {
|
||||
@Override public String code() { return "USER_NULL_PERMISSION"; }
|
||||
@Override public String message() { return "Permission cannot be null"; }
|
||||
}
|
||||
|
||||
record InvalidStatusTransition(UserStatus from, UserStatus to) implements UserError {
|
||||
@Override public String code() { return "USER_INVALID_STATUS_TRANSITION"; }
|
||||
@Override public String message() { return "Invalid status transition from " + from + " to " + to; }
|
||||
}
|
||||
|
||||
record InvalidInput(String message) implements UserError {
|
||||
@Override public String code() { return "INVALID_INPUT"; }
|
||||
}
|
||||
|
||||
record RepositoryFailure(String message) implements UserError {
|
||||
@Override public String code() { return "REPOSITORY_ERROR"; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package de.effigenix.domain.usermanagement;
|
||||
|
||||
import de.effigenix.shared.security.Action;
|
||||
|
||||
/**
|
||||
* Domain actions for the User Management Bounded Context.
|
||||
* Used with AuthorizationPort for type-safe authorization checks.
|
||||
*/
|
||||
public enum UserManagementAction implements Action {
|
||||
USER_CREATE,
|
||||
USER_UPDATE,
|
||||
USER_LOCK,
|
||||
USER_UNLOCK,
|
||||
USER_VIEW,
|
||||
USER_LIST,
|
||||
ROLE_ASSIGN,
|
||||
ROLE_REMOVE,
|
||||
PASSWORD_CHANGE
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package de.effigenix.infrastructure.config;
|
|||
import de.effigenix.application.usermanagement.*;
|
||||
import de.effigenix.domain.usermanagement.RoleRepository;
|
||||
import de.effigenix.domain.usermanagement.UserRepository;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
|
|
@ -20,9 +21,10 @@ public class UseCaseConfiguration {
|
|||
UserRepository userRepository,
|
||||
RoleRepository roleRepository,
|
||||
PasswordHasher passwordHasher,
|
||||
AuditLogger auditLogger
|
||||
AuditLogger auditLogger,
|
||||
AuthorizationPort authPort
|
||||
) {
|
||||
return new CreateUser(userRepository, roleRepository, passwordHasher, auditLogger);
|
||||
return new CreateUser(userRepository, roleRepository, passwordHasher, auditLogger, authPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
|
@ -39,60 +41,66 @@ public class UseCaseConfiguration {
|
|||
public ChangePassword changePassword(
|
||||
UserRepository userRepository,
|
||||
PasswordHasher passwordHasher,
|
||||
AuditLogger auditLogger
|
||||
AuditLogger auditLogger,
|
||||
AuthorizationPort authPort
|
||||
) {
|
||||
return new ChangePassword(userRepository, passwordHasher, auditLogger);
|
||||
return new ChangePassword(userRepository, passwordHasher, auditLogger, authPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UpdateUser updateUser(
|
||||
UserRepository userRepository,
|
||||
AuditLogger auditLogger
|
||||
AuditLogger auditLogger,
|
||||
AuthorizationPort authPort
|
||||
) {
|
||||
return new UpdateUser(userRepository, auditLogger);
|
||||
return new UpdateUser(userRepository, auditLogger, authPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AssignRole assignRole(
|
||||
UserRepository userRepository,
|
||||
RoleRepository roleRepository,
|
||||
AuditLogger auditLogger
|
||||
AuditLogger auditLogger,
|
||||
AuthorizationPort authPort
|
||||
) {
|
||||
return new AssignRole(userRepository, roleRepository, auditLogger);
|
||||
return new AssignRole(userRepository, roleRepository, auditLogger, authPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RemoveRole removeRole(
|
||||
UserRepository userRepository,
|
||||
RoleRepository roleRepository,
|
||||
AuditLogger auditLogger
|
||||
AuditLogger auditLogger,
|
||||
AuthorizationPort authPort
|
||||
) {
|
||||
return new RemoveRole(userRepository, roleRepository, auditLogger);
|
||||
return new RemoveRole(userRepository, roleRepository, auditLogger, authPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public LockUser lockUser(
|
||||
UserRepository userRepository,
|
||||
AuditLogger auditLogger
|
||||
AuditLogger auditLogger,
|
||||
AuthorizationPort authPort
|
||||
) {
|
||||
return new LockUser(userRepository, auditLogger);
|
||||
return new LockUser(userRepository, auditLogger, authPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UnlockUser unlockUser(
|
||||
UserRepository userRepository,
|
||||
AuditLogger auditLogger
|
||||
AuditLogger auditLogger,
|
||||
AuthorizationPort authPort
|
||||
) {
|
||||
return new UnlockUser(userRepository, auditLogger);
|
||||
return new UnlockUser(userRepository, auditLogger, authPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GetUser getUser(UserRepository userRepository) {
|
||||
return new GetUser(userRepository);
|
||||
public GetUser getUser(UserRepository userRepository, AuthorizationPort authPort) {
|
||||
return new GetUser(userRepository, authPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ListUsers listUsers(UserRepository userRepository) {
|
||||
return new ListUsers(userRepository);
|
||||
public ListUsers listUsers(UserRepository userRepository, AuthorizationPort authPort) {
|
||||
return new ListUsers(userRepository, authPort);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import de.effigenix.domain.production.ProductionAction;
|
|||
import de.effigenix.domain.quality.QualityAction;
|
||||
import de.effigenix.domain.sales.SalesAction;
|
||||
import de.effigenix.domain.usermanagement.Permission;
|
||||
import de.effigenix.domain.usermanagement.UserManagementAction;
|
||||
import de.effigenix.shared.security.Action;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
|
|
@ -71,6 +72,8 @@ public class ActionToPermissionMapper {
|
|||
return mapLabelingAction(labelingAction);
|
||||
} else if (action instanceof FilialesAction filialesAction) {
|
||||
return mapFilialesAction(filialesAction);
|
||||
} else if (action instanceof UserManagementAction userMgmtAction) {
|
||||
return mapUserManagementAction(userMgmtAction);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown action type: " + action.getClass().getName());
|
||||
}
|
||||
|
|
@ -157,4 +160,18 @@ public class ActionToPermissionMapper {
|
|||
case BRANCH_DELETE -> Permission.BRANCH_DELETE;
|
||||
};
|
||||
}
|
||||
|
||||
private Permission mapUserManagementAction(UserManagementAction action) {
|
||||
return switch (action) {
|
||||
case USER_CREATE -> Permission.USER_WRITE;
|
||||
case USER_UPDATE -> Permission.USER_WRITE;
|
||||
case USER_LOCK -> Permission.USER_LOCK;
|
||||
case USER_UNLOCK -> Permission.USER_UNLOCK;
|
||||
case USER_VIEW -> Permission.USER_READ;
|
||||
case USER_LIST -> Permission.USER_READ;
|
||||
case ROLE_ASSIGN -> Permission.ROLE_ASSIGN;
|
||||
case ROLE_REMOVE -> Permission.ROLE_REMOVE;
|
||||
case PASSWORD_CHANGE -> Permission.USER_WRITE;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,122 +4,65 @@ import de.effigenix.application.usermanagement.SessionManager;
|
|||
import de.effigenix.application.usermanagement.dto.SessionToken;
|
||||
import de.effigenix.domain.usermanagement.User;
|
||||
import de.effigenix.domain.usermanagement.UserId;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Set;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* JWT-based Session Manager implementation.
|
||||
*
|
||||
* Implements the SessionManager port from Application Layer using JWT tokens.
|
||||
* Stateless session management - no server-side session storage.
|
||||
*
|
||||
* Token Invalidation:
|
||||
* - Access tokens are stateless (cannot be truly invalidated)
|
||||
* - For MVP: Use in-memory blacklist for logged-out tokens
|
||||
* - For Production: Consider Redis-based blacklist or short token expiration
|
||||
*
|
||||
* Refresh Tokens:
|
||||
* - For MVP: Simple refresh token validation
|
||||
* - For Production: Consider refresh token rotation and family tracking
|
||||
*
|
||||
* Infrastructure Layer → Implements Application Layer port
|
||||
* - Uses in-memory blacklist with TTL (token expiration time)
|
||||
* - Expired tokens are automatically cleaned up every 5 minutes
|
||||
* - For production: consider Redis-based blacklist
|
||||
*/
|
||||
@Service
|
||||
public class JwtSessionManager implements SessionManager {
|
||||
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
|
||||
// In-memory token blacklist (for MVP - replace with Redis in production)
|
||||
private final Set<String> tokenBlacklist = ConcurrentHashMap.newKeySet();
|
||||
// Token → expiration instant (for TTL-based cleanup)
|
||||
private final ConcurrentHashMap<String, Instant> tokenBlacklist = new ConcurrentHashMap<>();
|
||||
|
||||
public JwtSessionManager(JwtTokenProvider tokenProvider) {
|
||||
this.tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new session (JWT token) for a user.
|
||||
*
|
||||
* Generates both access and refresh tokens:
|
||||
* - Access token: Short-lived (8h), contains user info and permissions
|
||||
* - Refresh token: Long-lived (7d), used to obtain new access tokens
|
||||
*
|
||||
* @param user User to create session for
|
||||
* @return Session token (access token + refresh token)
|
||||
*/
|
||||
@Override
|
||||
public SessionToken createSession(User user) {
|
||||
if (user == null) {
|
||||
throw new IllegalArgumentException("User cannot be null");
|
||||
}
|
||||
|
||||
// Generate access token with user information and permissions
|
||||
String accessToken = tokenProvider.generateAccessToken(
|
||||
user.id(),
|
||||
user.username(),
|
||||
user.getAllPermissions(),
|
||||
user.branchId()
|
||||
user.id(), user.username(), user.getAllPermissions(), user.branchId()
|
||||
);
|
||||
|
||||
// Generate refresh token for session renewal
|
||||
String refreshToken = tokenProvider.generateRefreshToken(user.id());
|
||||
|
||||
return SessionToken.create(
|
||||
accessToken,
|
||||
tokenProvider.getAccessTokenExpiration(),
|
||||
refreshToken
|
||||
);
|
||||
return SessionToken.create(accessToken, tokenProvider.getAccessTokenExpiration(), refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a JWT token and extracts the user ID.
|
||||
*
|
||||
* Checks:
|
||||
* 1. Token signature is valid
|
||||
* 2. Token is not expired
|
||||
* 3. Token is not blacklisted (logged out)
|
||||
*
|
||||
* @param token JWT access token
|
||||
* @return UserId if valid
|
||||
* @throws RuntimeException if token is invalid, expired, or blacklisted
|
||||
*/
|
||||
@Override
|
||||
public UserId validateToken(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new IllegalArgumentException("Token cannot be null or empty");
|
||||
}
|
||||
|
||||
// Check if token is blacklisted (user logged out)
|
||||
if (tokenBlacklist.contains(token)) {
|
||||
if (tokenBlacklist.containsKey(token)) {
|
||||
throw new SecurityException("Token has been invalidated (user logged out)");
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate token and extract userId
|
||||
return tokenProvider.extractUserId(token);
|
||||
} catch (JwtException e) {
|
||||
throw new SecurityException("Invalid or expired JWT token: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an expired access token using a refresh token.
|
||||
*
|
||||
* Process:
|
||||
* 1. Validate refresh token
|
||||
* 2. Extract userId from refresh token
|
||||
* 3. Load user from repository (not implemented here - done in Application Layer)
|
||||
* 4. Generate new access token
|
||||
*
|
||||
* Note: This method only validates the refresh token and extracts userId.
|
||||
* The Application Layer is responsible for loading the user and creating a new session.
|
||||
*
|
||||
* @param refreshToken Refresh token
|
||||
* @return New session token
|
||||
* @throws RuntimeException if refresh token is invalid or expired
|
||||
*/
|
||||
@Override
|
||||
public SessionToken refreshSession(String refreshToken) {
|
||||
if (refreshToken == null || refreshToken.isBlank()) {
|
||||
|
|
@ -127,62 +70,53 @@ public class JwtSessionManager implements SessionManager {
|
|||
}
|
||||
|
||||
try {
|
||||
// Validate refresh token and extract userId
|
||||
UserId userId = tokenProvider.extractUserId(refreshToken);
|
||||
// Validate refresh token
|
||||
Claims claims = tokenProvider.validateToken(refreshToken);
|
||||
String type = claims.get("type", String.class);
|
||||
if (!"refresh".equals(type)) {
|
||||
throw new SecurityException("Not a refresh token");
|
||||
}
|
||||
|
||||
// NOTE: In a real implementation, we would:
|
||||
// 1. Load the user from repository
|
||||
// 2. Verify user is still active
|
||||
// 3. Generate new access + refresh tokens
|
||||
//
|
||||
// For now, this is a placeholder that demonstrates the contract.
|
||||
// The Application Layer service will handle the full flow.
|
||||
UserId userId = UserId.of(claims.getSubject());
|
||||
|
||||
// Note: full implementation with user loading should be done via a dedicated Use Case.
|
||||
// This is the infrastructure-level token refresh.
|
||||
throw new UnsupportedOperationException(
|
||||
"Session refresh requires user loading from repository. " +
|
||||
"This should be implemented in the Application Layer service."
|
||||
"Use the RefreshSession use case instead."
|
||||
);
|
||||
|
||||
} catch (JwtException e) {
|
||||
throw new SecurityException("Invalid or expired refresh token: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates a session (logout).
|
||||
*
|
||||
* Adds the token to the blacklist to prevent further use.
|
||||
* Note: Blacklist is in-memory for MVP. For production, use Redis with TTL.
|
||||
*
|
||||
* @param token JWT access token to invalidate
|
||||
*/
|
||||
@Override
|
||||
public void invalidateSession(String token) {
|
||||
if (token != null && !token.isBlank()) {
|
||||
tokenBlacklist.add(token);
|
||||
|
||||
// TODO: In production, implement automatic cleanup of expired tokens from blacklist
|
||||
// Option 1: Use Redis with TTL (token expiration time)
|
||||
// Option 2: Background task to clean up expired tokens
|
||||
// Option 3: Use a time-based eviction cache (Caffeine, Guava)
|
||||
try {
|
||||
Claims claims = tokenProvider.validateToken(token);
|
||||
Instant expiration = claims.getExpiration().toInstant();
|
||||
tokenBlacklist.put(token, expiration);
|
||||
} catch (JwtException e) {
|
||||
// Token is already invalid/expired, no need to blacklist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a token is blacklisted.
|
||||
* Useful for debugging and testing.
|
||||
*
|
||||
* @param token JWT access token
|
||||
* @return true if token is blacklisted, false otherwise
|
||||
* Scheduled cleanup of expired tokens from the blacklist.
|
||||
* Runs every 5 minutes.
|
||||
*/
|
||||
@Scheduled(fixedRate = 300_000)
|
||||
public void cleanupExpiredTokens() {
|
||||
Instant now = Instant.now();
|
||||
tokenBlacklist.entrySet().removeIf(entry -> entry.getValue().isBefore(now));
|
||||
}
|
||||
|
||||
public boolean isTokenBlacklisted(String token) {
|
||||
return tokenBlacklist.contains(token);
|
||||
return tokenBlacklist.containsKey(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the token blacklist.
|
||||
* Useful for testing. DO NOT use in production!
|
||||
*/
|
||||
public void clearBlacklist() {
|
||||
tokenBlacklist.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
package de.effigenix.infrastructure.security;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* In-memory rate limiter for login attempts.
|
||||
* Prevents brute-force attacks by limiting failed login attempts per IP/username.
|
||||
*
|
||||
* Configuration:
|
||||
* - Max 5 attempts per 15 minutes per key (IP or username)
|
||||
* - Automatic cleanup of expired entries every 5 minutes
|
||||
*
|
||||
* For production: consider Redis-based rate limiting for distributed environments.
|
||||
*/
|
||||
@Component
|
||||
public class LoginRateLimiter {
|
||||
|
||||
private static final int MAX_ATTEMPTS = 5;
|
||||
private static final long WINDOW_MS = 15 * 60 * 1000L; // 15 minutes
|
||||
|
||||
private final ConcurrentHashMap<String, AttemptRecord> attempts = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Records a failed login attempt for the given key (e.g., IP address or username).
|
||||
*/
|
||||
public void recordFailedAttempt(String key) {
|
||||
attempts.compute(key, (k, existing) -> {
|
||||
Instant now = Instant.now();
|
||||
if (existing == null || existing.windowStart.plusMillis(WINDOW_MS).isBefore(now)) {
|
||||
return new AttemptRecord(now, 1);
|
||||
}
|
||||
return new AttemptRecord(existing.windowStart, existing.count + 1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given key is rate-limited (too many failed attempts).
|
||||
*/
|
||||
public boolean isRateLimited(String key) {
|
||||
AttemptRecord record = attempts.get(key);
|
||||
if (record == null) return false;
|
||||
|
||||
Instant now = Instant.now();
|
||||
if (record.windowStart.plusMillis(WINDOW_MS).isBefore(now)) {
|
||||
attempts.remove(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return record.count >= MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the attempt counter for a key (e.g., after successful login).
|
||||
*/
|
||||
public void resetAttempts(String key) {
|
||||
attempts.remove(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled cleanup of expired attempt records.
|
||||
* Runs every 5 minutes.
|
||||
*/
|
||||
@Scheduled(fixedRate = 300_000)
|
||||
public void cleanupExpiredEntries() {
|
||||
Instant now = Instant.now();
|
||||
attempts.entrySet().removeIf(entry ->
|
||||
entry.getValue().windowStart.plusMillis(WINDOW_MS).isBefore(now));
|
||||
}
|
||||
|
||||
private record AttemptRecord(Instant windowStart, int count) {}
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
package de.effigenix.infrastructure.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
|
|
@ -12,6 +12,11 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Spring Security 6 Configuration for JWT-based authentication.
|
||||
|
|
@ -20,18 +25,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
|||
* - Stateless: No server-side sessions (SessionCreationPolicy.STATELESS)
|
||||
* - JWT-based: Authentication via JWT tokens in Authorization header
|
||||
* - BCrypt: Password hashing with strength 12
|
||||
* - Role-based: Authorization via permissions (not roles!)
|
||||
*
|
||||
* Endpoint Security:
|
||||
* - Public: /api/auth/login, /api/auth/refresh
|
||||
* - Protected: All other /api/** endpoints require authentication
|
||||
* - Swagger: /swagger-ui/**, /api-docs/** (public for development, restrict in production)
|
||||
*
|
||||
* Filter Chain:
|
||||
* 1. JwtAuthenticationFilter: Validates JWT and sets Authentication in SecurityContext
|
||||
* 2. Spring Security filters: Authorization checks based on permissions
|
||||
*
|
||||
* Infrastructure Layer → Spring Security Configuration
|
||||
* - CORS: Configurable allowed origins
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
|
|
@ -42,6 +36,9 @@ public class SecurityConfig {
|
|||
private final ApiAuthenticationEntryPoint authenticationEntryPoint;
|
||||
private final ApiAccessDeniedHandler accessDeniedHandler;
|
||||
|
||||
@Value("${effigenix.cors.allowed-origins:http://localhost:3000}")
|
||||
private List<String> allowedOrigins;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
|
||||
ApiAuthenticationEntryPoint authenticationEntryPoint,
|
||||
ApiAccessDeniedHandler accessDeniedHandler) {
|
||||
|
|
@ -50,81 +47,49 @@ public class SecurityConfig {
|
|||
this.accessDeniedHandler = accessDeniedHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the Spring Security filter chain.
|
||||
*
|
||||
* Security Configuration:
|
||||
* - CSRF disabled (stateless JWT - no cookie-based sessions)
|
||||
* - CORS configured (allows cross-origin requests)
|
||||
* - Session management: STATELESS (no server-side sessions)
|
||||
* - Authorization: Public endpoints vs protected endpoints
|
||||
* - Exception handling: 401 Unauthorized for authentication failures
|
||||
*
|
||||
* @param http HttpSecurity builder
|
||||
* @return Configured SecurityFilterChain
|
||||
* @throws Exception if configuration fails
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// CSRF Protection: Disabled for stateless JWT authentication
|
||||
// IMPORTANT: Enable CSRF for cookie-based sessions in production!
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
|
||||
// CORS Configuration: Allow cross-origin requests
|
||||
// TODO: Configure specific origins in production (not allowAll)
|
||||
.cors(AbstractHttpConfigurer::disable)
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
|
||||
// Session Management: Stateless (no server-side sessions)
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
|
||||
// Authorization Rules
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Public Endpoints: Authentication
|
||||
.requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
|
||||
|
||||
// Public Endpoints: Swagger/OpenAPI (restrict in production!)
|
||||
.requestMatchers("/swagger-ui/**", "/api-docs/**", "/v3/api-docs/**").permitAll()
|
||||
|
||||
// Public Endpoints: Health check (for load balancers)
|
||||
.requestMatchers("/actuator/health").permitAll()
|
||||
|
||||
// Protected Endpoints: All other /api/** endpoints require authentication
|
||||
.requestMatchers("/api/**").authenticated()
|
||||
|
||||
// All other requests: Deny by default (secure by default)
|
||||
.anyRequest().denyAll()
|
||||
)
|
||||
|
||||
// Exception Handling: Return 401/403 with consistent ErrorResponse format
|
||||
.exceptionHandling(exception -> exception
|
||||
.authenticationEntryPoint(authenticationEntryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler)
|
||||
)
|
||||
|
||||
// Add JWT Authentication Filter before Spring Security's authentication filters
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Password Encoder Bean: BCrypt with strength 12.
|
||||
*
|
||||
* BCrypt Properties:
|
||||
* - Strength 12: ~250ms hashing time on modern hardware
|
||||
* - 2^12 = 4,096 iterations
|
||||
* - Includes automatic salt generation
|
||||
* - Resistant to rainbow table and brute-force attacks
|
||||
*
|
||||
* This bean is used by:
|
||||
* - BCryptPasswordHasher (for password hashing)
|
||||
* - Spring Security (for password verification in authentication)
|
||||
*
|
||||
* @return BCryptPasswordEncoder with strength 12
|
||||
*/
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(allowedOrigins);
|
||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(12);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import de.effigenix.application.usermanagement.SessionManager;
|
|||
import de.effigenix.application.usermanagement.command.AuthenticateCommand;
|
||||
import de.effigenix.application.usermanagement.dto.SessionToken;
|
||||
import de.effigenix.domain.usermanagement.UserError;
|
||||
import de.effigenix.infrastructure.security.LoginRateLimiter;
|
||||
import de.effigenix.infrastructure.usermanagement.web.dto.LoginRequest;
|
||||
import de.effigenix.infrastructure.usermanagement.web.dto.LoginResponse;
|
||||
import de.effigenix.infrastructure.usermanagement.web.dto.RefreshTokenRequest;
|
||||
|
|
@ -27,17 +28,9 @@ import org.springframework.web.bind.annotation.*;
|
|||
/**
|
||||
* REST Controller for Authentication endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /api/auth/login - Login with username/password, returns JWT
|
||||
* - POST /api/auth/logout - Logout (invalidate JWT)
|
||||
* - POST /api/auth/refresh - Refresh access token using refresh token
|
||||
*
|
||||
* Security:
|
||||
* - All endpoints are PUBLIC (configured in SecurityConfig)
|
||||
* - No authentication required for login/refresh
|
||||
* - Logout requires valid JWT token
|
||||
*
|
||||
* Infrastructure Layer → REST API
|
||||
* - Rate limiting on login endpoint (max 5 attempts per 15min per IP)
|
||||
* - All auth endpoints are PUBLIC (configured in SecurityConfig)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
|
|
@ -48,118 +41,60 @@ public class AuthController {
|
|||
|
||||
private final AuthenticateUser authenticateUser;
|
||||
private final SessionManager sessionManager;
|
||||
private final LoginRateLimiter rateLimiter;
|
||||
|
||||
public AuthController(
|
||||
AuthenticateUser authenticateUser,
|
||||
SessionManager sessionManager
|
||||
SessionManager sessionManager,
|
||||
LoginRateLimiter rateLimiter
|
||||
) {
|
||||
this.authenticateUser = authenticateUser;
|
||||
this.sessionManager = sessionManager;
|
||||
this.rateLimiter = rateLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login endpoint.
|
||||
*
|
||||
* Authenticates user with username and password.
|
||||
* Returns JWT access token and refresh token on success.
|
||||
*
|
||||
* POST /api/auth/login
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* "username": "admin",
|
||||
* "password": "admin123"
|
||||
* }
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
* "tokenType": "Bearer",
|
||||
* "expiresIn": 3600,
|
||||
* "expiresAt": "2026-02-17T14:30:00",
|
||||
* "refreshToken": "refresh-token-here"
|
||||
* }
|
||||
*
|
||||
* Error Responses:
|
||||
* - 401 Unauthorized: Invalid credentials, user locked, or user inactive
|
||||
* - 400 Bad Request: Validation error (missing username/password)
|
||||
*
|
||||
* @param request Login request with username and password
|
||||
* @return LoginResponse with JWT tokens
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
@Operation(
|
||||
summary = "User login",
|
||||
description = "Authenticate user with username and password. Returns JWT access token and refresh token."
|
||||
)
|
||||
@Operation(summary = "User login", description = "Authenticate user with username and password. Returns JWT tokens.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Login successful",
|
||||
content = @Content(schema = @Schema(implementation = LoginResponse.class))
|
||||
),
|
||||
@ApiResponse(
|
||||
responseCode = "401",
|
||||
description = "Invalid credentials, user locked, or user inactive"
|
||||
),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Validation error (missing username or password)"
|
||||
)
|
||||
@ApiResponse(responseCode = "200", description = "Login successful",
|
||||
content = @Content(schema = @Schema(implementation = LoginResponse.class))),
|
||||
@ApiResponse(responseCode = "401", description = "Invalid credentials, user locked, or user inactive"),
|
||||
@ApiResponse(responseCode = "429", description = "Too many login attempts")
|
||||
})
|
||||
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
public ResponseEntity<LoginResponse> login(
|
||||
@Valid @RequestBody LoginRequest request,
|
||||
HttpServletRequest httpRequest
|
||||
) {
|
||||
String clientIp = getClientIp(httpRequest);
|
||||
|
||||
// Rate limiting check
|
||||
if (rateLimiter.isRateLimited(clientIp)) {
|
||||
logger.warn("Rate limited login attempt from IP: {}", clientIp);
|
||||
throw new AuthenticationFailedException(new UserError.Unauthorized("Too many login attempts. Please try again later."));
|
||||
}
|
||||
|
||||
logger.info("Login attempt for username: {}", request.username());
|
||||
|
||||
// Execute authentication use case
|
||||
AuthenticateCommand command = new AuthenticateCommand(
|
||||
request.username(),
|
||||
request.password()
|
||||
);
|
||||
|
||||
AuthenticateCommand command = new AuthenticateCommand(request.username(), request.password());
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(command);
|
||||
|
||||
// Handle result
|
||||
if (result.isFailure()) {
|
||||
// Throw the domain error - will be handled by GlobalExceptionHandler
|
||||
UserError error = result.unsafeGetError();
|
||||
throw new AuthenticationFailedException(error);
|
||||
rateLimiter.recordFailedAttempt(clientIp);
|
||||
throw new AuthenticationFailedException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
rateLimiter.resetAttempts(clientIp);
|
||||
SessionToken token = result.unsafeGetValue();
|
||||
logger.info("Login successful for username: {}", request.username());
|
||||
|
||||
return ResponseEntity.ok(LoginResponse.from(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout endpoint.
|
||||
*
|
||||
* Invalidates the current JWT token.
|
||||
* Client should also delete the token from local storage.
|
||||
*
|
||||
* POST /api/auth/logout
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (204 No Content):
|
||||
* (empty body)
|
||||
*
|
||||
* @param authentication Current authentication (from JWT token)
|
||||
* @return Empty response with 204 status
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
@Operation(
|
||||
summary = "User logout",
|
||||
description = "Invalidate current JWT token. Requires authentication."
|
||||
)
|
||||
@Operation(summary = "User logout", description = "Invalidate current JWT token.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "204",
|
||||
description = "Logout successful"
|
||||
),
|
||||
@ApiResponse(
|
||||
responseCode = "401",
|
||||
description = "Invalid or missing authentication token"
|
||||
)
|
||||
@ApiResponse(responseCode = "204", description = "Logout successful"),
|
||||
@ApiResponse(responseCode = "401", description = "Invalid or missing authentication token")
|
||||
})
|
||||
public ResponseEntity<Void> logout(HttpServletRequest request, Authentication authentication) {
|
||||
String token = extractTokenFromRequest(request);
|
||||
|
|
@ -172,6 +107,26 @@ public class AuthController {
|
|||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
@Operation(summary = "Refresh access token", description = "Refresh an expired access token using a valid refresh token.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Token refresh successful",
|
||||
content = @Content(schema = @Schema(implementation = LoginResponse.class))),
|
||||
@ApiResponse(responseCode = "401", description = "Invalid or expired refresh token")
|
||||
})
|
||||
public ResponseEntity<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
|
||||
logger.info("Token refresh attempt");
|
||||
|
||||
try {
|
||||
SessionToken token = sessionManager.refreshSession(request.refreshToken());
|
||||
logger.info("Token refresh successful");
|
||||
return ResponseEntity.ok(LoginResponse.from(token));
|
||||
} catch (RuntimeException ex) {
|
||||
logger.warn("Token refresh failed");
|
||||
throw new AuthenticationFailedException(new UserError.InvalidCredentials());
|
||||
}
|
||||
}
|
||||
|
||||
private String extractTokenFromRequest(HttpServletRequest request) {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
|
|
@ -180,76 +135,14 @@ public class AuthController {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token endpoint.
|
||||
*
|
||||
* Refreshes an expired access token using a valid refresh token.
|
||||
* Returns new JWT access token and refresh token.
|
||||
*
|
||||
* POST /api/auth/refresh
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* "refreshToken": "refresh-token-here"
|
||||
* }
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "accessToken": "new-access-token",
|
||||
* "tokenType": "Bearer",
|
||||
* "expiresIn": 3600,
|
||||
* "expiresAt": "2026-02-17T15:30:00",
|
||||
* "refreshToken": "new-refresh-token"
|
||||
* }
|
||||
*
|
||||
* Error Responses:
|
||||
* - 401 Unauthorized: Invalid or expired refresh token
|
||||
* - 400 Bad Request: Validation error (missing refresh token)
|
||||
*
|
||||
* @param request Refresh token request
|
||||
* @return LoginResponse with new JWT tokens
|
||||
*/
|
||||
@PostMapping("/refresh")
|
||||
@Operation(
|
||||
summary = "Refresh access token",
|
||||
description = "Refresh an expired access token using a valid refresh token. Returns new access token and refresh token."
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Token refresh successful",
|
||||
content = @Content(schema = @Schema(implementation = LoginResponse.class))
|
||||
),
|
||||
@ApiResponse(
|
||||
responseCode = "401",
|
||||
description = "Invalid or expired refresh token"
|
||||
),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Validation error (missing refresh token)"
|
||||
)
|
||||
})
|
||||
public ResponseEntity<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
|
||||
logger.info("Token refresh attempt");
|
||||
|
||||
try {
|
||||
SessionToken token = sessionManager.refreshSession(request.refreshToken());
|
||||
logger.info("Token refresh successful");
|
||||
|
||||
return ResponseEntity.ok(LoginResponse.from(token));
|
||||
} catch (RuntimeException ex) {
|
||||
logger.warn("Token refresh failed: {}", ex.getMessage());
|
||||
logger.trace("Token refresh exception details", ex);
|
||||
throw new AuthenticationFailedException(
|
||||
new UserError.InvalidCredentials()
|
||||
);
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String xForwardedFor = request.getHeader("X-Forwarded-For");
|
||||
if (xForwardedFor != null && !xForwardedFor.isBlank()) {
|
||||
return xForwardedFor.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom runtime exception to wrap UserError for authentication failures.
|
||||
* This allows the GlobalExceptionHandler to catch and convert it properly.
|
||||
*/
|
||||
public static class AuthenticationFailedException extends RuntimeException {
|
||||
private final UserError error;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
package de.effigenix.infrastructure.usermanagement.web.controller;
|
||||
|
||||
import de.effigenix.application.usermanagement.*;
|
||||
import de.effigenix.application.usermanagement.command.AssignRoleCommand;
|
||||
import de.effigenix.application.usermanagement.command.ChangePasswordCommand;
|
||||
import de.effigenix.application.usermanagement.command.CreateUserCommand;
|
||||
import de.effigenix.application.usermanagement.command.UpdateUserCommand;
|
||||
import de.effigenix.application.usermanagement.command.*;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
import de.effigenix.domain.usermanagement.User;
|
||||
import de.effigenix.domain.usermanagement.UserError;
|
||||
import de.effigenix.infrastructure.usermanagement.web.dto.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
|
@ -34,21 +30,9 @@ import java.util.List;
|
|||
/**
|
||||
* REST Controller for User Management endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /api/users - Create user (ADMIN only)
|
||||
* - GET /api/users - List all users
|
||||
* - GET /api/users/{id} - Get user by ID
|
||||
* - PUT /api/users/{id} - Update user
|
||||
* - POST /api/users/{id}/lock - Lock user (ADMIN only)
|
||||
* - POST /api/users/{id}/unlock - Unlock user (ADMIN only)
|
||||
* - POST /api/users/{id}/roles - Assign role (ADMIN only)
|
||||
* - DELETE /api/users/{id}/roles/{roleName} - Remove role (ADMIN only)
|
||||
* - PUT /api/users/{id}/password - Change password
|
||||
*
|
||||
* Security:
|
||||
* - All endpoints require authentication (JWT token)
|
||||
* - ADMIN-only endpoints check for USER_MANAGEMENT permission
|
||||
* - Users can change their own password
|
||||
* - @PreAuthorize as defense-in-depth (authorization also enforced in use cases)
|
||||
*
|
||||
* Infrastructure Layer → REST API
|
||||
*/
|
||||
|
|
@ -92,54 +76,14 @@ public class UserController {
|
|||
this.changePassword = changePassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user endpoint.
|
||||
*
|
||||
* Creates a new user account with specified roles.
|
||||
* Requires ADMIN permission (USER_MANAGEMENT).
|
||||
*
|
||||
* POST /api/users
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "password": "SecurePass123",
|
||||
* "roleNames": ["USER", "MANAGER"],
|
||||
* "branchId": "BRANCH-001"
|
||||
* }
|
||||
*
|
||||
* Response (201 Created):
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-001",
|
||||
* "status": "ACTIVE",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": null
|
||||
* }
|
||||
*
|
||||
* @param request Create user request
|
||||
* @param authentication Current authentication
|
||||
* @return Created user DTO
|
||||
*/
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('USER_WRITE')")
|
||||
@Operation(
|
||||
summary = "Create user (ADMIN only)",
|
||||
description = "Create a new user account with specified roles. Requires USER_MANAGEMENT permission."
|
||||
)
|
||||
@Operation(summary = "Create user (ADMIN only)", description = "Create a new user account with specified roles.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "201",
|
||||
description = "User created successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "201", description = "User created successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))),
|
||||
@ApiResponse(responseCode = "400", description = "Validation error or invalid password"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing permission"),
|
||||
@ApiResponse(responseCode = "409", description = "Username or email already exists")
|
||||
})
|
||||
public ResponseEntity<UserDTO> createUser(
|
||||
|
|
@ -149,508 +93,202 @@ public class UserController {
|
|||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Creating user: {} by actor: {}", request.username(), actorId.value());
|
||||
|
||||
// Note: Authorization is checked via @PreAuthorize annotation
|
||||
// No need for additional manual authorization check here
|
||||
|
||||
// Execute use case
|
||||
CreateUserCommand command = new CreateUserCommand(
|
||||
request.username(),
|
||||
request.email(),
|
||||
request.password(),
|
||||
request.roleNames(),
|
||||
request.branchId()
|
||||
request.username(), request.email(), request.password(),
|
||||
request.roleNames(), request.branchId()
|
||||
);
|
||||
|
||||
Result<UserError, UserDTO> result = createUser.execute(command, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
|
||||
|
||||
logger.info("User created successfully: {}", request.username());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* List users endpoint.
|
||||
*
|
||||
* Lists all users in the system.
|
||||
* Returns simplified user information.
|
||||
*
|
||||
* GET /api/users
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (200 OK):
|
||||
* [
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-001",
|
||||
* "status": "ACTIVE",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": "2026-02-17T14:30:00"
|
||||
* }
|
||||
* ]
|
||||
*
|
||||
* @param authentication Current authentication
|
||||
* @return List of user DTOs
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "List all users",
|
||||
description = "Get a list of all users in the system. Requires authentication."
|
||||
)
|
||||
@Operation(summary = "List all users", description = "Get a list of all users in the system.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Users retrieved successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "200", description = "Users retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
})
|
||||
public ResponseEntity<List<UserDTO>> listUsers(Authentication authentication) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Listing users by actor: {}", actorId.value());
|
||||
|
||||
Result<UserError, List<UserDTO>> result = listUsers.execute();
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
Result<UserError, List<UserDTO>> result = listUsers.execute(actorId);
|
||||
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
|
||||
|
||||
return ResponseEntity.ok(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID endpoint.
|
||||
*
|
||||
* Retrieves a single user by their ID.
|
||||
*
|
||||
* GET /api/users/{id}
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-001",
|
||||
* "status": "ACTIVE",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": "2026-02-17T14:30:00"
|
||||
* }
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param authentication Current authentication
|
||||
* @return User DTO
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
@Operation(
|
||||
summary = "Get user by ID",
|
||||
description = "Retrieve a single user by their ID. Requires authentication."
|
||||
)
|
||||
@Operation(summary = "Get user by ID", description = "Retrieve a single user by their ID.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User retrieved successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "200", description = "User retrieved successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))),
|
||||
@ApiResponse(responseCode = "404", description = "User not found"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
})
|
||||
public ResponseEntity<UserDTO> getUserById(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
@Parameter(description = "User ID") @PathVariable("id") String userId,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Getting user: {} by actor: {}", userId, actorId.value());
|
||||
|
||||
Result<UserError, UserDTO> result = getUser.execute(userId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
Result<UserError, UserDTO> result = getUser.execute(userId, actorId);
|
||||
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
|
||||
|
||||
return ResponseEntity.ok(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user endpoint.
|
||||
*
|
||||
* Updates user details (email, branchId).
|
||||
* Only provided fields will be updated.
|
||||
*
|
||||
* PUT /api/users/{id}
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* "email": "newemail@example.com",
|
||||
* "branchId": "BRANCH-002"
|
||||
* }
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "newemail@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-002",
|
||||
* "status": "ACTIVE",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": "2026-02-17T14:30:00"
|
||||
* }
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param request Update user request
|
||||
* @param authentication Current authentication
|
||||
* @return Updated user DTO
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@Operation(
|
||||
summary = "Update user",
|
||||
description = "Update user details (email, branchId). Only provided fields will be updated."
|
||||
)
|
||||
@Operation(summary = "Update user", description = "Update user details (email, branchId).")
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User updated successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "200", description = "User updated successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))),
|
||||
@ApiResponse(responseCode = "404", description = "User not found"),
|
||||
@ApiResponse(responseCode = "409", description = "Email already exists"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
@ApiResponse(responseCode = "409", description = "Email already exists")
|
||||
})
|
||||
public ResponseEntity<UserDTO> updateUser(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
@Parameter(description = "User ID") @PathVariable("id") String userId,
|
||||
@Valid @RequestBody UpdateUserRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Updating user: {} by actor: {}", userId, actorId.value());
|
||||
|
||||
UpdateUserCommand command = new UpdateUserCommand(
|
||||
userId,
|
||||
request.email(),
|
||||
request.branchId()
|
||||
);
|
||||
|
||||
UpdateUserCommand command = new UpdateUserCommand(userId, request.email(), request.branchId());
|
||||
Result<UserError, UserDTO> result = updateUser.execute(command, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
|
||||
|
||||
logger.info("User updated successfully: {}", userId);
|
||||
return ResponseEntity.ok(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock user endpoint.
|
||||
*
|
||||
* Locks a user account (prevents login).
|
||||
* Requires ADMIN permission (USER_MANAGEMENT).
|
||||
*
|
||||
* POST /api/users/{id}/lock
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-001",
|
||||
* "status": "LOCKED",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": "2026-02-17T14:30:00"
|
||||
* }
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param authentication Current authentication
|
||||
* @return Updated user DTO
|
||||
*/
|
||||
@PostMapping("/{id}/lock")
|
||||
@PreAuthorize("hasAuthority('USER_LOCK')")
|
||||
@Operation(
|
||||
summary = "Lock user (ADMIN only)",
|
||||
description = "Lock a user account (prevents login). Requires USER_MANAGEMENT permission."
|
||||
)
|
||||
@Operation(summary = "Lock user (ADMIN only)", description = "Lock a user account (prevents login).")
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User locked successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "200", description = "User locked successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))),
|
||||
@ApiResponse(responseCode = "404", description = "User not found"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
@ApiResponse(responseCode = "409", description = "Invalid status transition"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing permission")
|
||||
})
|
||||
public ResponseEntity<UserDTO> lockUser(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
@Parameter(description = "User ID") @PathVariable("id") String userId,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Locking user: {} by actor: {}", userId, actorId.value());
|
||||
|
||||
Result<UserError, UserDTO> result = lockUser.execute(userId, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
LockUserCommand command = new LockUserCommand(userId);
|
||||
Result<UserError, UserDTO> result = lockUser.execute(command, actorId);
|
||||
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
|
||||
|
||||
logger.info("User locked successfully: {}", userId);
|
||||
return ResponseEntity.ok(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock user endpoint.
|
||||
*
|
||||
* Unlocks a user account (allows login).
|
||||
* Requires ADMIN permission (USER_MANAGEMENT).
|
||||
*
|
||||
* POST /api/users/{id}/unlock
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-001",
|
||||
* "status": "ACTIVE",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": "2026-02-17T14:30:00"
|
||||
* }
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param authentication Current authentication
|
||||
* @return Updated user DTO
|
||||
*/
|
||||
@PostMapping("/{id}/unlock")
|
||||
@PreAuthorize("hasAuthority('USER_UNLOCK')")
|
||||
@Operation(
|
||||
summary = "Unlock user (ADMIN only)",
|
||||
description = "Unlock a user account (allows login). Requires USER_MANAGEMENT permission."
|
||||
)
|
||||
@Operation(summary = "Unlock user (ADMIN only)", description = "Unlock a user account (allows login).")
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User unlocked successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "200", description = "User unlocked successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))),
|
||||
@ApiResponse(responseCode = "404", description = "User not found"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
@ApiResponse(responseCode = "409", description = "Invalid status transition"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing permission")
|
||||
})
|
||||
public ResponseEntity<UserDTO> unlockUser(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
@Parameter(description = "User ID") @PathVariable("id") String userId,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Unlocking user: {} by actor: {}", userId, actorId.value());
|
||||
|
||||
Result<UserError, UserDTO> result = unlockUser.execute(userId, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
UnlockUserCommand command = new UnlockUserCommand(userId);
|
||||
Result<UserError, UserDTO> result = unlockUser.execute(command, actorId);
|
||||
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
|
||||
|
||||
logger.info("User unlocked successfully: {}", userId);
|
||||
return ResponseEntity.ok(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign role endpoint.
|
||||
*
|
||||
* Assigns a role to a user.
|
||||
* Requires ADMIN permission (USER_MANAGEMENT).
|
||||
*
|
||||
* POST /api/users/{id}/roles
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* "roleName": "MANAGER"
|
||||
* }
|
||||
*
|
||||
* Response (200 OK):
|
||||
* {
|
||||
* "id": "user-uuid",
|
||||
* "username": "john.doe",
|
||||
* "email": "john.doe@example.com",
|
||||
* "roles": [...],
|
||||
* "branchId": "BRANCH-001",
|
||||
* "status": "ACTIVE",
|
||||
* "createdAt": "2026-02-17T12:00:00",
|
||||
* "lastLogin": "2026-02-17T14:30:00"
|
||||
* }
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param request Assign role request
|
||||
* @param authentication Current authentication
|
||||
* @return Updated user DTO
|
||||
*/
|
||||
@PostMapping("/{id}/roles")
|
||||
@PreAuthorize("hasAuthority('ROLE_ASSIGN')")
|
||||
@Operation(
|
||||
summary = "Assign role (ADMIN only)",
|
||||
description = "Assign a role to a user. Requires USER_MANAGEMENT permission."
|
||||
)
|
||||
@Operation(summary = "Assign role (ADMIN only)", description = "Assign a role to a user.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Role assigned successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))
|
||||
),
|
||||
@ApiResponse(responseCode = "200", description = "Role assigned successfully",
|
||||
content = @Content(schema = @Schema(implementation = UserDTO.class))),
|
||||
@ApiResponse(responseCode = "404", description = "User or role not found"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
@ApiResponse(responseCode = "403", description = "Missing permission")
|
||||
})
|
||||
public ResponseEntity<UserDTO> assignRole(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
@Parameter(description = "User ID") @PathVariable("id") String userId,
|
||||
@Valid @RequestBody AssignRoleRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Assigning role {} to user: {} by actor: {}",
|
||||
request.roleName(), userId, actorId.value());
|
||||
logger.info("Assigning role {} to user: {} by actor: {}", request.roleName(), userId, actorId.value());
|
||||
|
||||
AssignRoleCommand command = new AssignRoleCommand(userId, request.roleName());
|
||||
Result<UserError, UserDTO> result = assignRole.execute(command, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
|
||||
|
||||
logger.info("Role assigned successfully to user: {}", userId);
|
||||
return ResponseEntity.ok(result.unsafeGetValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove role endpoint.
|
||||
*
|
||||
* Removes a role from a user.
|
||||
* Requires ADMIN permission (USER_MANAGEMENT).
|
||||
*
|
||||
* DELETE /api/users/{id}/roles/{roleName}
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Response (204 No Content):
|
||||
* (empty body)
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param roleName Role name to remove
|
||||
* @param authentication Current authentication
|
||||
* @return Empty response
|
||||
*/
|
||||
@DeleteMapping("/{id}/roles/{roleName}")
|
||||
@PreAuthorize("hasAuthority('ROLE_REMOVE')")
|
||||
@Operation(
|
||||
summary = "Remove role (ADMIN only)",
|
||||
description = "Remove a role from a user. Requires USER_MANAGEMENT permission."
|
||||
)
|
||||
@Operation(summary = "Remove role (ADMIN only)", description = "Remove a role from a user.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "204", description = "Role removed successfully"),
|
||||
@ApiResponse(responseCode = "404", description = "User or role not found"),
|
||||
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
|
||||
@ApiResponse(responseCode = "401", description = "Authentication required")
|
||||
@ApiResponse(responseCode = "403", description = "Missing permission")
|
||||
})
|
||||
public ResponseEntity<Void> removeRole(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
@Parameter(description = "Role name", example = "MANAGER")
|
||||
@PathVariable("roleName") RoleName roleName,
|
||||
@Parameter(description = "User ID") @PathVariable("id") String userId,
|
||||
@Parameter(description = "Role name") @PathVariable("roleName") RoleName roleName,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Removing role {} from user: {} by actor: {}",
|
||||
roleName, userId, actorId.value());
|
||||
logger.info("Removing role {} from user: {} by actor: {}", roleName, userId, actorId.value());
|
||||
|
||||
Result<UserError, UserDTO> result = removeRole.execute(userId, roleName, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
RemoveRoleCommand command = new RemoveRoleCommand(userId, roleName);
|
||||
Result<UserError, UserDTO> result = removeRole.execute(command, actorId);
|
||||
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
|
||||
|
||||
logger.info("Role removed successfully from user: {}", userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change password endpoint.
|
||||
*
|
||||
* Changes a user's password.
|
||||
* Requires current password for verification.
|
||||
* Users can change their own password.
|
||||
*
|
||||
* PUT /api/users/{id}/password
|
||||
* Authorization: Bearer <access-token>
|
||||
*
|
||||
* Request Body:
|
||||
* {
|
||||
* "currentPassword": "OldPass123",
|
||||
* "newPassword": "NewSecurePass456"
|
||||
* }
|
||||
*
|
||||
* Response (204 No Content):
|
||||
* (empty body)
|
||||
*
|
||||
* @param userId User ID
|
||||
* @param request Change password request
|
||||
* @param authentication Current authentication
|
||||
* @return Empty response
|
||||
*/
|
||||
@PutMapping("/{id}/password")
|
||||
@Operation(
|
||||
summary = "Change password",
|
||||
description = "Change user password. Requires current password for verification."
|
||||
)
|
||||
@Operation(summary = "Change password", description = "Change user password. Requires current password for verification.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "204", description = "Password changed successfully"),
|
||||
@ApiResponse(responseCode = "400", description = "Invalid password"),
|
||||
@ApiResponse(responseCode = "401", description = "Invalid current password or authentication required"),
|
||||
@ApiResponse(responseCode = "401", description = "Invalid current password"),
|
||||
@ApiResponse(responseCode = "404", description = "User not found")
|
||||
})
|
||||
public ResponseEntity<Void> changePassword(
|
||||
@Parameter(description = "User ID", example = "user-uuid")
|
||||
@PathVariable("id") String userId,
|
||||
@Parameter(description = "User ID") @PathVariable("id") String userId,
|
||||
@Valid @RequestBody ChangePasswordRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
ActorId actorId = extractActorId(authentication);
|
||||
logger.info("Changing password for user: {} by actor: {}", userId, actorId.value());
|
||||
|
||||
ChangePasswordCommand command = new ChangePasswordCommand(
|
||||
userId,
|
||||
request.currentPassword(),
|
||||
request.newPassword()
|
||||
);
|
||||
|
||||
ChangePasswordCommand command = new ChangePasswordCommand(userId, request.currentPassword(), request.newPassword());
|
||||
Result<UserError, Void> result = changePassword.execute(command, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new DomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
|
||||
|
||||
logger.info("Password changed successfully for user: {}", userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* Extracts ActorId from Spring Security Authentication.
|
||||
*/
|
||||
private ActorId extractActorId(Authentication authentication) {
|
||||
if (authentication == null || authentication.getName() == null) {
|
||||
throw new IllegalStateException("No authentication found in SecurityContext");
|
||||
|
|
@ -658,10 +296,6 @@ public class UserController {
|
|||
return ActorId.of(authentication.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom exception to wrap UserError for domain failures.
|
||||
* This exception is caught by GlobalExceptionHandler.
|
||||
*/
|
||||
public static class DomainErrorException extends RuntimeException {
|
||||
private final UserError error;
|
||||
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ public class GlobalExceptionHandler {
|
|||
|
||||
ErrorResponse errorResponse = ErrorResponse.from(
|
||||
"AUTHENTICATION_FAILED",
|
||||
"Authentication failed: " + ex.getMessage(),
|
||||
"Authentication failed",
|
||||
HttpStatus.UNAUTHORIZED.value(),
|
||||
request.getRequestURI()
|
||||
);
|
||||
|
|
@ -312,7 +312,7 @@ public class GlobalExceptionHandler {
|
|||
|
||||
ErrorResponse errorResponse = ErrorResponse.from(
|
||||
"ACCESS_DENIED",
|
||||
"Access denied: " + ex.getMessage(),
|
||||
"Access denied",
|
||||
HttpStatus.FORBIDDEN.value(),
|
||||
request.getRequestURI()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ public final class UserErrorHttpStatusMapper {
|
|||
case UserError.InvalidUsername e -> 400;
|
||||
case UserError.NullPasswordHash e -> 400;
|
||||
case UserError.NullRole e -> 400;
|
||||
case UserError.NullPermission e -> 400;
|
||||
case UserError.InvalidStatusTransition e -> 409;
|
||||
case UserError.InvalidInput e -> 400;
|
||||
case UserError.RepositoryFailure e -> 500;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
17
backend/src/main/resources/application-prod.yml
Normal file
17
backend/src/main/resources/application-prod.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
swagger-ui:
|
||||
enabled: false
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
de.effigenix: INFO
|
||||
org.springframework.security: WARN
|
||||
org.hibernate.SQL: WARN
|
||||
|
||||
server:
|
||||
error:
|
||||
include-message: never
|
||||
include-binding-errors: never
|
||||
|
|
@ -47,10 +47,16 @@ logging:
|
|||
org.springframework.security: DEBUG
|
||||
org.hibernate.SQL: DEBUG
|
||||
|
||||
# CORS Configuration
|
||||
effigenix:
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
|
||||
|
||||
# API Documentation
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
enabled: ${SWAGGER_ENABLED:true}
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
enabled: true
|
||||
enabled: ${SWAGGER_ENABLED:true}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,18 @@
|
|||
-- Seed Admin User for initial system access
|
||||
-- Username: admin
|
||||
-- Password: admin123
|
||||
-- BCrypt hash with strength 12
|
||||
|
||||
-- Insert Admin User
|
||||
INSERT INTO users (id, username, email, password_hash, branch_id, status, created_at, last_login)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001', -- Fixed UUID for admin
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'admin',
|
||||
'admin@effigenix.com',
|
||||
'$2a$12$SJmX80hUZoA66W77CX7cHeRw1TPscXD6S8HYEZfhJ5PxTfkbwbLdi', -- BCrypt hash for "admin123"
|
||||
NULL, -- No branch = global access
|
||||
'$2a$12$SJmX80hUZoA66W77CX7cHeRw1TPscXD6S8HYEZfhJ5PxTfkbwbLdi',
|
||||
NULL,
|
||||
'ACTIVE',
|
||||
CURRENT_TIMESTAMP,
|
||||
NULL
|
||||
);
|
||||
|
||||
-- Assign ADMIN role to admin user
|
||||
INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT '00000000-0000-0000-0000-000000000001', id
|
||||
FROM roles
|
||||
WHERE name = 'ADMIN';
|
||||
|
||||
-- Add comment
|
||||
COMMENT ON TABLE users IS 'Default admin user: username=admin, password=admin123 (CHANGE IN PRODUCTION!)';
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<include file="db/changelog/changes/001-create-user-management-schema.xml"/>
|
||||
<include file="db/changelog/changes/002-seed-roles-and-permissions.xml"/>
|
||||
<include file="db/changelog/changes/003-create-audit-logs-table.xml"/>
|
||||
<include file="db/changelog/changes/004-seed-admin-user.xml"/>
|
||||
<include file="db/changelog/changes/004-seed-admin-user.xml" context="dev"/>
|
||||
<include file="db/changelog/changes/005-create-masterdata-schema.xml"/>
|
||||
<include file="db/changelog/changes/006-create-supplier-schema.xml"/>
|
||||
<include file="db/changelog/changes/007-create-customer-schema.xml"/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.AssignRoleCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("AssignRole Use Case")
|
||||
class AssignRoleTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private RoleRepository roleRepository;
|
||||
@Mock private AuditLogger auditLogger;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
|
||||
private AssignRole assignRole;
|
||||
private ActorId performedBy;
|
||||
private User testUser;
|
||||
private Role workerRole;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
assignRole = new AssignRole(userRepository, roleRepository, auditLogger, authPort);
|
||||
performedBy = ActorId.of("admin-user");
|
||||
testUser = User.reconstitute(
|
||||
UserId.of("user-1"), "john.doe", "john@example.com",
|
||||
new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW"),
|
||||
new HashSet<>(), "branch-1", UserStatus.ACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
workerRole = Role.reconstitute(RoleId.generate(), RoleName.PRODUCTION_WORKER, new HashSet<>(), "Production Worker");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_AssignRole_When_ValidCommandProvided")
|
||||
void should_AssignRole_When_ValidCommandProvided() {
|
||||
when(authPort.can(performedBy, UserManagementAction.ROLE_ASSIGN)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-1"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(roleRepository.findByName(RoleName.PRODUCTION_WORKER)).thenReturn(Result.success(Optional.of(workerRole)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
Result<UserError, UserDTO> result = assignRole.execute(
|
||||
new AssignRoleCommand("user-1", RoleName.PRODUCTION_WORKER), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().roles()).hasSize(1);
|
||||
verify(userRepository).save(argThat(user -> user.roles().contains(workerRole)));
|
||||
verify(auditLogger).log(eq(AuditEvent.ROLE_ASSIGNED), eq("user-1"), anyString(), eq(performedBy));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUnauthorized_When_ActorLacksPermission")
|
||||
void should_FailWithUnauthorized_When_ActorLacksPermission() {
|
||||
when(authPort.can(performedBy, UserManagementAction.ROLE_ASSIGN)).thenReturn(false);
|
||||
|
||||
Result<UserError, UserDTO> result = assignRole.execute(
|
||||
new AssignRoleCommand("user-1", RoleName.PRODUCTION_WORKER), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.Unauthorized.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidInput_When_UserIdIsBlank")
|
||||
void should_FailWithInvalidInput_When_UserIdIsBlank() {
|
||||
when(authPort.can(performedBy, UserManagementAction.ROLE_ASSIGN)).thenReturn(true);
|
||||
|
||||
Result<UserError, UserDTO> result = assignRole.execute(
|
||||
new AssignRoleCommand("", RoleName.PRODUCTION_WORKER), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidInput.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidInput_When_RoleNameIsNull")
|
||||
void should_FailWithInvalidInput_When_RoleNameIsNull() {
|
||||
when(authPort.can(performedBy, UserManagementAction.ROLE_ASSIGN)).thenReturn(true);
|
||||
|
||||
Result<UserError, UserDTO> result = assignRole.execute(
|
||||
new AssignRoleCommand("user-1", null), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidInput.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUserNotFound_When_UserDoesNotExist")
|
||||
void should_FailWithUserNotFound_When_UserDoesNotExist() {
|
||||
when(authPort.can(performedBy, UserManagementAction.ROLE_ASSIGN)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("nonexistent"))).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
Result<UserError, UserDTO> result = assignRole.execute(
|
||||
new AssignRoleCommand("nonexistent", RoleName.PRODUCTION_WORKER), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.UserNotFound.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithRoleNotFound_When_RoleDoesNotExist")
|
||||
void should_FailWithRoleNotFound_When_RoleDoesNotExist() {
|
||||
when(authPort.can(performedBy, UserManagementAction.ROLE_ASSIGN)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-1"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(roleRepository.findByName(RoleName.ADMIN)).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
Result<UserError, UserDTO> result = assignRole.execute(
|
||||
new AssignRoleCommand("user-1", RoleName.ADMIN), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.RoleNotFound.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
|
@ -21,25 +21,14 @@ import static org.assertj.core.api.Assertions.*;
|
|||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for AuthenticateUser Use Case.
|
||||
* Tests authentication flow, credential validation, status checks, and audit logging.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("AuthenticateUser Use Case")
|
||||
class AuthenticateUserTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private PasswordHasher passwordHasher;
|
||||
|
||||
@Mock
|
||||
private SessionManager sessionManager;
|
||||
|
||||
@Mock
|
||||
private AuditLogger auditLogger;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private PasswordHasher passwordHasher;
|
||||
@Mock private SessionManager sessionManager;
|
||||
@Mock private AuditLogger auditLogger;
|
||||
|
||||
@InjectMocks
|
||||
private AuthenticateUser authenticateUser;
|
||||
|
|
@ -55,15 +44,8 @@ class AuthenticateUserTest {
|
|||
validPasswordHash = new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW");
|
||||
|
||||
testUser = User.reconstitute(
|
||||
UserId.of("user-1"),
|
||||
"john.doe",
|
||||
"john@example.com",
|
||||
validPasswordHash,
|
||||
new HashSet<>(),
|
||||
"branch-1",
|
||||
UserStatus.ACTIVE,
|
||||
LocalDateTime.now(),
|
||||
null
|
||||
UserId.of("user-1"), "john.doe", "john@example.com", validPasswordHash,
|
||||
new HashSet<>(), "branch-1", UserStatus.ACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
|
||||
sessionToken = new SessionToken("jwt-token", "Bearer", 3600L, LocalDateTime.now().plusSeconds(3600), "refresh-token");
|
||||
|
|
@ -72,16 +54,13 @@ class AuthenticateUserTest {
|
|||
@Test
|
||||
@DisplayName("should_AuthenticateUser_When_ValidCredentialsProvided")
|
||||
void should_AuthenticateUser_When_ValidCredentialsProvided() {
|
||||
// Arrange
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("Password123!", validPasswordHash)).thenReturn(true);
|
||||
when(sessionManager.createSession(testUser)).thenReturn(sessionToken);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(validCommand);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue()).isEqualTo(sessionToken);
|
||||
verify(userRepository).save(any());
|
||||
|
|
@ -91,14 +70,10 @@ class AuthenticateUserTest {
|
|||
@Test
|
||||
@DisplayName("should_FailWithInvalidCredentials_When_UserNotFound")
|
||||
void should_FailWithInvalidCredentials_When_UserNotFound() {
|
||||
// Arrange
|
||||
when(userRepository.findByUsername("nonexistent")).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
// Act
|
||||
AuthenticateCommand command = new AuthenticateCommand("nonexistent", "Password123!");
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(command);
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(new AuthenticateCommand("nonexistent", "Password123!"));
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidCredentials.class);
|
||||
verify(auditLogger).log(eq(AuditEvent.LOGIN_FAILED), anyString());
|
||||
|
|
@ -107,25 +82,14 @@ class AuthenticateUserTest {
|
|||
@Test
|
||||
@DisplayName("should_FailWithLockedUser_When_UserStatusIsLocked")
|
||||
void should_FailWithLockedUser_When_UserStatusIsLocked() {
|
||||
// Arrange
|
||||
User lockedUser = User.reconstitute(
|
||||
UserId.of("user-2"),
|
||||
"john.doe",
|
||||
"john@example.com",
|
||||
validPasswordHash,
|
||||
new HashSet<>(),
|
||||
"branch-1",
|
||||
UserStatus.LOCKED,
|
||||
LocalDateTime.now(),
|
||||
null
|
||||
UserId.of("user-2"), "john.doe", "john@example.com", validPasswordHash,
|
||||
new HashSet<>(), "branch-1", UserStatus.LOCKED, LocalDateTime.now(), null
|
||||
);
|
||||
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(lockedUser)));
|
||||
|
||||
// Act
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(validCommand);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.UserLocked.class);
|
||||
verify(auditLogger).log(eq(AuditEvent.LOGIN_BLOCKED), anyString(), any());
|
||||
|
|
@ -134,209 +98,57 @@ class AuthenticateUserTest {
|
|||
@Test
|
||||
@DisplayName("should_FailWithInactiveUser_When_UserStatusIsInactive")
|
||||
void should_FailWithInactiveUser_When_UserStatusIsInactive() {
|
||||
// Arrange
|
||||
User inactiveUser = User.reconstitute(
|
||||
UserId.of("user-3"),
|
||||
"john.doe",
|
||||
"john@example.com",
|
||||
validPasswordHash,
|
||||
new HashSet<>(),
|
||||
"branch-1",
|
||||
UserStatus.INACTIVE,
|
||||
LocalDateTime.now(),
|
||||
null
|
||||
UserId.of("user-3"), "john.doe", "john@example.com", validPasswordHash,
|
||||
new HashSet<>(), "branch-1", UserStatus.INACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(inactiveUser)));
|
||||
|
||||
// Act
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(validCommand);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.UserInactive.class);
|
||||
verify(auditLogger).log(eq(AuditEvent.LOGIN_FAILED), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidCredentials_When_PasswordDoesNotMatch")
|
||||
void should_FailWithInvalidCredentials_When_PasswordDoesNotMatch() {
|
||||
// Arrange
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("WrongPassword", validPasswordHash)).thenReturn(false);
|
||||
|
||||
// Act
|
||||
AuthenticateCommand command = new AuthenticateCommand("john.doe", "WrongPassword");
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(command);
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(new AuthenticateCommand("john.doe", "WrongPassword"));
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidCredentials.class);
|
||||
verify(auditLogger).log(eq(AuditEvent.LOGIN_FAILED), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_CreateSessionToken_When_AuthenticationSucceeds")
|
||||
void should_CreateSessionToken_When_AuthenticationSucceeds() {
|
||||
// Arrange
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("Password123!", validPasswordHash)).thenReturn(true);
|
||||
when(sessionManager.createSession(testUser)).thenReturn(sessionToken);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(validCommand);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
verify(sessionManager).createSession(testUser);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_UpdateLastLoginTimestamp_When_AuthenticationSucceeds")
|
||||
void should_UpdateLastLoginTimestamp_When_AuthenticationSucceeds() {
|
||||
// Arrange
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("Password123!", validPasswordHash)).thenReturn(true);
|
||||
when(sessionManager.createSession(testUser)).thenReturn(sessionToken);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
LocalDateTime before = LocalDateTime.now();
|
||||
|
||||
// Act
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(validCommand);
|
||||
|
||||
LocalDateTime after = LocalDateTime.now();
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(testUser.lastLogin()).isBetween(before, after);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_SaveUserWithUpdatedLastLogin_When_AuthenticationSucceeds")
|
||||
void should_SaveUserWithUpdatedLastLogin_When_AuthenticationSucceeds() {
|
||||
// Arrange
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("Password123!", validPasswordHash)).thenReturn(true);
|
||||
when(sessionManager.createSession(testUser)).thenReturn(sessionToken);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
authenticateUser.execute(validCommand);
|
||||
|
||||
// Assert
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_LogLoginSuccessAuditEvent_When_AuthenticationSucceeds")
|
||||
void should_LogLoginSuccessAuditEvent_When_AuthenticationSucceeds() {
|
||||
// Arrange
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("Password123!", validPasswordHash)).thenReturn(true);
|
||||
when(sessionManager.createSession(testUser)).thenReturn(sessionToken);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(validCommand);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
verify(auditLogger).log(eq(AuditEvent.LOGIN_SUCCESS), eq("user-1"), any(ActorId.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_LogLoginFailureAuditEvent_When_PasswordIncorrect")
|
||||
void should_LogLoginFailureAuditEvent_When_PasswordIncorrect() {
|
||||
// Arrange
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("WrongPassword", validPasswordHash)).thenReturn(false);
|
||||
|
||||
// Act
|
||||
AuthenticateCommand command = new AuthenticateCommand("john.doe", "WrongPassword");
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(command);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
verify(auditLogger).log(eq(AuditEvent.LOGIN_FAILED), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_VerifyPasswordBeforeCheckingStatus_When_UserExists")
|
||||
void should_VerifyPasswordBeforeCheckingStatus_When_UserExists() {
|
||||
// Arrange
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("Password123!", validPasswordHash)).thenReturn(true);
|
||||
when(sessionManager.createSession(testUser)).thenReturn(sessionToken);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
authenticateUser.execute(validCommand);
|
||||
|
||||
// Assert
|
||||
verify(passwordHasher).verify("Password123!", validPasswordHash);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_CheckStatusBeforeCreatingSession_When_UserActive")
|
||||
void should_CheckStatusBeforeCreatingSession_When_UserActive() {
|
||||
// Arrange
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("Password123!", validPasswordHash)).thenReturn(true);
|
||||
when(sessionManager.createSession(testUser)).thenReturn(sessionToken);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(validCommand);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
// Session should be created only for active users
|
||||
verify(sessionManager).createSession(testUser);
|
||||
// Verify save was called with a user (immutable, so it's a new instance)
|
||||
verify(userRepository).save(argThat(user -> user.lastLogin() != null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_NotCreateSession_When_UserLocked")
|
||||
void should_NotCreateSession_When_UserLocked() {
|
||||
// Arrange
|
||||
User lockedUser = User.reconstitute(
|
||||
UserId.of("user-4"),
|
||||
"john.doe",
|
||||
"john@example.com",
|
||||
validPasswordHash,
|
||||
new HashSet<>(),
|
||||
"branch-1",
|
||||
UserStatus.LOCKED,
|
||||
LocalDateTime.now(),
|
||||
null
|
||||
UserId.of("user-4"), "john.doe", "john@example.com", validPasswordHash,
|
||||
new HashSet<>(), "branch-1", UserStatus.LOCKED, LocalDateTime.now(), null
|
||||
);
|
||||
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(lockedUser)));
|
||||
|
||||
// Act
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(validCommand);
|
||||
authenticateUser.execute(validCommand);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
verify(sessionManager, never()).createSession(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnSessionToken_When_AuthenticationSucceeds")
|
||||
void should_ReturnSessionToken_When_AuthenticationSucceeds() {
|
||||
// Arrange
|
||||
when(userRepository.findByUsername("john.doe")).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("Password123!", validPasswordHash)).thenReturn(true);
|
||||
SessionToken expectedToken = new SessionToken("jwt-xyz", "Bearer", 3600L, LocalDateTime.now().plusSeconds(3600), "refresh-xyz");
|
||||
when(sessionManager.createSession(testUser)).thenReturn(expectedToken);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, SessionToken> result = authenticateUser.execute(validCommand);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue()).isEqualTo(expectedToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import de.effigenix.application.usermanagement.command.ChangePasswordCommand;
|
|||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
|
|
@ -20,26 +20,16 @@ import static org.assertj.core.api.Assertions.*;
|
|||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for ChangePassword Use Case.
|
||||
* Tests password verification, password validation, update logic, and audit logging.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ChangePassword Use Case")
|
||||
class ChangePasswordTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private PasswordHasher passwordHasher;
|
||||
@Mock private AuditLogger auditLogger;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
|
||||
@Mock
|
||||
private PasswordHasher passwordHasher;
|
||||
|
||||
@Mock
|
||||
private AuditLogger auditLogger;
|
||||
|
||||
@InjectMocks
|
||||
private ChangePassword changePassword;
|
||||
|
||||
private User testUser;
|
||||
private PasswordHash oldPasswordHash;
|
||||
private PasswordHash newPasswordHash;
|
||||
|
|
@ -48,59 +38,58 @@ class ChangePasswordTest {
|
|||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
changePassword = new ChangePassword(userRepository, passwordHasher, auditLogger, authPort);
|
||||
oldPasswordHash = new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW");
|
||||
newPasswordHash = new PasswordHash("$2b$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW");
|
||||
|
||||
testUser = User.reconstitute(
|
||||
UserId.of("user-123"),
|
||||
"john.doe",
|
||||
"john@example.com",
|
||||
oldPasswordHash,
|
||||
new HashSet<>(),
|
||||
"branch-1",
|
||||
UserStatus.ACTIVE,
|
||||
LocalDateTime.now(),
|
||||
null
|
||||
UserId.of("user-123"), "john.doe", "john@example.com", oldPasswordHash,
|
||||
new HashSet<>(), "branch-1", UserStatus.ACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
|
||||
validCommand = new ChangePasswordCommand("user-123", "OldPassword123!", "NewPassword456!");
|
||||
performedBy = ActorId.of("user-123");
|
||||
performedBy = ActorId.of("user-123"); // self-service
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ChangePassword_When_ValidCurrentPasswordProvided")
|
||||
void should_ChangePassword_When_ValidCurrentPasswordProvided() {
|
||||
// Arrange
|
||||
@DisplayName("should_ChangePassword_When_ValidCurrentPasswordProvided_SelfService")
|
||||
void should_ChangePassword_When_ValidCurrentPasswordProvided_SelfService() {
|
||||
// Self-service: no authPort check needed
|
||||
when(userRepository.findById(UserId.of("user-123"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("OldPassword123!", oldPasswordHash)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("NewPassword456!")).thenReturn(true);
|
||||
when(passwordHasher.hash("NewPassword456!")).thenReturn(newPasswordHash);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = changePassword.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(auditLogger).log(eq(AuditEvent.PASSWORD_CHANGED), eq("user-123"), eq(performedBy));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUnauthorized_When_DifferentActorWithoutPermission")
|
||||
void should_FailWithUnauthorized_When_DifferentActorWithoutPermission() {
|
||||
ActorId otherActor = ActorId.of("other-user");
|
||||
when(authPort.can(otherActor, UserManagementAction.PASSWORD_CHANGE)).thenReturn(false);
|
||||
|
||||
Result<UserError, Void> result = changePassword.execute(validCommand, otherActor);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.Unauthorized.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUserNotFound_When_UserIdDoesNotExist")
|
||||
void should_FailWithUserNotFound_When_UserIdDoesNotExist() {
|
||||
// Arrange
|
||||
when(userRepository.findById(UserId.of("nonexistent-id"))).thenReturn(Result.success(Optional.empty()));
|
||||
ChangePasswordCommand command = new ChangePasswordCommand("nonexistent-id", "OldPassword123!", "NewPassword456!");
|
||||
ActorId actor = ActorId.of("nonexistent-id");
|
||||
|
||||
ChangePasswordCommand command = new ChangePasswordCommand(
|
||||
"nonexistent-id",
|
||||
"OldPassword123!",
|
||||
"NewPassword456!"
|
||||
);
|
||||
Result<UserError, Void> result = changePassword.execute(command, actor);
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = changePassword.execute(command, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.UserNotFound.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
|
|
@ -109,235 +98,41 @@ class ChangePasswordTest {
|
|||
@Test
|
||||
@DisplayName("should_FailWithInvalidCredentials_When_CurrentPasswordIncorrect")
|
||||
void should_FailWithInvalidCredentials_When_CurrentPasswordIncorrect() {
|
||||
// Arrange
|
||||
when(userRepository.findById(UserId.of("user-123"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("WrongPassword", oldPasswordHash)).thenReturn(false);
|
||||
ChangePasswordCommand command = new ChangePasswordCommand("user-123", "WrongPassword", "NewPassword456!");
|
||||
|
||||
ChangePasswordCommand command = new ChangePasswordCommand(
|
||||
"user-123",
|
||||
"WrongPassword",
|
||||
"NewPassword456!"
|
||||
);
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = changePassword.execute(command, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidCredentials.class);
|
||||
verify(auditLogger).log(eq(AuditEvent.PASSWORD_CHANGE_FAILED), eq("user-123"), eq(performedBy));
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidPassword_When_NewPasswordTooWeak")
|
||||
void should_FailWithInvalidPassword_When_NewPasswordTooWeak() {
|
||||
// Arrange
|
||||
when(userRepository.findById(UserId.of("user-123"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("OldPassword123!", oldPasswordHash)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("weak")).thenReturn(false);
|
||||
|
||||
ChangePasswordCommand command = new ChangePasswordCommand("user-123", "OldPassword123!", "weak");
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = changePassword.execute(command, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidPassword.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_VerifyCurrentPasswordBeforeValidatingNewPassword")
|
||||
void should_VerifyCurrentPasswordBeforeValidatingNewPassword() {
|
||||
// Arrange
|
||||
when(userRepository.findById(UserId.of("user-123"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("OldPassword123!", oldPasswordHash)).thenReturn(false);
|
||||
@DisplayName("should_FailWithInvalidInput_When_BlankUserId")
|
||||
void should_FailWithInvalidInput_When_BlankUserId() {
|
||||
ChangePasswordCommand command = new ChangePasswordCommand("", "OldPassword123!", "NewPassword456!");
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = changePassword.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
// Password validation should not be called if current password is wrong
|
||||
verify(passwordHasher, never()).isValidPassword(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_HashNewPassword_When_AllValidationsPass")
|
||||
void should_HashNewPassword_When_AllValidationsPass() {
|
||||
// Arrange
|
||||
when(userRepository.findById(UserId.of("user-123"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("OldPassword123!", oldPasswordHash)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("NewPassword456!")).thenReturn(true);
|
||||
when(passwordHasher.hash("NewPassword456!")).thenReturn(newPasswordHash);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
changePassword.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
verify(passwordHasher).hash("NewPassword456!");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_UpdateUserPassword_When_NewHashObtained")
|
||||
void should_UpdateUserPassword_When_NewHashObtained() {
|
||||
// Arrange
|
||||
when(userRepository.findById(UserId.of("user-123"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("OldPassword123!", oldPasswordHash)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("NewPassword456!")).thenReturn(true);
|
||||
when(passwordHasher.hash("NewPassword456!")).thenReturn(newPasswordHash);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = changePassword.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(testUser.passwordHash()).isEqualTo(newPasswordHash);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_SaveUpdatedUserToRepository_When_PasswordChanged")
|
||||
void should_SaveUpdatedUserToRepository_When_PasswordChanged() {
|
||||
// Arrange
|
||||
when(userRepository.findById(UserId.of("user-123"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("OldPassword123!", oldPasswordHash)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("NewPassword456!")).thenReturn(true);
|
||||
when(passwordHasher.hash("NewPassword456!")).thenReturn(newPasswordHash);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
changePassword.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_LogPasswordChangedAuditEvent_When_PasswordSuccessfullyChanged")
|
||||
void should_LogPasswordChangedAuditEvent_When_PasswordSuccessfullyChanged() {
|
||||
// Arrange
|
||||
when(userRepository.findById(UserId.of("user-123"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("OldPassword123!", oldPasswordHash)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("NewPassword456!")).thenReturn(true);
|
||||
when(passwordHasher.hash("NewPassword456!")).thenReturn(newPasswordHash);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = changePassword.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
verify(auditLogger).log(eq(AuditEvent.PASSWORD_CHANGED), eq("user-123"), eq(performedBy));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_LogFailureAuditEvent_When_CurrentPasswordIncorrect")
|
||||
void should_LogFailureAuditEvent_When_CurrentPasswordIncorrect() {
|
||||
// Arrange
|
||||
when(userRepository.findById(UserId.of("user-123"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("WrongPassword", oldPasswordHash)).thenReturn(false);
|
||||
|
||||
ChangePasswordCommand command = new ChangePasswordCommand(
|
||||
"user-123",
|
||||
"WrongPassword",
|
||||
"NewPassword456!"
|
||||
);
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = changePassword.execute(command, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
verify(auditLogger).log(
|
||||
eq(AuditEvent.PASSWORD_CHANGED),
|
||||
eq("user-123"),
|
||||
eq(performedBy)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnSuccess_When_PasswordChangedSuccessfully")
|
||||
void should_ReturnSuccess_When_PasswordChangedSuccessfully() {
|
||||
// Arrange
|
||||
when(userRepository.findById(UserId.of("user-123"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("OldPassword123!", oldPasswordHash)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("NewPassword456!")).thenReturn(true);
|
||||
when(passwordHasher.hash("NewPassword456!")).thenReturn(newPasswordHash);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = changePassword.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnFailure_When_UserNotFound")
|
||||
void should_ReturnFailure_When_UserNotFound() {
|
||||
// Arrange
|
||||
when(userRepository.findById(UserId.of("invalid-id"))).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
ChangePasswordCommand command = new ChangePasswordCommand(
|
||||
"invalid-id",
|
||||
"OldPassword123!",
|
||||
"NewPassword456!"
|
||||
);
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = changePassword.execute(command, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_AllowPasswordChangeForActiveUser")
|
||||
void should_AllowPasswordChangeForActiveUser() {
|
||||
// Arrange
|
||||
testUser = User.reconstitute(
|
||||
UserId.of("user-123"),
|
||||
"john.doe",
|
||||
"john@example.com",
|
||||
oldPasswordHash,
|
||||
new HashSet<>(),
|
||||
"branch-1",
|
||||
UserStatus.ACTIVE,
|
||||
LocalDateTime.now(),
|
||||
null
|
||||
);
|
||||
|
||||
when(userRepository.findById(UserId.of("user-123"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("OldPassword123!", oldPasswordHash)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("NewPassword456!")).thenReturn(true);
|
||||
when(passwordHasher.hash("NewPassword456!")).thenReturn(newPasswordHash);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = changePassword.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_UsePasswordHasherToVerifyCurrentPassword")
|
||||
void should_UsePasswordHasherToVerifyCurrentPassword() {
|
||||
// Arrange
|
||||
when(userRepository.findById(UserId.of("user-123"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(passwordHasher.verify("OldPassword123!", oldPasswordHash)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("NewPassword456!")).thenReturn(true);
|
||||
when(passwordHasher.hash("NewPassword456!")).thenReturn(newPasswordHash);
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
changePassword.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
verify(passwordHasher).verify("OldPassword123!", oldPasswordHash);
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidInput.class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import de.effigenix.application.usermanagement.command.CreateUserCommand;
|
|||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
|
|
@ -21,58 +21,35 @@ import static org.mockito.ArgumentMatchers.any;
|
|||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for CreateUser Use Case.
|
||||
* Tests validation, uniqueness checks, role loading, user creation, and audit logging.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("CreateUser Use Case")
|
||||
class CreateUserTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private RoleRepository roleRepository;
|
||||
@Mock private PasswordHasher passwordHasher;
|
||||
@Mock private AuditLogger auditLogger;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
|
||||
@Mock
|
||||
private RoleRepository roleRepository;
|
||||
|
||||
@Mock
|
||||
private PasswordHasher passwordHasher;
|
||||
|
||||
@Mock
|
||||
private AuditLogger auditLogger;
|
||||
|
||||
@InjectMocks
|
||||
private CreateUser createUser;
|
||||
|
||||
private CreateUserCommand validCommand;
|
||||
private ActorId performedBy;
|
||||
private PasswordHash validPasswordHash;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
createUser = new CreateUser(userRepository, roleRepository, passwordHasher, auditLogger, authPort);
|
||||
performedBy = ActorId.of("admin-user");
|
||||
validPasswordHash = new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW");
|
||||
|
||||
validCommand = new CreateUserCommand(
|
||||
"john.doe",
|
||||
"john@example.com",
|
||||
"Password123!",
|
||||
Set.of(RoleName.PRODUCTION_WORKER),
|
||||
"branch-1"
|
||||
);
|
||||
validCommand = new CreateUserCommand("john.doe", "john@example.com", "Password123!", Set.of(RoleName.PRODUCTION_WORKER), "branch-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_CreateUser_When_ValidCommandAndUniqueDetailsProvided")
|
||||
void should_CreateUser_When_ValidCommandAndUniqueDetailsProvided() {
|
||||
// Arrange
|
||||
Role role = Role.reconstitute(
|
||||
RoleId.generate(),
|
||||
RoleName.PRODUCTION_WORKER,
|
||||
new HashSet<>(),
|
||||
"Production Worker"
|
||||
);
|
||||
Role role = Role.reconstitute(RoleId.generate(), RoleName.PRODUCTION_WORKER, new HashSet<>(), "Production Worker");
|
||||
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_CREATE)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("Password123!")).thenReturn(true);
|
||||
when(userRepository.existsByUsername("john.doe")).thenReturn(Result.success(false));
|
||||
when(userRepository.existsByEmail("john@example.com")).thenReturn(Result.success(false));
|
||||
|
|
@ -80,157 +57,103 @@ class CreateUserTest {
|
|||
when(roleRepository.findByName(RoleName.PRODUCTION_WORKER)).thenReturn(Result.success(Optional.of(role)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, de.effigenix.application.usermanagement.dto.UserDTO> result =
|
||||
createUser.execute(validCommand, performedBy);
|
||||
var result = createUser.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().username()).isEqualTo("john.doe");
|
||||
assertThat(result.unsafeGetValue().email()).isEqualTo("john@example.com");
|
||||
|
||||
verify(userRepository).save(any());
|
||||
verify(auditLogger).log(AuditEvent.USER_CREATED, result.unsafeGetValue().id(), performedBy);
|
||||
verify(auditLogger).log(eq(AuditEvent.USER_CREATED), anyString(), eq(performedBy));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUnauthorized_When_ActorLacksPermission")
|
||||
void should_FailWithUnauthorized_When_ActorLacksPermission() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_CREATE)).thenReturn(false);
|
||||
|
||||
var result = createUser.execute(validCommand, performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.Unauthorized.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidInput_When_EmptyRoleNamesProvided")
|
||||
void should_FailWithInvalidInput_When_EmptyRoleNamesProvided() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_CREATE)).thenReturn(true);
|
||||
var cmd = new CreateUserCommand("john.doe", "john@example.com", "Password123!", Set.of(), "branch-1");
|
||||
|
||||
var result = createUser.execute(cmd, performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidInput.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidPassword_When_WeakPasswordProvided")
|
||||
void should_FailWithInvalidPassword_When_WeakPasswordProvided() {
|
||||
// Arrange
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_CREATE)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("Password123!")).thenReturn(false);
|
||||
|
||||
// Act
|
||||
Result<UserError, de.effigenix.application.usermanagement.dto.UserDTO> result =
|
||||
createUser.execute(validCommand, performedBy);
|
||||
var result = createUser.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidPassword.class);
|
||||
assertThat(result.unsafeGetError().message()).contains("at least 8 characters");
|
||||
|
||||
verify(userRepository, never()).save(any());
|
||||
verify(auditLogger, never()).log(any(AuditEvent.class), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUsernameExists_When_DuplicateUsernameProvided")
|
||||
void should_FailWithUsernameExists_When_DuplicateUsernameProvided() {
|
||||
// Arrange
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_CREATE)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("Password123!")).thenReturn(true);
|
||||
when(userRepository.existsByUsername("john.doe")).thenReturn(Result.success(true));
|
||||
|
||||
// Act
|
||||
Result<UserError, de.effigenix.application.usermanagement.dto.UserDTO> result =
|
||||
createUser.execute(validCommand, performedBy);
|
||||
var result = createUser.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.UsernameAlreadyExists.class);
|
||||
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithEmailExists_When_DuplicateEmailProvided")
|
||||
void should_FailWithEmailExists_When_DuplicateEmailProvided() {
|
||||
// Arrange
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_CREATE)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("Password123!")).thenReturn(true);
|
||||
when(userRepository.existsByUsername("john.doe")).thenReturn(Result.success(false));
|
||||
when(userRepository.existsByEmail("john@example.com")).thenReturn(Result.success(true));
|
||||
|
||||
// Act
|
||||
Result<UserError, de.effigenix.application.usermanagement.dto.UserDTO> result =
|
||||
createUser.execute(validCommand, performedBy);
|
||||
var result = createUser.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.EmailAlreadyExists.class);
|
||||
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_UsePasswordHasherToHashPassword_When_PasswordValid")
|
||||
void should_UsePasswordHasherToHashPassword_When_PasswordValid() {
|
||||
// Arrange
|
||||
Role role = Role.reconstitute(RoleId.generate(), RoleName.PRODUCTION_WORKER, new HashSet<>(), "Worker");
|
||||
|
||||
when(passwordHasher.isValidPassword("Password123!")).thenReturn(true);
|
||||
when(userRepository.existsByUsername("john.doe")).thenReturn(Result.success(false));
|
||||
when(userRepository.existsByEmail("john@example.com")).thenReturn(Result.success(false));
|
||||
when(passwordHasher.hash("Password123!")).thenReturn(validPasswordHash);
|
||||
when(roleRepository.findByName(RoleName.PRODUCTION_WORKER)).thenReturn(Result.success(Optional.of(role)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, de.effigenix.application.usermanagement.dto.UserDTO> result =
|
||||
createUser.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
verify(passwordHasher).hash("Password123!");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_LoadRolesByName_When_RoleNamesProvided")
|
||||
void should_LoadRolesByName_When_RoleNamesProvided() {
|
||||
// Arrange
|
||||
Role role1 = Role.reconstitute(RoleId.generate(), RoleName.PRODUCTION_WORKER, new HashSet<>(), "Worker");
|
||||
Role role2 = Role.reconstitute(RoleId.generate(), RoleName.PRODUCTION_MANAGER, new HashSet<>(), "Manager");
|
||||
|
||||
CreateUserCommand commandWithMultipleRoles = new CreateUserCommand(
|
||||
"john.doe",
|
||||
"john@example.com",
|
||||
"Password123!",
|
||||
Set.of(RoleName.PRODUCTION_WORKER, RoleName.PRODUCTION_MANAGER),
|
||||
"branch-1"
|
||||
);
|
||||
|
||||
when(passwordHasher.isValidPassword("Password123!")).thenReturn(true);
|
||||
when(userRepository.existsByUsername("john.doe")).thenReturn(Result.success(false));
|
||||
when(userRepository.existsByEmail("john@example.com")).thenReturn(Result.success(false));
|
||||
when(passwordHasher.hash("Password123!")).thenReturn(validPasswordHash);
|
||||
when(roleRepository.findByName(RoleName.PRODUCTION_WORKER)).thenReturn(Result.success(Optional.of(role1)));
|
||||
when(roleRepository.findByName(RoleName.PRODUCTION_MANAGER)).thenReturn(Result.success(Optional.of(role2)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, de.effigenix.application.usermanagement.dto.UserDTO> result =
|
||||
createUser.execute(commandWithMultipleRoles, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
verify(roleRepository).findByName(RoleName.PRODUCTION_WORKER);
|
||||
verify(roleRepository).findByName(RoleName.PRODUCTION_MANAGER);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithRoleNotFound_When_RoleNotFound")
|
||||
void should_FailWithRoleNotFound_When_RoleNotFound() {
|
||||
// Arrange
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_CREATE)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("Password123!")).thenReturn(true);
|
||||
when(userRepository.existsByUsername("john.doe")).thenReturn(Result.success(false));
|
||||
when(userRepository.existsByEmail("john@example.com")).thenReturn(Result.success(false));
|
||||
when(passwordHasher.hash("Password123!")).thenReturn(validPasswordHash);
|
||||
when(roleRepository.findByName(RoleName.PRODUCTION_WORKER)).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
// Act
|
||||
Result<UserError, de.effigenix.application.usermanagement.dto.UserDTO> result =
|
||||
createUser.execute(validCommand, performedBy);
|
||||
var result = createUser.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.RoleNotFound.class);
|
||||
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_CreateActiveUser_When_UserCreatedWithFactoryMethod")
|
||||
void should_CreateActiveUser_When_UserCreatedWithFactoryMethod() {
|
||||
// Arrange
|
||||
@DisplayName("should_CreateActiveUser_When_UserCreatedSuccessfully")
|
||||
void should_CreateActiveUser_When_UserCreatedSuccessfully() {
|
||||
Role role = Role.reconstitute(RoleId.generate(), RoleName.ADMIN, new HashSet<>(), "Admin");
|
||||
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_CREATE)).thenReturn(true);
|
||||
when(passwordHasher.isValidPassword("Password123!")).thenReturn(true);
|
||||
when(userRepository.existsByUsername("john.doe")).thenReturn(Result.success(false));
|
||||
when(userRepository.existsByEmail("john@example.com")).thenReturn(Result.success(false));
|
||||
|
|
@ -238,79 +161,9 @@ class CreateUserTest {
|
|||
when(roleRepository.findByName(RoleName.PRODUCTION_WORKER)).thenReturn(Result.success(Optional.of(role)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, de.effigenix.application.usermanagement.dto.UserDTO> result =
|
||||
createUser.execute(validCommand, performedBy);
|
||||
var result = createUser.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().status()).isEqualTo(UserStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_LogUserCreatedAuditEvent_When_UserSuccessfullyCreated")
|
||||
void should_LogUserCreatedAuditEvent_When_UserSuccessfullyCreated() {
|
||||
// Arrange
|
||||
Role role = Role.reconstitute(RoleId.generate(), RoleName.PRODUCTION_WORKER, new HashSet<>(), "Worker");
|
||||
|
||||
when(passwordHasher.isValidPassword("Password123!")).thenReturn(true);
|
||||
when(userRepository.existsByUsername("john.doe")).thenReturn(Result.success(false));
|
||||
when(userRepository.existsByEmail("john@example.com")).thenReturn(Result.success(false));
|
||||
when(passwordHasher.hash("Password123!")).thenReturn(validPasswordHash);
|
||||
when(roleRepository.findByName(RoleName.PRODUCTION_WORKER)).thenReturn(Result.success(Optional.of(role)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, de.effigenix.application.usermanagement.dto.UserDTO> result =
|
||||
createUser.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
verify(auditLogger).log(eq(AuditEvent.USER_CREATED), anyString(), eq(performedBy));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_SaveUserToRepository_When_AllValidationsPass")
|
||||
void should_SaveUserToRepository_When_AllValidationsPass() {
|
||||
// Arrange
|
||||
Role role = Role.reconstitute(RoleId.generate(), RoleName.PRODUCTION_WORKER, new HashSet<>(), "Worker");
|
||||
|
||||
when(passwordHasher.isValidPassword("Password123!")).thenReturn(true);
|
||||
when(userRepository.existsByUsername("john.doe")).thenReturn(Result.success(false));
|
||||
when(userRepository.existsByEmail("john@example.com")).thenReturn(Result.success(false));
|
||||
when(passwordHasher.hash("Password123!")).thenReturn(validPasswordHash);
|
||||
when(roleRepository.findByName(RoleName.PRODUCTION_WORKER)).thenReturn(Result.success(Optional.of(role)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
createUser.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnUserDTO_When_UserCreatedSuccessfully")
|
||||
void should_ReturnUserDTO_When_UserCreatedSuccessfully() {
|
||||
// Arrange
|
||||
Role role = Role.reconstitute(RoleId.generate(), RoleName.PRODUCTION_WORKER, new HashSet<>(), "Worker");
|
||||
|
||||
when(passwordHasher.isValidPassword("Password123!")).thenReturn(true);
|
||||
when(userRepository.existsByUsername("john.doe")).thenReturn(Result.success(false));
|
||||
when(userRepository.existsByEmail("john@example.com")).thenReturn(Result.success(false));
|
||||
when(passwordHasher.hash("Password123!")).thenReturn(validPasswordHash);
|
||||
when(roleRepository.findByName(RoleName.PRODUCTION_WORKER)).thenReturn(Result.success(Optional.of(role)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
// Act
|
||||
Result<UserError, de.effigenix.application.usermanagement.dto.UserDTO> result =
|
||||
createUser.execute(validCommand, performedBy);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var userDTO = result.unsafeGetValue();
|
||||
assertThat(userDTO.username()).isEqualTo("john.doe");
|
||||
assertThat(userDTO.email()).isEqualTo("john@example.com");
|
||||
assertThat(userDTO.status()).isEqualTo(UserStatus.ACTIVE);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("GetUser Use Case")
|
||||
class GetUserTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
|
||||
private GetUser getUser;
|
||||
private ActorId performedBy;
|
||||
private User testUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
getUser = new GetUser(userRepository, authPort);
|
||||
performedBy = ActorId.of("admin-user");
|
||||
testUser = User.reconstitute(
|
||||
UserId.of("user-1"), "john.doe", "john@example.com",
|
||||
new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW"),
|
||||
new HashSet<>(), "branch-1", UserStatus.ACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnUser_When_UserExistsAndAuthorized")
|
||||
void should_ReturnUser_When_UserExistsAndAuthorized() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_VIEW)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-1"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
|
||||
Result<UserError, UserDTO> result = getUser.execute("user-1", performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().username()).isEqualTo("john.doe");
|
||||
assertThat(result.unsafeGetValue().email()).isEqualTo("john@example.com");
|
||||
assertThat(result.unsafeGetValue().id()).isEqualTo("user-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUnauthorized_When_ActorLacksPermission")
|
||||
void should_FailWithUnauthorized_When_ActorLacksPermission() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_VIEW)).thenReturn(false);
|
||||
|
||||
Result<UserError, UserDTO> result = getUser.execute("user-1", performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.Unauthorized.class);
|
||||
verify(userRepository, never()).findById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUserNotFound_When_UserDoesNotExist")
|
||||
void should_FailWithUserNotFound_When_UserDoesNotExist() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_VIEW)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("nonexistent"))).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
Result<UserError, UserDTO> result = getUser.execute("nonexistent", performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.UserNotFound.class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import de.effigenix.shared.security.BranchId;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ListUsers Use Case")
|
||||
class ListUsersTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
|
||||
private ListUsers listUsers;
|
||||
private ActorId performedBy;
|
||||
private User user1;
|
||||
private User user2;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
listUsers = new ListUsers(userRepository, authPort);
|
||||
performedBy = ActorId.of("admin-user");
|
||||
user1 = User.reconstitute(
|
||||
UserId.of("user-1"), "john.doe", "john@example.com",
|
||||
new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW"),
|
||||
new HashSet<>(), "branch-1", UserStatus.ACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
user2 = User.reconstitute(
|
||||
UserId.of("user-2"), "jane.doe", "jane@example.com",
|
||||
new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW"),
|
||||
new HashSet<>(), "branch-1", UserStatus.ACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnAllUsers_When_Authorized")
|
||||
void should_ReturnAllUsers_When_Authorized() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_LIST)).thenReturn(true);
|
||||
when(userRepository.findAll()).thenReturn(Result.success(List.of(user1, user2)));
|
||||
|
||||
Result<UserError, List<UserDTO>> result = listUsers.execute(performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue()).hasSize(2);
|
||||
assertThat(result.unsafeGetValue()).extracting(UserDTO::username)
|
||||
.containsExactlyInAnyOrder("john.doe", "jane.doe");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnEmptyList_When_NoUsersExist")
|
||||
void should_ReturnEmptyList_When_NoUsersExist() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_LIST)).thenReturn(true);
|
||||
when(userRepository.findAll()).thenReturn(Result.success(List.of()));
|
||||
|
||||
Result<UserError, List<UserDTO>> result = listUsers.execute(performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUnauthorized_When_ActorLacksPermission")
|
||||
void should_FailWithUnauthorized_When_ActorLacksPermission() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_LIST)).thenReturn(false);
|
||||
|
||||
Result<UserError, List<UserDTO>> result = listUsers.execute(performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.Unauthorized.class);
|
||||
verify(userRepository, never()).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnBranchUsers_When_FilteredByBranch")
|
||||
void should_ReturnBranchUsers_When_FilteredByBranch() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_LIST)).thenReturn(true);
|
||||
when(userRepository.findByBranchId("branch-1")).thenReturn(Result.success(List.of(user1, user2)));
|
||||
|
||||
Result<UserError, List<UserDTO>> result = listUsers.executeForBranch(BranchId.of("branch-1"), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUnauthorized_When_ActorLacksPermissionForBranchList")
|
||||
void should_FailWithUnauthorized_When_ActorLacksPermissionForBranchList() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_LIST)).thenReturn(false);
|
||||
|
||||
Result<UserError, List<UserDTO>> result = listUsers.executeForBranch(BranchId.of("branch-1"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.Unauthorized.class);
|
||||
verify(userRepository, never()).findByBranchId(anyString());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.LockUserCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("LockUser Use Case")
|
||||
class LockUserTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private AuditLogger auditLogger;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
|
||||
private LockUser lockUser;
|
||||
private ActorId performedBy;
|
||||
private User activeUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lockUser = new LockUser(userRepository, auditLogger, authPort);
|
||||
performedBy = ActorId.of("admin-user");
|
||||
activeUser = User.reconstitute(
|
||||
UserId.of("user-1"), "john.doe", "john@example.com",
|
||||
new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW"),
|
||||
new HashSet<>(), "branch-1", UserStatus.ACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_LockUser_When_UserIsActive")
|
||||
void should_LockUser_When_UserIsActive() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_LOCK)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-1"))).thenReturn(Result.success(Optional.of(activeUser)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
Result<UserError, UserDTO> result = lockUser.execute(new LockUserCommand("user-1"), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().status()).isEqualTo(UserStatus.LOCKED);
|
||||
verify(userRepository).save(argThat(user -> user.status() == UserStatus.LOCKED));
|
||||
verify(auditLogger).log(eq(AuditEvent.USER_LOCKED), eq("user-1"), eq(performedBy));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUnauthorized_When_ActorLacksPermission")
|
||||
void should_FailWithUnauthorized_When_ActorLacksPermission() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_LOCK)).thenReturn(false);
|
||||
|
||||
Result<UserError, UserDTO> result = lockUser.execute(new LockUserCommand("user-1"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.Unauthorized.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidInput_When_UserIdIsBlank")
|
||||
void should_FailWithInvalidInput_When_UserIdIsBlank() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_LOCK)).thenReturn(true);
|
||||
|
||||
Result<UserError, UserDTO> result = lockUser.execute(new LockUserCommand(""), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidInput.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUserNotFound_When_UserDoesNotExist")
|
||||
void should_FailWithUserNotFound_When_UserDoesNotExist() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_LOCK)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("nonexistent"))).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
Result<UserError, UserDTO> result = lockUser.execute(new LockUserCommand("nonexistent"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.UserNotFound.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidStatusTransition_When_UserAlreadyLocked")
|
||||
void should_FailWithInvalidStatusTransition_When_UserAlreadyLocked() {
|
||||
User lockedUser = User.reconstitute(
|
||||
UserId.of("user-2"), "jane.doe", "jane@example.com",
|
||||
new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW"),
|
||||
new HashSet<>(), "branch-1", UserStatus.LOCKED, LocalDateTime.now(), null
|
||||
);
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_LOCK)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-2"))).thenReturn(Result.success(Optional.of(lockedUser)));
|
||||
|
||||
Result<UserError, UserDTO> result = lockUser.execute(new LockUserCommand("user-2"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidStatusTransition.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidStatusTransition_When_UserIsInactive")
|
||||
void should_FailWithInvalidStatusTransition_When_UserIsInactive() {
|
||||
User inactiveUser = User.reconstitute(
|
||||
UserId.of("user-3"), "bob", "bob@example.com",
|
||||
new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW"),
|
||||
new HashSet<>(), "branch-1", UserStatus.INACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_LOCK)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-3"))).thenReturn(Result.success(Optional.of(inactiveUser)));
|
||||
|
||||
Result<UserError, UserDTO> result = lockUser.execute(new LockUserCommand("user-3"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidStatusTransition.class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.RemoveRoleCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("RemoveRole Use Case")
|
||||
class RemoveRoleTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private RoleRepository roleRepository;
|
||||
@Mock private AuditLogger auditLogger;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
|
||||
private RemoveRole removeRole;
|
||||
private ActorId performedBy;
|
||||
private Role workerRole;
|
||||
private User userWithRole;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
removeRole = new RemoveRole(userRepository, roleRepository, auditLogger, authPort);
|
||||
performedBy = ActorId.of("admin-user");
|
||||
workerRole = Role.reconstitute(RoleId.generate(), RoleName.PRODUCTION_WORKER, new HashSet<>(), "Production Worker");
|
||||
userWithRole = User.reconstitute(
|
||||
UserId.of("user-1"), "john.doe", "john@example.com",
|
||||
new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW"),
|
||||
new HashSet<>(Set.of(workerRole)), "branch-1", UserStatus.ACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_RemoveRole_When_ValidCommandProvided")
|
||||
void should_RemoveRole_When_ValidCommandProvided() {
|
||||
when(authPort.can(performedBy, UserManagementAction.ROLE_REMOVE)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-1"))).thenReturn(Result.success(Optional.of(userWithRole)));
|
||||
when(roleRepository.findByName(RoleName.PRODUCTION_WORKER)).thenReturn(Result.success(Optional.of(workerRole)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
Result<UserError, UserDTO> result = removeRole.execute(
|
||||
new RemoveRoleCommand("user-1", RoleName.PRODUCTION_WORKER), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().roles()).isEmpty();
|
||||
verify(userRepository).save(argThat(user -> user.roles().isEmpty()));
|
||||
verify(auditLogger).log(eq(AuditEvent.ROLE_REMOVED), eq("user-1"), anyString(), eq(performedBy));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUnauthorized_When_ActorLacksPermission")
|
||||
void should_FailWithUnauthorized_When_ActorLacksPermission() {
|
||||
when(authPort.can(performedBy, UserManagementAction.ROLE_REMOVE)).thenReturn(false);
|
||||
|
||||
Result<UserError, UserDTO> result = removeRole.execute(
|
||||
new RemoveRoleCommand("user-1", RoleName.PRODUCTION_WORKER), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.Unauthorized.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidInput_When_UserIdIsBlank")
|
||||
void should_FailWithInvalidInput_When_UserIdIsBlank() {
|
||||
when(authPort.can(performedBy, UserManagementAction.ROLE_REMOVE)).thenReturn(true);
|
||||
|
||||
Result<UserError, UserDTO> result = removeRole.execute(
|
||||
new RemoveRoleCommand("", RoleName.PRODUCTION_WORKER), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidInput.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidInput_When_RoleNameIsNull")
|
||||
void should_FailWithInvalidInput_When_RoleNameIsNull() {
|
||||
when(authPort.can(performedBy, UserManagementAction.ROLE_REMOVE)).thenReturn(true);
|
||||
|
||||
Result<UserError, UserDTO> result = removeRole.execute(
|
||||
new RemoveRoleCommand("user-1", null), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidInput.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUserNotFound_When_UserDoesNotExist")
|
||||
void should_FailWithUserNotFound_When_UserDoesNotExist() {
|
||||
when(authPort.can(performedBy, UserManagementAction.ROLE_REMOVE)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("nonexistent"))).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
Result<UserError, UserDTO> result = removeRole.execute(
|
||||
new RemoveRoleCommand("nonexistent", RoleName.PRODUCTION_WORKER), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.UserNotFound.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithRoleNotFound_When_RoleDoesNotExist")
|
||||
void should_FailWithRoleNotFound_When_RoleDoesNotExist() {
|
||||
when(authPort.can(performedBy, UserManagementAction.ROLE_REMOVE)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-1"))).thenReturn(Result.success(Optional.of(userWithRole)));
|
||||
when(roleRepository.findByName(RoleName.ADMIN)).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
Result<UserError, UserDTO> result = removeRole.execute(
|
||||
new RemoveRoleCommand("user-1", RoleName.ADMIN), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.RoleNotFound.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.UnlockUserCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("UnlockUser Use Case")
|
||||
class UnlockUserTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private AuditLogger auditLogger;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
|
||||
private UnlockUser unlockUser;
|
||||
private ActorId performedBy;
|
||||
private User lockedUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
unlockUser = new UnlockUser(userRepository, auditLogger, authPort);
|
||||
performedBy = ActorId.of("admin-user");
|
||||
lockedUser = User.reconstitute(
|
||||
UserId.of("user-1"), "john.doe", "john@example.com",
|
||||
new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW"),
|
||||
new HashSet<>(), "branch-1", UserStatus.LOCKED, LocalDateTime.now(), null
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_UnlockUser_When_UserIsLocked")
|
||||
void should_UnlockUser_When_UserIsLocked() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UNLOCK)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-1"))).thenReturn(Result.success(Optional.of(lockedUser)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
Result<UserError, UserDTO> result = unlockUser.execute(new UnlockUserCommand("user-1"), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().status()).isEqualTo(UserStatus.ACTIVE);
|
||||
verify(userRepository).save(argThat(user -> user.status() == UserStatus.ACTIVE));
|
||||
verify(auditLogger).log(eq(AuditEvent.USER_UNLOCKED), eq("user-1"), eq(performedBy));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUnauthorized_When_ActorLacksPermission")
|
||||
void should_FailWithUnauthorized_When_ActorLacksPermission() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UNLOCK)).thenReturn(false);
|
||||
|
||||
Result<UserError, UserDTO> result = unlockUser.execute(new UnlockUserCommand("user-1"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.Unauthorized.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidInput_When_UserIdIsBlank")
|
||||
void should_FailWithInvalidInput_When_UserIdIsBlank() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UNLOCK)).thenReturn(true);
|
||||
|
||||
Result<UserError, UserDTO> result = unlockUser.execute(new UnlockUserCommand(""), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidInput.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUserNotFound_When_UserDoesNotExist")
|
||||
void should_FailWithUserNotFound_When_UserDoesNotExist() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UNLOCK)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("nonexistent"))).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
Result<UserError, UserDTO> result = unlockUser.execute(new UnlockUserCommand("nonexistent"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.UserNotFound.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidStatusTransition_When_UserIsActive")
|
||||
void should_FailWithInvalidStatusTransition_When_UserIsActive() {
|
||||
User activeUser = User.reconstitute(
|
||||
UserId.of("user-2"), "jane.doe", "jane@example.com",
|
||||
new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW"),
|
||||
new HashSet<>(), "branch-1", UserStatus.ACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UNLOCK)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-2"))).thenReturn(Result.success(Optional.of(activeUser)));
|
||||
|
||||
Result<UserError, UserDTO> result = unlockUser.execute(new UnlockUserCommand("user-2"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidStatusTransition.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithInvalidStatusTransition_When_UserIsInactive")
|
||||
void should_FailWithInvalidStatusTransition_When_UserIsInactive() {
|
||||
User inactiveUser = User.reconstitute(
|
||||
UserId.of("user-3"), "bob", "bob@example.com",
|
||||
new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW"),
|
||||
new HashSet<>(), "branch-1", UserStatus.INACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UNLOCK)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-3"))).thenReturn(Result.success(Optional.of(inactiveUser)));
|
||||
|
||||
Result<UserError, UserDTO> result = unlockUser.execute(new UnlockUserCommand("user-3"), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidStatusTransition.class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
package de.effigenix.application.usermanagement;
|
||||
|
||||
import de.effigenix.application.usermanagement.command.UpdateUserCommand;
|
||||
import de.effigenix.application.usermanagement.dto.UserDTO;
|
||||
import de.effigenix.domain.usermanagement.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("UpdateUser Use Case")
|
||||
class UpdateUserTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private AuditLogger auditLogger;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
|
||||
private UpdateUser updateUser;
|
||||
private ActorId performedBy;
|
||||
private User testUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
updateUser = new UpdateUser(userRepository, auditLogger, authPort);
|
||||
performedBy = ActorId.of("admin-user");
|
||||
testUser = User.reconstitute(
|
||||
UserId.of("user-1"), "john.doe", "john@example.com",
|
||||
new PasswordHash("$2a$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW"),
|
||||
new HashSet<>(), "branch-1", UserStatus.ACTIVE, LocalDateTime.now(), null
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_UpdateEmail_When_NewEmailProvided")
|
||||
void should_UpdateEmail_When_NewEmailProvided() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UPDATE)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-1"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(userRepository.existsByEmail("newemail@example.com")).thenReturn(Result.success(false));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
Result<UserError, UserDTO> result = updateUser.execute(
|
||||
new UpdateUserCommand("user-1", "newemail@example.com", null), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().email()).isEqualTo("newemail@example.com");
|
||||
verify(userRepository).save(argThat(user -> "newemail@example.com".equals(user.email())));
|
||||
verify(auditLogger).log(eq(AuditEvent.USER_UPDATED), eq("user-1"), eq(performedBy));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_UpdateBranch_When_NewBranchProvided")
|
||||
void should_UpdateBranch_When_NewBranchProvided() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UPDATE)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-1"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
Result<UserError, UserDTO> result = updateUser.execute(
|
||||
new UpdateUserCommand("user-1", null, "branch-2"), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().branchId()).isEqualTo("branch-2");
|
||||
verify(userRepository).save(argThat(user -> "branch-2".equals(user.branchId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_UpdateEmailAndBranch_When_BothProvided")
|
||||
void should_UpdateEmailAndBranch_When_BothProvided() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UPDATE)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-1"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(userRepository.existsByEmail("new@example.com")).thenReturn(Result.success(false));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
Result<UserError, UserDTO> result = updateUser.execute(
|
||||
new UpdateUserCommand("user-1", "new@example.com", "branch-2"), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().email()).isEqualTo("new@example.com");
|
||||
assertThat(result.unsafeGetValue().branchId()).isEqualTo("branch-2");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUnauthorized_When_ActorLacksPermission")
|
||||
void should_FailWithUnauthorized_When_ActorLacksPermission() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UPDATE)).thenReturn(false);
|
||||
|
||||
Result<UserError, UserDTO> result = updateUser.execute(
|
||||
new UpdateUserCommand("user-1", "new@example.com", null), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.Unauthorized.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithUserNotFound_When_UserDoesNotExist")
|
||||
void should_FailWithUserNotFound_When_UserDoesNotExist() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UPDATE)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("nonexistent"))).thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
Result<UserError, UserDTO> result = updateUser.execute(
|
||||
new UpdateUserCommand("nonexistent", "new@example.com", null), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.UserNotFound.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailWithEmailAlreadyExists_When_DuplicateEmail")
|
||||
void should_FailWithEmailAlreadyExists_When_DuplicateEmail() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UPDATE)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-1"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(userRepository.existsByEmail("taken@example.com")).thenReturn(Result.success(true));
|
||||
|
||||
Result<UserError, UserDTO> result = updateUser.execute(
|
||||
new UpdateUserCommand("user-1", "taken@example.com", null), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.EmailAlreadyExists.class);
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_NotCheckEmailUniqueness_When_EmailUnchanged")
|
||||
void should_NotCheckEmailUniqueness_When_EmailUnchanged() {
|
||||
when(authPort.can(performedBy, UserManagementAction.USER_UPDATE)).thenReturn(true);
|
||||
when(userRepository.findById(UserId.of("user-1"))).thenReturn(Result.success(Optional.of(testUser)));
|
||||
when(userRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
Result<UserError, UserDTO> result = updateUser.execute(
|
||||
new UpdateUserCommand("user-1", "john@example.com", "branch-2"), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
verify(userRepository, never()).existsByEmail(anyString());
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.*;
|
|||
|
||||
/**
|
||||
* Unit tests for Role Entity.
|
||||
* Tests validation, permission management, factory methods, and equality.
|
||||
* Tests validation, immutable permission management, factory methods, and equality.
|
||||
*/
|
||||
@DisplayName("Role Entity")
|
||||
class RoleTest {
|
||||
|
|
@ -25,21 +25,15 @@ class RoleTest {
|
|||
void setUp() {
|
||||
roleId = RoleId.generate();
|
||||
roleName = RoleName.ADMIN;
|
||||
permissions = new HashSet<>(Set.of(
|
||||
Permission.USER_READ,
|
||||
Permission.USER_WRITE,
|
||||
Permission.ROLE_READ
|
||||
));
|
||||
permissions = new HashSet<>(Set.of(Permission.USER_READ, Permission.USER_WRITE, Permission.ROLE_READ));
|
||||
description = "Administrator role with full access";
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_CreateRole_When_ValidDataProvided")
|
||||
void should_CreateRole_When_ValidDataProvided() {
|
||||
// Act
|
||||
Role role = Role.reconstitute(roleId, roleName, permissions, description);
|
||||
|
||||
// Assert
|
||||
assertThat(role.id()).isEqualTo(roleId);
|
||||
assertThat(role.name()).isEqualTo(roleName);
|
||||
assertThat(role.permissions()).contains(Permission.USER_READ, Permission.USER_WRITE);
|
||||
|
|
@ -49,10 +43,8 @@ class RoleTest {
|
|||
@Test
|
||||
@DisplayName("should_ReturnFailure_When_NullRoleNameProvided")
|
||||
void should_ReturnFailure_When_NullRoleNameProvided() {
|
||||
// Act
|
||||
Result<UserError, Role> result = Role.create(null, permissions, description);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.NullRole.class);
|
||||
}
|
||||
|
|
@ -60,30 +52,24 @@ class RoleTest {
|
|||
@Test
|
||||
@DisplayName("should_CreateRoleWithEmptyPermissions_When_NullPermissionsProvided")
|
||||
void should_CreateRoleWithEmptyPermissions_When_NullPermissionsProvided() {
|
||||
// Act
|
||||
Role role = Role.reconstitute(roleId, roleName, null, description);
|
||||
|
||||
// Assert
|
||||
assertThat(role.permissions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_CreateRoleWithNullDescription_When_DescriptionNotProvided")
|
||||
void should_CreateRoleWithNullDescription_When_DescriptionNotProvided() {
|
||||
// Act
|
||||
Role role = Role.reconstitute(roleId, roleName, permissions, null);
|
||||
|
||||
// Assert
|
||||
assertThat(role.description()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_CreateRole_When_FactoryMethodCalled")
|
||||
void should_CreateRole_When_FactoryMethodCalled() {
|
||||
// Act
|
||||
Role role = Role.create(roleName, permissions, description).unsafeGetValue();
|
||||
|
||||
// Assert
|
||||
assertThat(role.id()).isNotNull();
|
||||
assertThat(role.name()).isEqualTo(roleName);
|
||||
assertThat(role.permissions()).isEqualTo(permissions);
|
||||
|
|
@ -91,135 +77,106 @@ class RoleTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_AddPermission_When_ValidPermissionProvided")
|
||||
void should_AddPermission_When_ValidPermissionProvided() {
|
||||
// Arrange
|
||||
@DisplayName("should_ReturnNewRoleWithPermission_When_AddPermissionCalled")
|
||||
void should_ReturnNewRoleWithPermission_When_AddPermissionCalled() {
|
||||
Role role = Role.reconstitute(roleId, roleName, new HashSet<>(), description);
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = role.addPermission(Permission.USER_DELETE);
|
||||
Result<UserError, Role> result = role.addPermission(Permission.USER_DELETE);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(role.permissions()).contains(Permission.USER_DELETE);
|
||||
assertThat(result.unsafeGetValue().permissions()).contains(Permission.USER_DELETE);
|
||||
assertThat(role.permissions()).doesNotContain(Permission.USER_DELETE); // original unchanged
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnFailure_When_NullPermissionProvidedToAddPermission")
|
||||
void should_ReturnFailure_When_NullPermissionProvidedToAddPermission() {
|
||||
// Arrange
|
||||
Role role = Role.reconstitute(roleId, roleName, new HashSet<>(), description);
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = role.addPermission(null);
|
||||
Result<UserError, Role> result = role.addPermission(null);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.NullRole.class);
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.NullPermission.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_AddMultiplePermissions_When_MethodCalledRepeatedly")
|
||||
void should_AddMultiplePermissions_When_MethodCalledRepeatedly() {
|
||||
// Arrange
|
||||
@DisplayName("should_ReturnNewRoleWithMultiplePermissions_When_AddedSequentially")
|
||||
void should_ReturnNewRoleWithMultiplePermissions_When_AddedSequentially() {
|
||||
Role role = Role.reconstitute(roleId, roleName, new HashSet<>(), description);
|
||||
|
||||
// Act
|
||||
role.addPermission(Permission.USER_READ);
|
||||
role.addPermission(Permission.USER_WRITE);
|
||||
role.addPermission(Permission.USER_DELETE);
|
||||
Role updated = role.addPermission(Permission.USER_READ).unsafeGetValue()
|
||||
.addPermission(Permission.USER_WRITE).unsafeGetValue()
|
||||
.addPermission(Permission.USER_DELETE).unsafeGetValue();
|
||||
|
||||
// Assert
|
||||
assertThat(role.permissions()).hasSize(3);
|
||||
assertThat(role.permissions()).contains(
|
||||
Permission.USER_READ,
|
||||
Permission.USER_WRITE,
|
||||
Permission.USER_DELETE
|
||||
);
|
||||
assertThat(updated.permissions()).hasSize(3);
|
||||
assertThat(updated.permissions()).contains(Permission.USER_READ, Permission.USER_WRITE, Permission.USER_DELETE);
|
||||
assertThat(role.permissions()).isEmpty(); // original unchanged
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_NotAddDuplicatePermission_When_SamePermissionAddedTwice")
|
||||
void should_NotAddDuplicatePermission_When_SamePermissionAddedTwice() {
|
||||
// Arrange
|
||||
Role role = Role.reconstitute(roleId, roleName, new HashSet<>(), description);
|
||||
|
||||
// Act
|
||||
role.addPermission(Permission.USER_READ);
|
||||
role.addPermission(Permission.USER_READ);
|
||||
Role updated = role.addPermission(Permission.USER_READ).unsafeGetValue()
|
||||
.addPermission(Permission.USER_READ).unsafeGetValue();
|
||||
|
||||
// Assert
|
||||
assertThat(role.permissions()).hasSize(1);
|
||||
assertThat(updated.permissions()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_RemovePermission_When_PermissionProvided")
|
||||
void should_RemovePermission_When_PermissionProvided() {
|
||||
// Arrange
|
||||
Set<Permission> initialPermissions = new HashSet<>(Set.of(
|
||||
Permission.USER_READ,
|
||||
Permission.USER_WRITE
|
||||
));
|
||||
@DisplayName("should_ReturnNewRoleWithoutPermission_When_RemovePermissionCalled")
|
||||
void should_ReturnNewRoleWithoutPermission_When_RemovePermissionCalled() {
|
||||
Set<Permission> initialPermissions = new HashSet<>(Set.of(Permission.USER_READ, Permission.USER_WRITE));
|
||||
Role role = Role.reconstitute(roleId, roleName, initialPermissions, description);
|
||||
|
||||
// Act
|
||||
role.removePermission(Permission.USER_READ);
|
||||
Result<UserError, Role> result = role.removePermission(Permission.USER_READ);
|
||||
|
||||
// Assert
|
||||
assertThat(role.permissions()).doesNotContain(Permission.USER_READ);
|
||||
assertThat(role.permissions()).contains(Permission.USER_WRITE);
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().permissions()).doesNotContain(Permission.USER_READ);
|
||||
assertThat(result.unsafeGetValue().permissions()).contains(Permission.USER_WRITE);
|
||||
assertThat(role.permissions()).contains(Permission.USER_READ); // original unchanged
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_NotThrowException_When_RemovingNonExistentPermission")
|
||||
void should_NotThrowException_When_RemovingNonExistentPermission() {
|
||||
// Arrange
|
||||
@DisplayName("should_ReturnFailure_When_NullPermissionProvidedToRemovePermission")
|
||||
void should_ReturnFailure_When_NullPermissionProvidedToRemovePermission() {
|
||||
Role role = Role.reconstitute(roleId, roleName, new HashSet<>(), description);
|
||||
|
||||
// Act & Assert
|
||||
assertThatCode(() -> role.removePermission(Permission.USER_READ))
|
||||
.doesNotThrowAnyException();
|
||||
Result<UserError, Role> result = role.removePermission(null);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.NullPermission.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_UpdateDescription_When_NewDescriptionProvided")
|
||||
void should_UpdateDescription_When_NewDescriptionProvided() {
|
||||
// Arrange
|
||||
@DisplayName("should_ReturnNewRoleWithDescription_When_UpdateDescriptionCalled")
|
||||
void should_ReturnNewRoleWithDescription_When_UpdateDescriptionCalled() {
|
||||
Role role = Role.reconstitute(roleId, roleName, permissions, description);
|
||||
String newDescription = "Updated administrator role";
|
||||
|
||||
// Act
|
||||
role.updateDescription(newDescription);
|
||||
Result<UserError, Role> result = role.updateDescription(newDescription);
|
||||
|
||||
// Assert
|
||||
assertThat(role.description()).isEqualTo(newDescription);
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().description()).isEqualTo(newDescription);
|
||||
assertThat(role.description()).isEqualTo(description); // original unchanged
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_SetDescriptionToNull_When_NullDescriptionProvided")
|
||||
void should_SetDescriptionToNull_When_NullDescriptionProvided() {
|
||||
// Arrange
|
||||
Role role = Role.reconstitute(roleId, roleName, permissions, description);
|
||||
|
||||
// Act
|
||||
role.updateDescription(null);
|
||||
Role updated = role.updateDescription(null).unsafeGetValue();
|
||||
|
||||
// Assert
|
||||
assertThat(role.description()).isNull();
|
||||
assertThat(updated.description()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_CheckPermission_When_RoleHasPermission")
|
||||
void should_CheckPermission_When_RoleHasPermission() {
|
||||
// Arrange
|
||||
Role role = Role.reconstitute(
|
||||
roleId,
|
||||
roleName,
|
||||
new HashSet<>(Set.of(Permission.USER_READ, Permission.USER_WRITE)),
|
||||
description
|
||||
);
|
||||
Role role = Role.reconstitute(roleId, roleName, new HashSet<>(Set.of(Permission.USER_READ, Permission.USER_WRITE)), description);
|
||||
|
||||
// Act & Assert
|
||||
assertThat(role.hasPermission(Permission.USER_READ)).isTrue();
|
||||
assertThat(role.hasPermission(Permission.USER_WRITE)).isTrue();
|
||||
}
|
||||
|
|
@ -227,26 +184,17 @@ class RoleTest {
|
|||
@Test
|
||||
@DisplayName("should_CheckPermission_When_RoleLacksPermission")
|
||||
void should_CheckPermission_When_RoleLacksPermission() {
|
||||
// Arrange
|
||||
Role role = Role.reconstitute(
|
||||
roleId,
|
||||
roleName,
|
||||
new HashSet<>(Set.of(Permission.USER_READ)),
|
||||
description
|
||||
);
|
||||
Role role = Role.reconstitute(roleId, roleName, new HashSet<>(Set.of(Permission.USER_READ)), description);
|
||||
|
||||
// Act & Assert
|
||||
assertThat(role.hasPermission(Permission.USER_DELETE)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_BeEqualToAnother_When_BothHaveSameId")
|
||||
void should_BeEqualToAnother_When_BothHaveSameId() {
|
||||
// Arrange
|
||||
Role role1 = Role.reconstitute(roleId, RoleName.ADMIN, permissions, description);
|
||||
Role role2 = Role.reconstitute(roleId, RoleName.PRODUCTION_MANAGER, new HashSet<>(), "Different role");
|
||||
|
||||
// Act & Assert
|
||||
assertThat(role1).isEqualTo(role2);
|
||||
assertThat(role1.hashCode()).isEqualTo(role2.hashCode());
|
||||
}
|
||||
|
|
@ -254,24 +202,19 @@ class RoleTest {
|
|||
@Test
|
||||
@DisplayName("should_NotBeEqual_When_DifferentIds")
|
||||
void should_NotBeEqual_When_DifferentIds() {
|
||||
// Arrange
|
||||
Role role1 = Role.reconstitute(RoleId.generate(), roleName, permissions, description);
|
||||
Role role2 = Role.reconstitute(RoleId.generate(), roleName, permissions, description);
|
||||
|
||||
// Act & Assert
|
||||
assertThat(role1).isNotEqualTo(role2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnUnmodifiablePermissionSet_When_PermissionsRetrieved")
|
||||
void should_ReturnUnmodifiablePermissionSet_When_PermissionsRetrieved() {
|
||||
// Arrange
|
||||
Role role = Role.reconstitute(roleId, roleName, permissions, description);
|
||||
|
||||
// Act
|
||||
Set<Permission> retrievedPermissions = role.permissions();
|
||||
|
||||
// Assert
|
||||
assertThatThrownBy(() -> retrievedPermissions.add(Permission.USER_DELETE))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
|
@ -279,49 +222,23 @@ class RoleTest {
|
|||
@Test
|
||||
@DisplayName("should_PreserveImmutabilityOfPermissions_When_PermissionsModified")
|
||||
void should_PreserveImmutabilityOfPermissions_When_PermissionsModified() {
|
||||
// Arrange
|
||||
Set<Permission> initialPermissions = new HashSet<>(Set.of(Permission.USER_READ));
|
||||
Role role = Role.reconstitute(roleId, roleName, initialPermissions, description);
|
||||
|
||||
// Act - Modify the original set passed to constructor
|
||||
initialPermissions.add(Permission.USER_WRITE);
|
||||
|
||||
// Assert - Role should not be affected
|
||||
assertThat(role.permissions()).doesNotContain(Permission.USER_WRITE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_SupportMultipleRoleNames_When_DifferentNamesUsed")
|
||||
void should_SupportMultipleRoleNames_When_DifferentNamesUsed() {
|
||||
// Arrange & Act
|
||||
Role adminRole = Role.reconstitute(RoleId.generate(), RoleName.ADMIN, permissions, "Admin");
|
||||
Role managerRole = Role.reconstitute(RoleId.generate(), RoleName.PRODUCTION_MANAGER, permissions, "Manager");
|
||||
Role workerRole = Role.reconstitute(RoleId.generate(), RoleName.PRODUCTION_WORKER, permissions, "Worker");
|
||||
|
||||
// Assert
|
||||
assertThat(adminRole.name()).isEqualTo(RoleName.ADMIN);
|
||||
assertThat(managerRole.name()).isEqualTo(RoleName.PRODUCTION_MANAGER);
|
||||
assertThat(workerRole.name()).isEqualTo(RoleName.PRODUCTION_WORKER);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_AllowDifferentPermissionSets_When_MultipleRolesCreated")
|
||||
void should_AllowDifferentPermissionSets_When_MultipleRolesCreated() {
|
||||
// Arrange
|
||||
Set<Permission> adminPerms = new HashSet<>(Set.of(
|
||||
Permission.USER_READ, Permission.USER_WRITE, Permission.USER_DELETE
|
||||
));
|
||||
Set<Permission> readerPerms = new HashSet<>(Set.of(
|
||||
Permission.USER_READ
|
||||
));
|
||||
|
||||
// Act
|
||||
Role adminRole = Role.reconstitute(RoleId.generate(), RoleName.ADMIN, adminPerms, "Admin");
|
||||
Role readerRole = Role.reconstitute(RoleId.generate(), RoleName.PRODUCTION_WORKER, readerPerms, "Reader");
|
||||
|
||||
// Assert
|
||||
assertThat(adminRole.permissions()).hasSize(3);
|
||||
assertThat(readerRole.permissions()).hasSize(1);
|
||||
assertThat(adminRole.permissions()).isNotEqualTo(readerRole.permissions());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import static org.assertj.core.api.Assertions.*;
|
|||
|
||||
/**
|
||||
* Unit tests for User Entity.
|
||||
* Tests validation, business methods, status management, role assignment, and permissions.
|
||||
* Tests validation, immutable business methods, status transitions, role assignment, and permissions.
|
||||
*/
|
||||
@DisplayName("User Entity")
|
||||
class UserTest {
|
||||
|
|
@ -41,20 +41,8 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_CreateUser_When_ValidDataProvided")
|
||||
void should_CreateUser_When_ValidDataProvided() {
|
||||
// Act
|
||||
User user = User.reconstitute(
|
||||
userId,
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
roles,
|
||||
branchId,
|
||||
UserStatus.ACTIVE,
|
||||
createdAt,
|
||||
null
|
||||
);
|
||||
User user = User.reconstitute(userId, username, email, passwordHash, roles, branchId, UserStatus.ACTIVE, createdAt, null);
|
||||
|
||||
// Assert
|
||||
assertThat(user.id()).isEqualTo(userId);
|
||||
assertThat(user.username()).isEqualTo(username);
|
||||
assertThat(user.email()).isEqualTo(email);
|
||||
|
|
@ -68,22 +56,10 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_SetDefaultCreatedAtToNow_When_NullProvidedForCreatedAt")
|
||||
void should_SetDefaultCreatedAtToNow_When_NullProvidedForCreatedAt() {
|
||||
// Act
|
||||
LocalDateTime before = LocalDateTime.now();
|
||||
User user = User.reconstitute(
|
||||
userId,
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
roles,
|
||||
branchId,
|
||||
UserStatus.ACTIVE,
|
||||
null,
|
||||
null
|
||||
);
|
||||
User user = User.reconstitute(userId, username, email, passwordHash, roles, branchId, UserStatus.ACTIVE, null, null);
|
||||
LocalDateTime after = LocalDateTime.now();
|
||||
|
||||
// Assert
|
||||
assertThat(user.createdAt()).isNotNull();
|
||||
assertThat(user.createdAt()).isBetween(before, after);
|
||||
}
|
||||
|
|
@ -91,10 +67,8 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_ReturnFailure_When_NullUsernameProvided")
|
||||
void should_ReturnFailure_When_NullUsernameProvided() {
|
||||
// Act
|
||||
Result<UserError, User> result = User.create(null, email, passwordHash, roles, branchId);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidUsername.class);
|
||||
}
|
||||
|
|
@ -102,10 +76,8 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_ReturnFailure_When_EmptyUsernameProvided")
|
||||
void should_ReturnFailure_When_EmptyUsernameProvided() {
|
||||
// Act
|
||||
Result<UserError, User> result = User.create("", email, passwordHash, roles, branchId);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidUsername.class);
|
||||
}
|
||||
|
|
@ -113,10 +85,8 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_ReturnFailure_When_InvalidEmailProvided")
|
||||
void should_ReturnFailure_When_InvalidEmailProvided() {
|
||||
// Act
|
||||
Result<UserError, User> result = User.create(username, "invalid-email", passwordHash, roles, branchId);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidEmail.class);
|
||||
}
|
||||
|
|
@ -125,10 +95,8 @@ class UserTest {
|
|||
@ValueSource(strings = {"", " ", "notanemail"})
|
||||
@DisplayName("should_ReturnFailure_When_InvalidEmailFormatsProvided")
|
||||
void should_ReturnFailure_When_InvalidEmailFormatsProvided(String invalidEmail) {
|
||||
// Act
|
||||
Result<UserError, User> result = User.create(username, invalidEmail, passwordHash, roles, branchId);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidEmail.class);
|
||||
}
|
||||
|
|
@ -136,10 +104,8 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_ReturnFailure_When_NullPasswordHashProvided")
|
||||
void should_ReturnFailure_When_NullPasswordHashProvided() {
|
||||
// Act
|
||||
Result<UserError, User> result = User.create(username, email, null, roles, branchId);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.NullPasswordHash.class);
|
||||
}
|
||||
|
|
@ -147,16 +113,8 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_CreateUser_When_FactoryMethodCalled")
|
||||
void should_CreateUser_When_FactoryMethodCalled() {
|
||||
// Act
|
||||
User user = User.create(
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
roles,
|
||||
branchId
|
||||
).unsafeGetValue();
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
|
||||
// Assert
|
||||
assertThat(user.username()).isEqualTo(username);
|
||||
assertThat(user.email()).isEqualTo(email);
|
||||
assertThat(user.passwordHash()).isEqualTo(passwordHash);
|
||||
|
|
@ -168,137 +126,181 @@ class UserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_UpdateLastLogin_When_MethodCalled")
|
||||
void should_UpdateLastLogin_When_MethodCalled() {
|
||||
// Arrange
|
||||
@DisplayName("should_ReturnNewUserWithLastLogin_When_WithLastLoginCalled")
|
||||
void should_ReturnNewUserWithLastLogin_When_WithLastLoginCalled() {
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
// Act
|
||||
user.updateLastLogin(now);
|
||||
User updated = user.withLastLogin(now).unsafeGetValue();
|
||||
|
||||
// Assert
|
||||
assertThat(user.lastLogin()).isEqualTo(now);
|
||||
assertThat(updated.lastLogin()).isEqualTo(now);
|
||||
assertThat(user.lastLogin()).isNull(); // original unchanged
|
||||
assertThat(updated.id()).isEqualTo(user.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ChangePassword_When_NewHashProvided")
|
||||
void should_ChangePassword_When_NewHashProvided() {
|
||||
// Arrange
|
||||
@DisplayName("should_ReturnNewUserWithPassword_When_ChangePasswordCalled")
|
||||
void should_ReturnNewUserWithPassword_When_ChangePasswordCalled() {
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
PasswordHash newHash = new PasswordHash("$2b$12$R9h/cIPz0gi.URNN3kh2OPST9EBwVeL00lzQRYe3z08MZx3e8YCWW");
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = user.changePassword(newHash);
|
||||
Result<UserError, User> result = user.changePassword(newHash);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(user.passwordHash()).isEqualTo(newHash);
|
||||
assertThat(user.passwordHash()).isNotEqualTo(passwordHash);
|
||||
assertThat(result.unsafeGetValue().passwordHash()).isEqualTo(newHash);
|
||||
assertThat(user.passwordHash()).isEqualTo(passwordHash); // original unchanged
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnFailure_When_NullPasswordHashProvidedToChangePassword")
|
||||
void should_ReturnFailure_When_NullPasswordHashProvidedToChangePassword() {
|
||||
// Arrange
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = user.changePassword(null);
|
||||
Result<UserError, User> result = user.changePassword(null);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.NullPasswordHash.class);
|
||||
}
|
||||
|
||||
// ==================== Status Transition Tests ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("should_LockUser_When_LockMethodCalled")
|
||||
void should_LockUser_When_LockMethodCalled() {
|
||||
// Arrange
|
||||
@DisplayName("should_LockUser_When_StatusIsActive")
|
||||
void should_LockUser_When_StatusIsActive() {
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
assertThat(user.status()).isEqualTo(UserStatus.ACTIVE);
|
||||
|
||||
// Act
|
||||
user.lock();
|
||||
Result<UserError, User> result = user.lock();
|
||||
|
||||
// Assert
|
||||
assertThat(user.status()).isEqualTo(UserStatus.LOCKED);
|
||||
assertThat(user.isLocked()).isTrue();
|
||||
assertThat(user.isActive()).isFalse();
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().status()).isEqualTo(UserStatus.LOCKED);
|
||||
assertThat(result.unsafeGetValue().isLocked()).isTrue();
|
||||
assertThat(user.status()).isEqualTo(UserStatus.ACTIVE); // original unchanged
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_UnlockUser_When_UnlockMethodCalled")
|
||||
void should_UnlockUser_When_UnlockMethodCalled() {
|
||||
// Arrange
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
user.lock();
|
||||
assertThat(user.status()).isEqualTo(UserStatus.LOCKED);
|
||||
@DisplayName("should_FailLock_When_StatusIsLocked")
|
||||
void should_FailLock_When_StatusIsLocked() {
|
||||
User user = User.reconstitute(userId, username, email, passwordHash, roles, branchId, UserStatus.LOCKED, createdAt, null);
|
||||
|
||||
// Act
|
||||
user.unlock();
|
||||
Result<UserError, User> result = user.lock();
|
||||
|
||||
// Assert
|
||||
assertThat(user.status()).isEqualTo(UserStatus.ACTIVE);
|
||||
assertThat(user.isActive()).isTrue();
|
||||
assertThat(user.isLocked()).isFalse();
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidStatusTransition.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_DeactivateUser_When_DeactivateMethodCalled")
|
||||
void should_DeactivateUser_When_DeactivateMethodCalled() {
|
||||
// Arrange
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
@DisplayName("should_FailLock_When_StatusIsInactive")
|
||||
void should_FailLock_When_StatusIsInactive() {
|
||||
User user = User.reconstitute(userId, username, email, passwordHash, roles, branchId, UserStatus.INACTIVE, createdAt, null);
|
||||
|
||||
// Act
|
||||
user.deactivate();
|
||||
Result<UserError, User> result = user.lock();
|
||||
|
||||
// Assert
|
||||
assertThat(user.status()).isEqualTo(UserStatus.INACTIVE);
|
||||
assertThat(user.isActive()).isFalse();
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidStatusTransition.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ActivateUser_When_ActivateMethodCalled")
|
||||
void should_ActivateUser_When_ActivateMethodCalled() {
|
||||
// Arrange
|
||||
@DisplayName("should_UnlockUser_When_StatusIsLocked")
|
||||
void should_UnlockUser_When_StatusIsLocked() {
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
user.deactivate();
|
||||
assertThat(user.status()).isEqualTo(UserStatus.INACTIVE);
|
||||
User locked = user.lock().unsafeGetValue();
|
||||
|
||||
// Act
|
||||
user.activate();
|
||||
Result<UserError, User> result = locked.unlock();
|
||||
|
||||
// Assert
|
||||
assertThat(user.status()).isEqualTo(UserStatus.ACTIVE);
|
||||
assertThat(user.isActive()).isTrue();
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().status()).isEqualTo(UserStatus.ACTIVE);
|
||||
assertThat(result.unsafeGetValue().isActive()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailUnlock_When_StatusIsActive")
|
||||
void should_FailUnlock_When_StatusIsActive() {
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
|
||||
Result<UserError, User> result = user.unlock();
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidStatusTransition.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_DeactivateUser_When_StatusIsActive")
|
||||
void should_DeactivateUser_When_StatusIsActive() {
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
|
||||
Result<UserError, User> result = user.deactivate();
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().status()).isEqualTo(UserStatus.INACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_DeactivateUser_When_StatusIsLocked")
|
||||
void should_DeactivateUser_When_StatusIsLocked() {
|
||||
User user = User.reconstitute(userId, username, email, passwordHash, roles, branchId, UserStatus.LOCKED, createdAt, null);
|
||||
|
||||
Result<UserError, User> result = user.deactivate();
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().status()).isEqualTo(UserStatus.INACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailDeactivate_When_StatusIsInactive")
|
||||
void should_FailDeactivate_When_StatusIsInactive() {
|
||||
User user = User.reconstitute(userId, username, email, passwordHash, roles, branchId, UserStatus.INACTIVE, createdAt, null);
|
||||
|
||||
Result<UserError, User> result = user.deactivate();
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidStatusTransition.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ActivateUser_When_StatusIsInactive")
|
||||
void should_ActivateUser_When_StatusIsInactive() {
|
||||
User user = User.reconstitute(userId, username, email, passwordHash, roles, branchId, UserStatus.INACTIVE, createdAt, null);
|
||||
|
||||
Result<UserError, User> result = user.activate();
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().status()).isEqualTo(UserStatus.ACTIVE);
|
||||
assertThat(result.unsafeGetValue().isActive()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_FailActivate_When_StatusIsActive")
|
||||
void should_FailActivate_When_StatusIsActive() {
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
|
||||
Result<UserError, User> result = user.activate();
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidStatusTransition.class);
|
||||
}
|
||||
|
||||
// ==================== Role Tests ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("should_AssignRole_When_RoleProvided")
|
||||
void should_AssignRole_When_RoleProvided() {
|
||||
// Arrange
|
||||
User user = User.create(username, email, passwordHash, new HashSet<>(), branchId).unsafeGetValue();
|
||||
Role role = createTestRole("ADMIN");
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = user.assignRole(role);
|
||||
Result<UserError, User> result = user.assignRole(role);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(user.roles()).contains(role);
|
||||
assertThat(result.unsafeGetValue().roles()).contains(role);
|
||||
assertThat(user.roles()).doesNotContain(role); // original unchanged
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnFailure_When_NullRoleProvidedToAssignRole")
|
||||
void should_ReturnFailure_When_NullRoleProvidedToAssignRole() {
|
||||
// Arrange
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = user.assignRole(null);
|
||||
Result<UserError, User> result = user.assignRole(null);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.NullRole.class);
|
||||
}
|
||||
|
|
@ -306,43 +308,49 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_RemoveRole_When_RoleProvided")
|
||||
void should_RemoveRole_When_RoleProvided() {
|
||||
// Arrange
|
||||
Role role = createTestRole("ADMIN");
|
||||
User user = User.create(username, email, passwordHash, new HashSet<>(Set.of(role)), branchId).unsafeGetValue();
|
||||
assertThat(user.roles()).contains(role);
|
||||
|
||||
// Act
|
||||
user.removeRole(role);
|
||||
Result<UserError, User> result = user.removeRole(role);
|
||||
|
||||
// Assert
|
||||
assertThat(user.roles()).doesNotContain(role);
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().roles()).doesNotContain(role);
|
||||
assertThat(user.roles()).contains(role); // original unchanged
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnFailure_When_NullRoleProvidedToRemoveRole")
|
||||
void should_ReturnFailure_When_NullRoleProvidedToRemoveRole() {
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
|
||||
Result<UserError, User> result = user.removeRole(null);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.NullRole.class);
|
||||
}
|
||||
|
||||
// ==================== Email & Branch Tests ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("should_UpdateEmail_When_ValidEmailProvided")
|
||||
void should_UpdateEmail_When_ValidEmailProvided() {
|
||||
// Arrange
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
String newEmail = "newemail@example.com";
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = user.updateEmail(newEmail);
|
||||
Result<UserError, User> result = user.updateEmail(newEmail);
|
||||
|
||||
// Assert
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(user.email()).isEqualTo(newEmail);
|
||||
assertThat(result.unsafeGetValue().email()).isEqualTo(newEmail);
|
||||
assertThat(user.email()).isEqualTo(email); // original unchanged
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnFailure_When_InvalidEmailProvidedToUpdateEmail")
|
||||
void should_ReturnFailure_When_InvalidEmailProvidedToUpdateEmail() {
|
||||
// Arrange
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
|
||||
// Act
|
||||
Result<UserError, Void> result = user.updateEmail("invalid-email");
|
||||
Result<UserError, User> result = user.updateEmail("invalid-email");
|
||||
|
||||
// Assert
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(UserError.InvalidEmail.class);
|
||||
}
|
||||
|
|
@ -350,71 +358,46 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_UpdateBranch_When_BranchIdProvided")
|
||||
void should_UpdateBranch_When_BranchIdProvided() {
|
||||
// Arrange
|
||||
User user = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
String newBranchId = "branch-2";
|
||||
|
||||
// Act
|
||||
user.updateBranch(newBranchId);
|
||||
Result<UserError, User> result = user.updateBranch(newBranchId);
|
||||
|
||||
// Assert
|
||||
assertThat(user.branchId()).isEqualTo(newBranchId);
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().branchId()).isEqualTo(newBranchId);
|
||||
assertThat(user.branchId()).isEqualTo(branchId); // original unchanged
|
||||
}
|
||||
|
||||
// ==================== Permission Tests ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnAllPermissions_When_GetAllPermissionsMethodCalled")
|
||||
void should_ReturnAllPermissions_When_GetAllPermissionsMethodCalled() {
|
||||
// Arrange
|
||||
Set<Permission> role1Perms = Set.of(Permission.USER_READ, Permission.USER_WRITE);
|
||||
Set<Permission> role2Perms = Set.of(Permission.ROLE_READ, Permission.ROLE_WRITE);
|
||||
Role role1 = createRoleWithPermissions("ADMIN", role1Perms);
|
||||
Role role2 = createRoleWithPermissions("PRODUCTION_MANAGER", role2Perms);
|
||||
User user = User.create(
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
new HashSet<>(Set.of(role1, role2)),
|
||||
branchId
|
||||
).unsafeGetValue();
|
||||
User user = User.create(username, email, passwordHash, new HashSet<>(Set.of(role1, role2)), branchId).unsafeGetValue();
|
||||
|
||||
// Act
|
||||
Set<Permission> allPermissions = user.getAllPermissions();
|
||||
|
||||
// Assert
|
||||
assertThat(allPermissions).contains(Permission.USER_READ, Permission.USER_WRITE,
|
||||
Permission.ROLE_READ, Permission.ROLE_WRITE);
|
||||
assertThat(allPermissions).contains(Permission.USER_READ, Permission.USER_WRITE, Permission.ROLE_READ, Permission.ROLE_WRITE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnEmptyPermissions_When_UserHasNoRoles")
|
||||
void should_ReturnEmptyPermissions_When_UserHasNoRoles() {
|
||||
// Arrange
|
||||
User user = User.create(username, email, passwordHash, new HashSet<>(), branchId).unsafeGetValue();
|
||||
|
||||
// Act
|
||||
Set<Permission> permissions = user.getAllPermissions();
|
||||
|
||||
// Assert
|
||||
assertThat(permissions).isEmpty();
|
||||
assertThat(user.getAllPermissions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_CheckPermission_When_UserHasPermission")
|
||||
void should_CheckPermission_When_UserHasPermission() {
|
||||
// Arrange
|
||||
Role role = createRoleWithPermissions(
|
||||
"ADMIN",
|
||||
Set.of(Permission.USER_READ, Permission.USER_WRITE)
|
||||
);
|
||||
User user = User.create(
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
new HashSet<>(Set.of(role)),
|
||||
branchId
|
||||
).unsafeGetValue();
|
||||
Role role = createRoleWithPermissions("ADMIN", Set.of(Permission.USER_READ, Permission.USER_WRITE));
|
||||
User user = User.create(username, email, passwordHash, new HashSet<>(Set.of(role)), branchId).unsafeGetValue();
|
||||
|
||||
// Act & Assert
|
||||
assertThat(user.hasPermission(Permission.USER_READ)).isTrue();
|
||||
assertThat(user.hasPermission(Permission.USER_WRITE)).isTrue();
|
||||
}
|
||||
|
|
@ -422,51 +405,20 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_CheckPermission_When_UserLacksPermission")
|
||||
void should_CheckPermission_When_UserLacksPermission() {
|
||||
// Arrange
|
||||
Role role = createRoleWithPermissions(
|
||||
"PRODUCTION_WORKER",
|
||||
Set.of(Permission.USER_READ)
|
||||
);
|
||||
User user = User.create(
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
new HashSet<>(Set.of(role)),
|
||||
branchId
|
||||
).unsafeGetValue();
|
||||
Role role = createRoleWithPermissions("PRODUCTION_WORKER", Set.of(Permission.USER_READ));
|
||||
User user = User.create(username, email, passwordHash, new HashSet<>(Set.of(role)), branchId).unsafeGetValue();
|
||||
|
||||
// Act & Assert
|
||||
assertThat(user.hasPermission(Permission.USER_DELETE)).isFalse();
|
||||
}
|
||||
|
||||
// ==================== Equality Tests ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("should_BeEqualToAnother_When_BothHaveSameId")
|
||||
void should_BeEqualToAnother_When_BothHaveSameId() {
|
||||
// Arrange
|
||||
User user1 = User.reconstitute(
|
||||
userId,
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
roles,
|
||||
branchId,
|
||||
UserStatus.ACTIVE,
|
||||
createdAt,
|
||||
null
|
||||
);
|
||||
User user2 = User.reconstitute(
|
||||
userId,
|
||||
"different_username",
|
||||
"different@example.com",
|
||||
passwordHash,
|
||||
roles,
|
||||
"different-branch",
|
||||
UserStatus.INACTIVE,
|
||||
createdAt,
|
||||
null
|
||||
);
|
||||
User user1 = User.reconstitute(userId, username, email, passwordHash, roles, branchId, UserStatus.ACTIVE, createdAt, null);
|
||||
User user2 = User.reconstitute(userId, "different_username", "different@example.com", passwordHash, roles, "different-branch", UserStatus.INACTIVE, createdAt, null);
|
||||
|
||||
// Act & Assert
|
||||
assertThat(user1).isEqualTo(user2);
|
||||
assertThat(user1.hashCode()).isEqualTo(user2.hashCode());
|
||||
}
|
||||
|
|
@ -474,31 +426,20 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_NotBeEqual_When_DifferentIds")
|
||||
void should_NotBeEqual_When_DifferentIds() {
|
||||
// Arrange
|
||||
User user1 = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
User user2 = User.create(username, email, passwordHash, roles, branchId).unsafeGetValue();
|
||||
|
||||
// Act & Assert
|
||||
assertThat(user1).isNotEqualTo(user2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should_ReturnUnmodifiableRoleSet_When_RolesRetrieved")
|
||||
void should_ReturnUnmodifiableRoleSet_When_RolesRetrieved() {
|
||||
// Arrange
|
||||
Role role = createTestRole("ADMIN");
|
||||
User user = User.create(
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
new HashSet<>(Set.of(role)),
|
||||
branchId
|
||||
).unsafeGetValue();
|
||||
User user = User.create(username, email, passwordHash, new HashSet<>(Set.of(role)), branchId).unsafeGetValue();
|
||||
|
||||
// Act
|
||||
Set<Role> retrievedRoles = user.roles();
|
||||
|
||||
// Assert
|
||||
assertThatThrownBy(() -> retrievedRoles.add(createTestRole("PRODUCTION_MANAGER")))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
|
@ -506,20 +447,11 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_ReturnUnmodifiablePermissionSet_When_PermissionsRetrieved")
|
||||
void should_ReturnUnmodifiablePermissionSet_When_PermissionsRetrieved() {
|
||||
// Arrange
|
||||
Role role = createRoleWithPermissions("ADMIN", Set.of(Permission.USER_READ));
|
||||
User user = User.create(
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
new HashSet<>(Set.of(role)),
|
||||
branchId
|
||||
).unsafeGetValue();
|
||||
User user = User.create(username, email, passwordHash, new HashSet<>(Set.of(role)), branchId).unsafeGetValue();
|
||||
|
||||
// Act
|
||||
Set<Permission> permissions = user.getAllPermissions();
|
||||
|
||||
// Assert
|
||||
assertThatThrownBy(() -> permissions.add(Permission.USER_WRITE))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
|
@ -527,40 +459,18 @@ class UserTest {
|
|||
@Test
|
||||
@DisplayName("should_PreserveNullRolesAsEmptySet_When_NullRolesProvided")
|
||||
void should_PreserveNullRolesAsEmptySet_When_NullRolesProvided() {
|
||||
// Act
|
||||
User user = User.reconstitute(
|
||||
userId,
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
null,
|
||||
branchId,
|
||||
UserStatus.ACTIVE,
|
||||
createdAt,
|
||||
null
|
||||
);
|
||||
User user = User.reconstitute(userId, username, email, passwordHash, null, branchId, UserStatus.ACTIVE, createdAt, null);
|
||||
|
||||
// Assert
|
||||
assertThat(user.roles()).isEmpty();
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
private Role createTestRole(String roleName) {
|
||||
return Role.reconstitute(
|
||||
RoleId.generate(),
|
||||
RoleName.valueOf(roleName),
|
||||
new HashSet<>(),
|
||||
"Test role: " + roleName
|
||||
);
|
||||
return Role.reconstitute(RoleId.generate(), RoleName.valueOf(roleName), new HashSet<>(), "Test role: " + roleName);
|
||||
}
|
||||
|
||||
private Role createRoleWithPermissions(String roleName, Set<Permission> permissions) {
|
||||
return Role.reconstitute(
|
||||
RoleId.generate(),
|
||||
RoleName.valueOf(roleName),
|
||||
new HashSet<>(permissions),
|
||||
"Test role: " + roleName
|
||||
);
|
||||
return Role.reconstitute(RoleId.generate(), RoleName.valueOf(roleName), new HashSet<>(permissions), "Test role: " + roleName);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -564,7 +564,7 @@ class UserControllerIntegrationTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Change password for non-existent user should return 404 Not Found")
|
||||
@DisplayName("Change password for non-existent user should return 403 when not self-service")
|
||||
void testChangePasswordForNonExistentUser() throws Exception {
|
||||
String nonExistentId = UUID.randomUUID().toString();
|
||||
ChangePasswordRequest request = new ChangePasswordRequest(
|
||||
|
|
@ -572,11 +572,12 @@ class UserControllerIntegrationTest {
|
|||
"NewSecurePass456!"
|
||||
);
|
||||
|
||||
// Regular user has no PASSWORD_CHANGE permission for other users → 403 before user lookup
|
||||
mockMvc.perform(put("/api/users/{id}/password", nonExistentId)
|
||||
.header("Authorization", "Bearer " + regularUserToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(status().isForbidden())
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue