diff --git a/README.md b/README.md index 8bf93af..2d336a1 100644 --- a/README.md +++ b/README.md @@ -369,18 +369,18 @@ Full CRUD for users, roles, and permissions, still inside `core/auth`. Assignmen ### Tasks -- [ ] `Role`, `Permission`, `AuditLog` entity classes (`entity/`, `@Table("roles")`/`@Table("permissions")`/`@Table("audit_logs")`) and `RoleDto`/`PermissionDto`/`AuditLogDto` (`dto/`), `RoleRowMapper`/`PermissionRowMapper`/`AuditLogRowMapper` (entity, from `ResultSet`) plus `RoleMapper`/`PermissionMapper`/`AuditLogMapper` (entity ↔ dto), `RoleDao`/`PermissionDao`/`AuditLogDao` + impl (working in entity/ types) -- [ ] `RbacService` (interface) + `RbacServiceImpl`: +- [x] `Role`, `Permission`, `AuditLog` entity classes (`entity/`, `@Table("roles")`/`@Table("permissions")`/`@Table("audit_logs")`) and `RoleDto`/`PermissionDto`/`AuditLogDto` (`dto/`), `RoleRowMapper`/`PermissionRowMapper`/`AuditLogRowMapper` (entity, from `ResultSet`) plus `RoleMapper`/`PermissionMapper`/`AuditLogMapper` (entity ↔ dto), `RoleDao`/`PermissionDao`/`AuditLogDao` + impl (working in entity/ types) +- [x] `RbacService` (interface) + `RbacServiceImpl`: - `createRole`/`updateRole`/`deleteRole` - `deleteRole` rejects if any user is still assigned this role - `createPermission`/`updatePermission`/`deletePermission` - rejects if any role still has this permission - `assignPermissionToRole`/`removePermissionFromRole` - rejects removing `ROLE:WRITE` from a role if it would leave zero users anywhere holding a role that grants it - `assignRoleToUser`/`removeRoleFromUser` - the same last-admin check applied at the point of removal from a specific user -- [ ] `AuditLogger`/`AuditLoggerImpl`: a single `log(String action, String entityType, Long entityId, String details)` method, called from every `RbacServiceImpl` mutation -- [ ] `CustomPermissionEvaluator` (`ws`, implements Spring Security's `PermissionEvaluator`): resolves the authenticated user's permissions (loaded once at login and embedded in the JWT, read from the `Authentication` object - no database call per request) and answers `hasPermission(target, permission)` calls from `@PreAuthorize` -- [ ] `SecurityConfig` updated: `@EnableMethodSecurity`, `CustomPermissionEvaluator` registered on the `MethodSecurityExpressionHandler` -- [ ] A seeding step (a Flyway data-migration): baseline permissions covering every resource/action this project defines, assigned to a seeded `ADMIN` role - without this, nobody could ever be granted `ROLE:WRITE`/`PERMISSION:WRITE` to create the first assignment -- [ ] `RoleController`, `PermissionController`, `UserController` role-assignment endpoints, `RoleConverter`/`PermissionConverter` -- [ ] Tests at all five layers, including the "still referenced" rejection on `deleteRole`/`deletePermission`, the last-admin rejection triggered both ways, `CustomPermissionEvaluator` allowing/denying correctly, and an assertion that every `RbacServiceImpl` mutation produces a matching `AuditLog` row +- [x] `AuditLogger`/`AuditLoggerImpl`: a single `log(String action, String entityType, Long entityId, String details)` method, called from every `RbacServiceImpl` mutation +- [x] `CustomPermissionEvaluator` (`ws`, implements Spring Security's `PermissionEvaluator`): resolves the authenticated user's permissions (loaded once at login and embedded in the JWT, read from the `Authentication` object - no database call per request) and answers `hasPermission(target, permission)` calls from `@PreAuthorize` +- [x] `SecurityConfig` updated: `@EnableMethodSecurity`, `CustomPermissionEvaluator` registered on the `MethodSecurityExpressionHandler` +- [x] A seeding step (a Flyway data-migration): baseline permissions covering every resource/action this project defines, assigned to a seeded `ADMIN` role - without this, nobody could ever be granted `ROLE:WRITE`/`PERMISSION:WRITE` to create the first assignment +- [x] `RoleController`, `PermissionController`, `UserController` role-assignment endpoints, `RoleConverter`/`PermissionConverter` +- [x] Tests at all five layers, including the "still referenced" rejection on `deleteRole`/`deletePermission`, the last-admin rejection triggered both ways, `CustomPermissionEvaluator` allowing/denying correctly, and an assertion that every `RbacServiceImpl` mutation produces a matching `AuditLog` row ## feature/catalog diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/DaoConfig.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/DaoConfig.java index ce44e78..9185d24 100644 --- a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/DaoConfig.java +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/DaoConfig.java @@ -2,11 +2,17 @@ import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao; import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.AuditLogDao; import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.PermissionDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.RoleDao; import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; import com.edgareldy.springjdbctutorial.core.auth.dao.impl.ActivationTokenDaoImpl; import com.edgareldy.springjdbctutorial.core.auth.dao.impl.BlacklistedTokenDaoImpl; +import com.edgareldy.springjdbctutorial.core.auth.dao.impl.AuditLogDaoImpl; import com.edgareldy.springjdbctutorial.core.auth.dao.impl.PasswordResetTokenDaoImpl; +import com.edgareldy.springjdbctutorial.core.auth.dao.impl.PermissionDaoImpl; +import com.edgareldy.springjdbctutorial.core.auth.dao.impl.RoleDaoImpl; import com.edgareldy.springjdbctutorial.core.auth.dao.impl.UserDaoImpl; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -42,4 +48,19 @@ public PasswordResetTokenDao passwordResetTokenDao(JdbcTemplate jdbcTemplate) { public BlacklistedTokenDao blacklistedTokenDao(JdbcTemplate jdbcTemplate) { return new BlacklistedTokenDaoImpl(jdbcTemplate); } + + @Bean + public RoleDao roleDao(JdbcTemplate jdbcTemplate) { + return new RoleDaoImpl(jdbcTemplate); + } + + @Bean + public PermissionDao permissionDao(JdbcTemplate jdbcTemplate) { + return new PermissionDaoImpl(jdbcTemplate); + } + + @Bean + public AuditLogDao auditLogDao(JdbcTemplate jdbcTemplate) { + return new AuditLogDaoImpl(jdbcTemplate); + } } diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/ServiceConfig.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/ServiceConfig.java index 2fee538..4f11cd5 100644 --- a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/ServiceConfig.java +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/ServiceConfig.java @@ -1,24 +1,37 @@ package com.edgareldy.springjdbctutorial.core.auth.config; import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.AuditLogDao; import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao; import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.PermissionDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.RoleDao; import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.service.ActorProvider; +import com.edgareldy.springjdbctutorial.core.auth.service.AuditLogger; import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.auth.service.RbacService; +import com.edgareldy.springjdbctutorial.core.auth.service.impl.AdminBootstrap; +import com.edgareldy.springjdbctutorial.core.auth.service.impl.AuditLoggerImpl; import com.edgareldy.springjdbctutorial.core.auth.service.impl.AuthServiceImpl; import com.edgareldy.springjdbctutorial.core.auth.service.impl.ExpiredTokenCleanupJob; +import com.edgareldy.springjdbctutorial.core.auth.service.impl.RbacServiceImpl; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.core.env.Environment; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import java.time.Clock; /** * Declares the service beans of the auth module. Imports its DaoConfig so importing this class alone - * is enough. The Clock bean comes from CommonConfig, the JdbcTemplate from DataSourceConfig. + * is enough. The Clock bean comes from CommonConfig, the JdbcTemplate from DataSourceConfig. Any context + * importing this class must also provide an ActorProvider bean (declared by the web layer). *

* Created edgar.muhamyangabo on 9/19/26 * Author : edgar.muhamyangabo @@ -54,4 +67,33 @@ public AuthService authService(UserDao userDao, ActivationTokenDao activationTok public ExpiredTokenCleanupJob expiredTokenCleanupJob(AuthService authService) { return new ExpiredTokenCleanupJob(authService); } + + // AuditLogger and RbacService are returned as their interfaces so the @Transactional advice (including + // the REQUIRES_NEW of logRejected) is applied by the proxy. RbacServiceImpl receives the AuditLogger + // PROXY here, which is why its refusal rows really commit in an independent transaction. + // The ActorProvider parameter is a bean the web layer declares: core only knows the interface. + @Bean + public AuditLogger auditLogger(AuditLogDao auditLogDao, ActorProvider actorProvider, Clock clock) { + return new AuditLoggerImpl(auditLogDao, actorProvider, clock); + } + + @Bean + public RbacService rbacService(UserDao userDao, RoleDao roleDao, PermissionDao permissionDao, + AuditLogger auditLogger) { + return new RbacServiceImpl(userDao, roleDao, permissionDao, auditLogger); + } + + // initMethod "run" makes Spring call AdminBootstrap.run() once the bean is wired, after Flyway (the DAOs + // depend on it). The account is created only when BOTH app.bootstrap.* values are configured (development + // only: never set them in test or production); absent values make the bean do nothing. The Environment + // is read leniently (getProperty, no default), unlike the required datasource settings. + @Bean(initMethod = "run") + public AdminBootstrap adminBootstrap(UserDao userDao, RoleDao roleDao, AuditLogger auditLogger, + PasswordEncoder passwordEncoder, PlatformTransactionManager transactionManager, + Environment environment) { + return new AdminBootstrap(userDao, roleDao, auditLogger, passwordEncoder, + new TransactionTemplate(transactionManager), + environment.getProperty("app.bootstrap.admin-email"), + environment.getProperty("app.bootstrap.admin-password")); + } } diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/AuditLogDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/AuditLogDao.java new file mode 100644 index 0000000..307cae2 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/AuditLogDao.java @@ -0,0 +1,22 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao; + +import com.edgareldy.springjdbctutorial.core.auth.entity.AuditLog; + +import java.util.List; + +/** + * Data access for the audit trail. Entity types only. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface AuditLogDao { + + /** Inserts the entry and returns it with its generated id. */ + AuditLog insert(AuditLog auditLog); + + /** Every entry ordered by id, meant for tests and diagnostics. */ + List findAll(); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PermissionDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PermissionDao.java new file mode 100644 index 0000000..4c4a454 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PermissionDao.java @@ -0,0 +1,34 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao; + +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for permissions. Entity types only. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface PermissionDao { + + /** All permissions ordered by resource then action. */ + List findAll(); + + Optional findById(Long id); + + boolean existsByResourceAndAction(String resource, String action); + + /** Inserts the permission and returns it with its generated id. */ + Permission insert(Permission permission); + + void update(Permission permission); + + void delete(Long id); + + /** Number of roles currently holding this permission. */ + long countRolesWithPermission(Long permissionId); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/RoleDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/RoleDao.java new file mode 100644 index 0000000..3b3add5 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/RoleDao.java @@ -0,0 +1,48 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao; + +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; +import com.edgareldy.springjdbctutorial.core.auth.entity.Role; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for roles and their role_permission assignments. Entity types only. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface RoleDao { + + /** All roles ordered by name. */ + List findAll(); + + Optional findById(Long id); + + Optional findByRoleName(String roleName); + + boolean existsByRoleName(String roleName); + + /** Inserts the role and returns it with its generated id. */ + Role insert(Role role); + + void updateName(Long id, String roleName); + + /** Deletes the role (its role_permission rows go with it by cascade). */ + void delete(Long id); + + /** Number of users currently assigned this role. */ + long countUsersWithRole(Long roleId); + + /** Permissions of the role ordered by resource then action. */ + List findPermissionsByRoleId(Long roleId); + + void addPermission(Long roleId, Long permissionId); + + /** Returns the number of rows removed (0 when the permission was not assigned). */ + int removePermission(Long roleId, Long permissionId); + + boolean hasPermission(Long roleId, Long permissionId); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java index 044f1a0..8afac0d 100644 --- a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java @@ -34,4 +34,28 @@ public interface UserDao { /** Distinct "RESOURCE:ACTION" codes granted through the user's roles, sorted. */ List findPermissionCodesByUserId(Long userId); + + /** One page of users ordered by id (LIMIT/OFFSET). The page is zero-based. */ + List findPage(int page, int size); + + long countAll(); + + void addRole(Long userId, Long roleId); + + /** Returns the number of rows removed (0 when the role was not assigned). */ + int removeRole(Long userId, Long roleId); + + boolean hasRole(Long userId, Long roleId); + + /** + * Number of distinct users that are enabled AND not locked and hold the ROLE:WRITE permission through + * one of their roles. Reads database state, not live tokens. + */ + long countLastAdminCandidates(); + + /** + * Takes a transaction-scoped advisory lock: must be the first statement of any transaction that could + * remove the last ROLE:WRITE holder. Released automatically at commit or rollback. + */ + void acquireLastAdminLock(); } diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/AuditLogDaoImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/AuditLogDaoImpl.java new file mode 100644 index 0000000..5880eef --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/AuditLogDaoImpl.java @@ -0,0 +1,46 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.AuditLogDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.AuditLog; +import com.edgareldy.springjdbctutorial.core.auth.mapper.AuditLogRowMapper; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Timestamp; +import java.util.List; + +/** + * JdbcTemplate implementation of AuditLogDao. Declared by @Bean in DaoConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AuditLogDaoImpl extends AbstractDao implements AuditLogDao { + + private static final String COLUMNS = "id, actor_user_id, action, entity_type, entity_id, details, created_at"; + + private final AuditLogRowMapper rowMapper = new AuditLogRowMapper(); + + public AuditLogDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public AuditLog insert(AuditLog auditLog) { + long id = insertReturningId( + "INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, details, created_at) " + + "VALUES (?, ?, ?, ?, ?, ?) RETURNING id", + auditLog.getActorUserId(), auditLog.getAction(), auditLog.getEntityType(), auditLog.getEntityId(), + auditLog.getDetails(), + auditLog.getCreatedAt() == null ? null : Timestamp.from(auditLog.getCreatedAt())); + auditLog.setId(id); + return auditLog; + } + + @Override + public List findAll() { + return getJdbcTemplate().query("SELECT " + COLUMNS + " FROM audit_logs ORDER BY id", rowMapper); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PermissionDaoImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PermissionDaoImpl.java new file mode 100644 index 0000000..f909e51 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PermissionDaoImpl.java @@ -0,0 +1,68 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.PermissionDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; +import com.edgareldy.springjdbctutorial.core.auth.mapper.PermissionRowMapper; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Optional; + +/** + * JdbcTemplate implementation of PermissionDao. Declared by @Bean in DaoConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PermissionDaoImpl extends AbstractDao implements PermissionDao { + + private final PermissionRowMapper rowMapper = new PermissionRowMapper(); + + public PermissionDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public List findAll() { + return getJdbcTemplate().query( + "SELECT id, resource, action FROM permissions ORDER BY resource, action", rowMapper); + } + + @Override + public Optional findById(Long id) { + return findOne("SELECT id, resource, action FROM permissions WHERE id = ?", rowMapper, id); + } + + @Override + public boolean existsByResourceAndAction(String resource, String action) { + return count("SELECT COUNT(*) FROM permissions WHERE resource = ? AND action = ?", resource, action) > 0; + } + + @Override + public Permission insert(Permission permission) { + long id = insertReturningId( + "INSERT INTO permissions (resource, action) VALUES (?, ?) RETURNING id", + permission.getResource(), permission.getAction()); + permission.setId(id); + return permission; + } + + @Override + public void update(Permission permission) { + getJdbcTemplate().update("UPDATE permissions SET resource = ?, action = ? WHERE id = ?", + permission.getResource(), permission.getAction(), permission.getId()); + } + + @Override + public void delete(Long id) { + getJdbcTemplate().update("DELETE FROM permissions WHERE id = ?", id); + } + + @Override + public long countRolesWithPermission(Long permissionId) { + return count("SELECT COUNT(*) FROM role_permission WHERE permission_id = ?", permissionId); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/RoleDaoImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/RoleDaoImpl.java new file mode 100644 index 0000000..170efcc --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/RoleDaoImpl.java @@ -0,0 +1,99 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.RoleDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; +import com.edgareldy.springjdbctutorial.core.auth.entity.Role; +import com.edgareldy.springjdbctutorial.core.auth.mapper.PermissionRowMapper; +import com.edgareldy.springjdbctutorial.core.auth.mapper.RoleRowMapper; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Optional; + +/** + * JdbcTemplate implementation of RoleDao. Declared by @Bean in DaoConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class RoleDaoImpl extends AbstractDao implements RoleDao { + + private final RoleRowMapper rowMapper = new RoleRowMapper(); + private final PermissionRowMapper permissionRowMapper = new PermissionRowMapper(); + + public RoleDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public List findAll() { + return getJdbcTemplate().query("SELECT id, role_name FROM roles ORDER BY role_name", rowMapper); + } + + @Override + public Optional findById(Long id) { + return findOne("SELECT id, role_name FROM roles WHERE id = ?", rowMapper, id); + } + + @Override + public Optional findByRoleName(String roleName) { + return findOne("SELECT id, role_name FROM roles WHERE role_name = ?", rowMapper, roleName); + } + + @Override + public boolean existsByRoleName(String roleName) { + return count("SELECT COUNT(*) FROM roles WHERE role_name = ?", roleName) > 0; + } + + @Override + public Role insert(Role role) { + long id = insertReturningId("INSERT INTO roles (role_name) VALUES (?) RETURNING id", role.getRoleName()); + role.setId(id); + return role; + } + + @Override + public void updateName(Long id, String roleName) { + getJdbcTemplate().update("UPDATE roles SET role_name = ? WHERE id = ?", roleName, id); + } + + @Override + public void delete(Long id) { + getJdbcTemplate().update("DELETE FROM roles WHERE id = ?", id); + } + + @Override + public long countUsersWithRole(Long roleId) { + return count("SELECT COUNT(*) FROM role_user WHERE role_id = ?", roleId); + } + + @Override + public List findPermissionsByRoleId(Long roleId) { + return getJdbcTemplate().query( + "SELECT p.id, p.resource, p.action FROM permissions p " + + "JOIN role_permission rp ON rp.permission_id = p.id " + + "WHERE rp.role_id = ? ORDER BY p.resource, p.action", + permissionRowMapper, roleId); + } + + @Override + public void addPermission(Long roleId, Long permissionId) { + getJdbcTemplate().update("INSERT INTO role_permission (role_id, permission_id) VALUES (?, ?)", + roleId, permissionId); + } + + @Override + public int removePermission(Long roleId, Long permissionId) { + return getJdbcTemplate().update("DELETE FROM role_permission WHERE role_id = ? AND permission_id = ?", + roleId, permissionId); + } + + @Override + public boolean hasPermission(Long roleId, Long permissionId) { + return count("SELECT COUNT(*) FROM role_permission WHERE role_id = ? AND permission_id = ?", + roleId, permissionId) > 0; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImpl.java index 16e2bd8..1bfecba 100644 --- a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImpl.java +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImpl.java @@ -19,6 +19,9 @@ */ public class UserDaoImpl extends AbstractDao implements UserDao { + // Arbitrary but fixed key of the PostgreSQL advisory lock guarding the last-admin rule + private static final long LAST_ADMIN_LOCK_KEY = 726_001_001L; + private static final String COLUMNS = "id, first_name, last_name, email, password, enabled, account_locked"; private final UserRowMapper rowMapper = new UserRowMapper(); @@ -80,4 +83,49 @@ public List findPermissionCodesByUserId(Long userId) { + "WHERE ru.user_id = ? ORDER BY code", String.class, userId); } + + @Override + public List findPage(int page, int size) { + return getJdbcTemplate().query( + "SELECT " + COLUMNS + " FROM users ORDER BY id LIMIT ? OFFSET ?", + rowMapper, size, (long) page * size); + } + + @Override + public long countAll() { + return count("SELECT COUNT(*) FROM users"); + } + + @Override + public void addRole(Long userId, Long roleId) { + getJdbcTemplate().update("INSERT INTO role_user (role_id, user_id) VALUES (?, ?)", roleId, userId); + } + + @Override + public int removeRole(Long userId, Long roleId) { + return getJdbcTemplate().update("DELETE FROM role_user WHERE role_id = ? AND user_id = ?", roleId, userId); + } + + @Override + public boolean hasRole(Long userId, Long roleId) { + return count("SELECT COUNT(*) FROM role_user WHERE role_id = ? AND user_id = ?", roleId, userId) > 0; + } + + @Override + public long countLastAdminCandidates() { + return count("SELECT COUNT(DISTINCT u.id) FROM users u " + + "JOIN role_user ru ON ru.user_id = u.id " + + "JOIN role_permission rp ON rp.role_id = ru.role_id " + + "JOIN permissions p ON p.id = rp.permission_id " + + "WHERE u.enabled = TRUE AND u.account_locked = FALSE " + + "AND p.resource = 'ROLE' AND p.action = 'WRITE'"); + } + + @Override + public void acquireLastAdminLock() { + // pg_advisory_xact_lock is a transaction-scoped advisory lock: concurrent transactions asking for the + // same key wait here, which serialises every operation that could remove the last admin. It is + // released at commit or rollback (no explicit unlock). The function returns void, hence queryForList. + getJdbcTemplate().queryForList("SELECT pg_advisory_xact_lock(?)", LAST_ADMIN_LOCK_KEY); + } } diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/AuditLogDto.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/AuditLogDto.java new file mode 100644 index 0000000..535a7c4 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/AuditLogDto.java @@ -0,0 +1,134 @@ +package com.edgareldy.springjdbctutorial.core.auth.dto; + +import java.time.Instant; +import java.util.Objects; + +/** + * Business shape of an audit log entry. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AuditLogDto { + + private Long id; + + private Long actorUserId; + + private String action; + + private String entityType; + + private Long entityId; + + private String details; + + private Instant createdAt; + + public AuditLogDto() { + } + + public AuditLogDto(Long id, Long actorUserId, String action, String entityType, Long entityId, String details, Instant createdAt) { + this.id = id; + this.actorUserId = actorUserId; + this.action = action; + this.entityType = entityType; + this.entityId = entityId; + this.details = details; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getActorUserId() { + return actorUserId; + } + + public void setActorUserId(Long actorUserId) { + this.actorUserId = actorUserId; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getEntityType() { + return entityType; + } + + public void setEntityType(String entityType) { + this.entityType = entityType; + } + + public Long getEntityId() { + return entityId; + } + + public void setEntityId(Long entityId) { + this.entityId = entityId; + } + + public String getDetails() { + return details; + } + + public void setDetails(String details) { + this.details = details; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AuditLogDto that = (AuditLogDto) o; + return Objects.equals(id, that.id) + && Objects.equals(actorUserId, that.actorUserId) + && Objects.equals(action, that.action) + && Objects.equals(entityType, that.entityType) + && Objects.equals(entityId, that.entityId) + && Objects.equals(details, that.details) + && Objects.equals(createdAt, that.createdAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, actorUserId, action, entityType, entityId, details, createdAt); + } + + @Override + public String toString() { + return "AuditLogDto{" + + "id=" + id + + ", actorUserId=" + actorUserId + + ", action=" + action + + ", entityType=" + entityType + + ", entityId=" + entityId + + ", details=" + details + + ", createdAt=" + createdAt + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/PermissionDto.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/PermissionDto.java new file mode 100644 index 0000000..1739def --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/PermissionDto.java @@ -0,0 +1,80 @@ +package com.edgareldy.springjdbctutorial.core.auth.dto; + +import java.util.Objects; + +/** + * Business shape of a permission (a resource and an action). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PermissionDto { + + private Long id; + + private String resource; + + private String action; + + public PermissionDto() { + } + + public PermissionDto(Long id, String resource, String action) { + this.id = id; + this.resource = resource; + this.action = action; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getResource() { + return resource; + } + + public void setResource(String resource) { + this.resource = resource; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PermissionDto that = (PermissionDto) o; + return Objects.equals(id, that.id) && Objects.equals(resource, that.resource) + && Objects.equals(action, that.action); + } + + @Override + public int hashCode() { + return Objects.hash(id, resource, action); + } + + @Override + public String toString() { + return "PermissionDto{" + + "id=" + id + + ", resource=" + resource + + ", action=" + action + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/RoleDto.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/RoleDto.java new file mode 100644 index 0000000..5c5abb9 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/RoleDto.java @@ -0,0 +1,81 @@ +package com.edgareldy.springjdbctutorial.core.auth.dto; + +import java.util.List; +import java.util.Objects; + +/** + * Business shape of a role. On output the permissions are filled by the service, on input only roleName is read. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class RoleDto { + + private Long id; + + private String roleName; + + private List permissions; + + public RoleDto() { + } + + public RoleDto(Long id, String roleName, List permissions) { + this.id = id; + this.roleName = roleName; + this.permissions = permissions; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RoleDto that = (RoleDto) o; + return Objects.equals(id, that.id) && Objects.equals(roleName, that.roleName) + && Objects.equals(permissions, that.permissions); + } + + @Override + public int hashCode() { + return Objects.hash(id, roleName, permissions); + } + + @Override + public String toString() { + return "RoleDto{" + + "id=" + id + + ", roleName=" + roleName + + ", permissions=" + permissions + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/AuditLog.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/AuditLog.java new file mode 100644 index 0000000..b75851d --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/AuditLog.java @@ -0,0 +1,146 @@ +package com.edgareldy.springjdbctutorial.core.auth.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Objects; + +/** + * Row of the audit_logs table: one RBAC mutation or refusal. Annotations are documentation only. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "audit_logs") +public class AuditLog { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "actor_user_id") + private Long actorUserId; + + @Column(name = "action") + private String action; + + @Column(name = "entity_type") + private String entityType; + + @Column(name = "entity_id") + private Long entityId; + + @Column(name = "details") + private String details; + + @Column(name = "created_at") + private Instant createdAt; + + public AuditLog() { + } + + public AuditLog(Long id, Long actorUserId, String action, String entityType, Long entityId, String details, Instant createdAt) { + this.id = id; + this.actorUserId = actorUserId; + this.action = action; + this.entityType = entityType; + this.entityId = entityId; + this.details = details; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getActorUserId() { + return actorUserId; + } + + public void setActorUserId(Long actorUserId) { + this.actorUserId = actorUserId; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getEntityType() { + return entityType; + } + + public void setEntityType(String entityType) { + this.entityType = entityType; + } + + public Long getEntityId() { + return entityId; + } + + public void setEntityId(Long entityId) { + this.entityId = entityId; + } + + public String getDetails() { + return details; + } + + public void setDetails(String details) { + this.details = details; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AuditLog that = (AuditLog) o; + return Objects.equals(id, that.id) + && Objects.equals(actorUserId, that.actorUserId) + && Objects.equals(action, that.action) + && Objects.equals(entityType, that.entityType) + && Objects.equals(entityId, that.entityId) + && Objects.equals(details, that.details) + && Objects.equals(createdAt, that.createdAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, actorUserId, action, entityType, entityId, details, createdAt); + } + + @Override + public String toString() { + return "AuditLog{" + + "id=" + id + + ", actorUserId=" + actorUserId + + ", action=" + action + + ", entityType=" + entityType + + ", entityId=" + entityId + + ", details=" + details + + ", createdAt=" + createdAt + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/Permission.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/Permission.java new file mode 100644 index 0000000..658d655 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/Permission.java @@ -0,0 +1,88 @@ +package com.edgareldy.springjdbctutorial.core.auth.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.Objects; + +/** + * Row of the permissions table: an action (READ, WRITE) allowed on a resource (USER, ROLE...). Annotations are documentation only. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "permissions") +public class Permission { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "resource") + private String resource; + + @Column(name = "action") + private String action; + + public Permission() { + } + + public Permission(Long id, String resource, String action) { + this.id = id; + this.resource = resource; + this.action = action; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getResource() { + return resource; + } + + public void setResource(String resource) { + this.resource = resource; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Permission that = (Permission) o; + return Objects.equals(id, that.id) && Objects.equals(resource, that.resource) + && Objects.equals(action, that.action); + } + + @Override + public int hashCode() { + return Objects.hash(id, resource, action); + } + + @Override + public String toString() { + return "Permission{" + + "id=" + id + + ", resource=" + resource + + ", action=" + action + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/Role.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/Role.java new file mode 100644 index 0000000..fa2d343 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/Role.java @@ -0,0 +1,74 @@ +package com.edgareldy.springjdbctutorial.core.auth.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.Objects; + +/** + * Row of the roles table. The jakarta.persistence annotations are documentation only, nothing reads them at runtime. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "roles") +public class Role { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "role_name") + private String roleName; + + public Role() { + } + + public Role(Long id, String roleName) { + this.id = id; + this.roleName = roleName; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Role that = (Role) o; + return Objects.equals(id, that.id) && Objects.equals(roleName, that.roleName); + } + + @Override + public int hashCode() { + return Objects.hash(id, roleName); + } + + @Override + public String toString() { + return "Role{" + + "id=" + id + + ", roleName=" + roleName + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogMapper.java new file mode 100644 index 0000000..74cf41f --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogMapper.java @@ -0,0 +1,31 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.dto.AuditLogDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.AuditLog; + +/** + * Converts between the AuditLog entity and the AuditLogDto. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AuditLogMapper { + + public AuditLogDto toDto(AuditLog log) { + if (log == null) { + return null; + } + return new AuditLogDto(log.getId(), log.getActorUserId(), log.getAction(), log.getEntityType(), + log.getEntityId(), log.getDetails(), log.getCreatedAt()); + } + + public AuditLog toEntity(AuditLogDto dto) { + if (dto == null) { + return null; + } + return new AuditLog(dto.getId(), dto.getActorUserId(), dto.getAction(), dto.getEntityType(), + dto.getEntityId(), dto.getDetails(), dto.getCreatedAt()); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogRowMapper.java new file mode 100644 index 0000000..d25a38f --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogRowMapper.java @@ -0,0 +1,31 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.entity.AuditLog; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the audit_logs table into an AuditLog entity. Nullable columns stay null. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AuditLogRowMapper implements RowMapper { + + @Override + public AuditLog mapRow(ResultSet rs, int rowNum) throws SQLException { + // getLong returns 0 for SQL NULL, so nullable BIGINT columns are read as objects + return new AuditLog( + rs.getLong("id"), + rs.getObject("actor_user_id", Long.class), + rs.getString("action"), + rs.getString("entity_type"), + rs.getObject("entity_id", Long.class), + rs.getString("details"), + RowMapperSupport.instant(rs, "created_at")); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionMapper.java new file mode 100644 index 0000000..2a17080 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionMapper.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.dto.PermissionDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; + +/** + * Converts between the Permission entity and the PermissionDto. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PermissionMapper { + + public PermissionDto toDto(Permission permission) { + if (permission == null) { + return null; + } + return new PermissionDto(permission.getId(), permission.getResource(), permission.getAction()); + } + + public Permission toEntity(PermissionDto dto) { + if (dto == null) { + return null; + } + return new Permission(dto.getId(), dto.getResource(), dto.getAction()); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionRowMapper.java new file mode 100644 index 0000000..6364482 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionRowMapper.java @@ -0,0 +1,26 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the permissions table into a Permission entity. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PermissionRowMapper implements RowMapper { + + @Override + public Permission mapRow(ResultSet rs, int rowNum) throws SQLException { + return new Permission( + rs.getLong("id"), + rs.getString("resource"), + rs.getString("action")); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleMapper.java new file mode 100644 index 0000000..7a0d221 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleMapper.java @@ -0,0 +1,34 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.dto.RoleDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.Role; + +import java.util.ArrayList; + +/** + * Converts between the Role entity and the RoleDto. Permissions are filled by the service. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class RoleMapper { + + /** + * Entity to dto: permissions start empty. + */ + public RoleDto toDto(Role role) { + if (role == null) { + return null; + } + return new RoleDto(role.getId(), role.getRoleName(), new ArrayList<>()); + } + + public Role toEntity(RoleDto dto) { + if (dto == null) { + return null; + } + return new Role(dto.getId(), dto.getRoleName()); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleRowMapper.java new file mode 100644 index 0000000..80542b5 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleRowMapper.java @@ -0,0 +1,25 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.entity.Role; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the roles table into a Role entity. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class RoleRowMapper implements RowMapper { + + @Override + public Role mapRow(ResultSet rs, int rowNum) throws SQLException { + return new Role( + rs.getLong("id"), + rs.getString("role_name")); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/ActorProvider.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/ActorProvider.java new file mode 100644 index 0000000..dc3d4a8 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/ActorProvider.java @@ -0,0 +1,15 @@ +package com.edgareldy.springjdbctutorial.core.auth.service; + +/** + * Tells the core services who is performing the current operation, for the audit trail. Implemented by the web layer from the authenticated principal; any context importing ServiceConfig must provide one. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface ActorProvider { + + /** Id of the authenticated user, or null when there is no authenticated actor (bootstrap, scheduled job). */ + Long currentUserId(); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/AuditLogger.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/AuditLogger.java new file mode 100644 index 0000000..d6c5793 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/AuditLogger.java @@ -0,0 +1,18 @@ +package com.edgareldy.springjdbctutorial.core.auth.service; + +/** + * Writes the RBAC audit trail. A success row joins the business transaction, a refusal row is written independently so it survives the rollback. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface AuditLogger { + + /** Records a successful mutation, inside the caller's transaction (disappears if that transaction rolls back). */ + void log(String action, String entityType, Long entityId, String details); + + /** Records a refused operation in its own transaction, under the action prefixed with REJECTED_. */ + void logRejected(String action, String entityType, Long entityId, String details); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/RbacService.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/RbacService.java new file mode 100644 index 0000000..daffef1 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/RbacService.java @@ -0,0 +1,55 @@ +package com.edgareldy.springjdbctutorial.core.auth.service; + +import com.edgareldy.springjdbctutorial.core.auth.dto.PermissionDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.RoleDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; + +import java.util.List; + +/** + * Users, roles and permissions administration (RBAC). Dto types at the boundary. Missing resources throw ResourceNotFoundException (not audited), refusals BusinessRuleException (audited as REJECTED_*). Every successful mutation is audited. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface RbacService { + + /** One page of users with roles and permissions filled. Page must be >= 0 and size 1..100. */ + PageDto listUsers(int page, int size); + + UserDto getUser(Long id); + + UserDto assignRoleToUser(Long userId, Long roleId); + + /** Refused when it would leave no enabled, unlocked account holding ROLE:WRITE. */ + UserDto removeRoleFromUser(Long userId, Long roleId); + + /** Every role with its permissions. */ + List listRoles(); + + RoleDto createRole(RoleDto role); + + /** Renames the role (only the name is read). */ + RoleDto updateRole(Long id, RoleDto role); + + /** Refused while any user still has the role. */ + void deleteRole(Long id); + + RoleDto assignPermissionToRole(Long roleId, Long permissionId); + + /** Refused when it would leave no enabled, unlocked account holding ROLE:WRITE. */ + RoleDto removePermissionFromRole(Long roleId, Long permissionId); + + List listPermissions(); + + PermissionDto createPermission(PermissionDto permission); + + /** Refused when it would strip ROLE:WRITE from the last account holding it. */ + PermissionDto updatePermission(Long id, PermissionDto permission); + + /** Refused while any role still has the permission. */ + void deletePermission(Long id); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AdminBootstrap.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AdminBootstrap.java new file mode 100644 index 0000000..8a72d5f --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AdminBootstrap.java @@ -0,0 +1,88 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.RoleDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.Role; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import com.edgareldy.springjdbctutorial.core.auth.service.AuditLogger; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.support.TransactionTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +/** + * Optional first administrator, created at startup only when both configuration values are set. + * The values are for development only and must never be set in test or production. It never makes + * startup fail: every problem is logged as a warning. The password is never logged. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AdminBootstrap { + + private static final Log LOG = LogFactory.getLog(AdminBootstrap.class); + + private static final String ADMIN_ROLE = "ADMIN"; + private static final int MAX_PASSWORD_BYTES = 72; + + private final UserDao userDao; + private final RoleDao roleDao; + private final AuditLogger audit; + private final PasswordEncoder passwordEncoder; + private final TransactionTemplate transactionTemplate; + private final String email; + private final String password; + + public AdminBootstrap(UserDao userDao, RoleDao roleDao, AuditLogger audit, PasswordEncoder passwordEncoder, + TransactionTemplate transactionTemplate, String email, String password) { + this.userDao = userDao; + this.roleDao = roleDao; + this.audit = audit; + this.passwordEncoder = passwordEncoder; + this.transactionTemplate = transactionTemplate; + this.email = email; + this.password = password; + } + + // Registered with @Bean(initMethod = "run"): Spring calls it once, after the bean is built and its + // dependencies are wired. Nothing is thrown out of it, so a bootstrap problem cannot stop the application. + public void run() { + if (email == null || email.isBlank() || password == null || password.isEmpty()) { + return; + } + try { + String normalizedEmail = email.trim().toLowerCase(Locale.ROOT); + if (password.getBytes(StandardCharsets.UTF_8).length > MAX_PASSWORD_BYTES) { + LOG.warn("Admin bootstrap skipped: the configured password exceeds 72 bytes"); + return; + } + // The programmatic counterpart of @Transactional: the user, its role and the audit row are one unit. + Boolean created = transactionTemplate.execute(status -> createAdmin(normalizedEmail)); + if (Boolean.TRUE.equals(created)) { + LOG.info("Admin bootstrap created the administrator account " + normalizedEmail); + } + } catch (Exception e) { + // Deliberately broad: whatever goes wrong here (missing ADMIN role, database error) must not abort startup + LOG.warn("Admin bootstrap failed, startup continues: " + e.getClass().getSimpleName() + + ": " + e.getMessage()); + } + } + + private Boolean createAdmin(String normalizedEmail) { + if (userDao.existsByEmail(normalizedEmail)) { + return Boolean.FALSE; + } + Role adminRole = roleDao.findByRoleName(ADMIN_ROLE) + .orElseThrow(() -> new IllegalStateException("Seeded role " + ADMIN_ROLE + " not found")); + User user = userDao.insert(new User(null, "Admin", "Admin", normalizedEmail, + passwordEncoder.encode(password), true, false)); + userDao.addRole(user.getId(), adminRole.getId()); + audit.log("BOOTSTRAP_ADMIN", "USER", user.getId(), "email=" + normalizedEmail); + return Boolean.TRUE; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuditLoggerImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuditLoggerImpl.java new file mode 100644 index 0000000..15b0681 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuditLoggerImpl.java @@ -0,0 +1,58 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.AuditLogDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.AuditLog; +import com.edgareldy.springjdbctutorial.core.auth.service.ActorProvider; +import com.edgareldy.springjdbctutorial.core.auth.service.AuditLogger; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; + +/** + * Implementation of AuditLogger. Declared by @Bean in ServiceConfig (no stereotype) and returned as the interface so the @Transactional advice applies through a JDK proxy. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AuditLoggerImpl implements AuditLogger { + + private static final String REJECTED_PREFIX = "REJECTED_"; + + private final AuditLogDao auditLogDao; + private final ActorProvider actorProvider; + private final Clock clock; + + public AuditLoggerImpl(AuditLogDao auditLogDao, ActorProvider actorProvider, Clock clock) { + this.auditLogDao = auditLogDao; + this.actorProvider = actorProvider; + this.clock = clock; + } + + // Propagation.REQUIRED (the default) joins the transaction already open in RbacServiceImpl: the audit + // row commits or rolls back together with the business change, so a success row can never describe + // a change that was rolled back. + @Override + @Transactional + public void log(String action, String entityType, Long entityId, String details) { + insert(action, entityType, entityId, details); + } + + // Propagation.REQUIRES_NEW suspends the caller's transaction and runs in a brand new one on another + // connection, committed as soon as this method returns. A refusal makes the business transaction roll + // back (BusinessRuleException), but this row is already committed and survives. It only works because + // RbacServiceImpl calls this bean through its Spring proxy (the AuditLogger interface bean); a call on + // "this" would bypass the proxy and the advice, and the row would be rolled back with the rest. + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void logRejected(String action, String entityType, Long entityId, String details) { + insert(REJECTED_PREFIX + action, entityType, entityId, details); + } + + private void insert(String action, String entityType, Long entityId, String details) { + auditLogDao.insert(new AuditLog(null, actorProvider.currentUserId(), action, entityType, entityId, + details, clock.instant())); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/RbacServiceImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/RbacServiceImpl.java new file mode 100644 index 0000000..52dddb5 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/RbacServiceImpl.java @@ -0,0 +1,389 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.PermissionDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.RoleDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.dto.PermissionDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.RoleDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; +import com.edgareldy.springjdbctutorial.core.auth.entity.Role; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import com.edgareldy.springjdbctutorial.core.auth.mapper.PermissionMapper; +import com.edgareldy.springjdbctutorial.core.auth.mapper.RoleMapper; +import com.edgareldy.springjdbctutorial.core.auth.mapper.UserMapper; +import com.edgareldy.springjdbctutorial.core.auth.service.AuditLogger; +import com.edgareldy.springjdbctutorial.core.auth.service.RbacService; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Implementation of RbacService. Declared by @Bean in ServiceConfig (no stereotype) and returned as the + * interface so the @Transactional advice applies through a JDK proxy. The audit logger is a collaborator + * bean called through its own proxy, which is what makes its REQUIRES_NEW refusal rows really independent. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class RbacServiceImpl implements RbacService { + + private static final int MAX_PAGE_SIZE = 100; + private static final int MAX_NAME_LENGTH = 100; + private static final String LAST_ADMIN_MESSAGE = "Cannot remove the last account holding ROLE:WRITE"; + + private static final String ROLE = "ROLE"; + private static final String PERMISSION = "PERMISSION"; + private static final String USER = "USER"; + + private final UserDao userDao; + private final RoleDao roleDao; + private final PermissionDao permissionDao; + private final AuditLogger audit; + private final UserMapper userMapper = new UserMapper(); + private final RoleMapper roleMapper = new RoleMapper(); + private final PermissionMapper permissionMapper = new PermissionMapper(); + + public RbacServiceImpl(UserDao userDao, RoleDao roleDao, PermissionDao permissionDao, AuditLogger audit) { + this.userDao = userDao; + this.roleDao = roleDao; + this.permissionDao = permissionDao; + this.audit = audit; + } + + // ------------------------------------------------------------------ users + + @Override + @Transactional(readOnly = true) + public PageDto listUsers(int page, int size) { + if (page < 0) { + throw new BusinessRuleException("Page must be greater than or equal to 0"); + } + if (size < 1 || size > MAX_PAGE_SIZE) { + throw new BusinessRuleException("Size must be between 1 and " + MAX_PAGE_SIZE); + } + List content = new ArrayList<>(); + for (User user : userDao.findPage(page, size)) { + content.add(toUserDto(user)); + } + return new PageDto<>(content, page, size, userDao.countAll()); + } + + @Override + @Transactional(readOnly = true) + public UserDto getUser(Long id) { + return toUserDto(requireUser(id)); + } + + @Override + @Transactional + public UserDto assignRoleToUser(Long userId, Long roleId) { + User user = requireUser(userId); + Role role = requireRole(roleId); + String details = "role=" + role.getRoleName() + ", userId=" + userId; + if (userDao.hasRole(userId, roleId)) { + throw reject("ASSIGN_ROLE_TO_USER", USER, userId, details, "Role is already assigned to this user"); + } + try { + userDao.addRole(userId, roleId); + } catch (DuplicateKeyException e) { + // Lost a race against a concurrent identical assignment: the primary key decided + throw reject("ASSIGN_ROLE_TO_USER", USER, userId, details, "Role is already assigned to this user", e); + } + audit.log("ASSIGN_ROLE_TO_USER", USER, userId, details); + return toUserDto(user); + } + + @Override + @Transactional + public UserDto removeRoleFromUser(Long userId, Long roleId) { + // The advisory lock is the FIRST statement, before any lookup, so concurrent last-admin operations queue up + userDao.acquireLastAdminLock(); + User user = requireUser(userId); + Role role = requireRole(roleId); + String details = "role=" + role.getRoleName() + ", userId=" + userId; + long before = userDao.countLastAdminCandidates(); + if (userDao.removeRole(userId, roleId) == 0) { + throw new ResourceNotFoundException("Role " + roleId + " is not assigned to user " + userId); + } + guardLastAdmin(before, "REMOVE_ROLE_FROM_USER", USER, userId, details); + audit.log("REMOVE_ROLE_FROM_USER", USER, userId, details); + return toUserDto(user); + } + + // ------------------------------------------------------------------ roles + + @Override + @Transactional(readOnly = true) + public List listRoles() { + // One extra query per role: acceptable for the handful of roles of a tutorial application + List roles = new ArrayList<>(); + for (Role role : roleDao.findAll()) { + roles.add(toRoleDto(role)); + } + return roles; + } + + @Override + @Transactional + public RoleDto createRole(RoleDto input) { + String name = normalizeRoleName(input); + String details = "roleName=" + name; + if (roleDao.existsByRoleName(name)) { + throw reject("CREATE_ROLE", ROLE, null, details, "Role name already exists"); + } + Role created; + try { + created = roleDao.insert(new Role(null, name)); + } catch (DuplicateKeyException e) { + throw reject("CREATE_ROLE", ROLE, null, details, "Role name already exists", e); + } + audit.log("CREATE_ROLE", ROLE, created.getId(), details); + return toRoleDto(created); + } + + @Override + @Transactional + public RoleDto updateRole(Long id, RoleDto input) { + Role role = requireRole(id); + String name = normalizeRoleName(input); + String details = "roleName=" + role.getRoleName() + " -> " + name; + if (!name.equals(role.getRoleName()) && roleDao.existsByRoleName(name)) { + throw reject("UPDATE_ROLE", ROLE, id, details, "Role name already exists"); + } + try { + roleDao.updateName(id, name); + } catch (DuplicateKeyException e) { + throw reject("UPDATE_ROLE", ROLE, id, details, "Role name already exists", e); + } + role.setRoleName(name); + audit.log("UPDATE_ROLE", ROLE, id, details); + return toRoleDto(role); + } + + @Override + @Transactional + public void deleteRole(Long id) { + Role role = requireRole(id); + String details = "roleName=" + role.getRoleName(); + if (roleDao.countUsersWithRole(id) > 0) { + throw reject("DELETE_ROLE", ROLE, id, details, "Role is still assigned to users"); + } + try { + roleDao.delete(id); + } catch (DataIntegrityViolationException e) { + // A user was assigned the role after the check above: the foreign key refuses the delete + throw reject("DELETE_ROLE", ROLE, id, details, "Role is still assigned to users", e); + } + audit.log("DELETE_ROLE", ROLE, id, details); + } + + @Override + @Transactional + public RoleDto assignPermissionToRole(Long roleId, Long permissionId) { + Role role = requireRole(roleId); + Permission permission = requirePermission(permissionId); + String details = "role=" + role.getRoleName() + ", permission=" + code(permission); + if (roleDao.hasPermission(roleId, permissionId)) { + throw reject("ASSIGN_PERMISSION_TO_ROLE", ROLE, roleId, details, + "Permission is already assigned to this role"); + } + try { + roleDao.addPermission(roleId, permissionId); + } catch (DuplicateKeyException e) { + throw reject("ASSIGN_PERMISSION_TO_ROLE", ROLE, roleId, details, + "Permission is already assigned to this role", e); + } + audit.log("ASSIGN_PERMISSION_TO_ROLE", ROLE, roleId, details); + return toRoleDto(role); + } + + @Override + @Transactional + public RoleDto removePermissionFromRole(Long roleId, Long permissionId) { + userDao.acquireLastAdminLock(); + Role role = requireRole(roleId); + Permission permission = requirePermission(permissionId); + String details = "role=" + role.getRoleName() + ", permission=" + code(permission); + long before = userDao.countLastAdminCandidates(); + if (roleDao.removePermission(roleId, permissionId) == 0) { + throw new ResourceNotFoundException("Permission " + permissionId + " is not assigned to role " + roleId); + } + guardLastAdmin(before, "REMOVE_PERMISSION_FROM_ROLE", ROLE, roleId, details); + audit.log("REMOVE_PERMISSION_FROM_ROLE", ROLE, roleId, details); + return toRoleDto(role); + } + + // ------------------------------------------------------------ permissions + + @Override + @Transactional(readOnly = true) + public List listPermissions() { + List permissions = new ArrayList<>(); + for (Permission permission : permissionDao.findAll()) { + permissions.add(permissionMapper.toDto(permission)); + } + return permissions; + } + + @Override + @Transactional + public PermissionDto createPermission(PermissionDto input) { + Permission wanted = normalizePermission(input); + String details = "permission=" + code(wanted); + if (permissionDao.existsByResourceAndAction(wanted.getResource(), wanted.getAction())) { + throw reject("CREATE_PERMISSION", PERMISSION, null, details, "Permission already exists"); + } + Permission created; + try { + created = permissionDao.insert(wanted); + } catch (DuplicateKeyException e) { + throw reject("CREATE_PERMISSION", PERMISSION, null, details, "Permission already exists", e); + } + audit.log("CREATE_PERMISSION", PERMISSION, created.getId(), details); + return permissionMapper.toDto(created); + } + + @Override + @Transactional + public PermissionDto updatePermission(Long id, PermissionDto input) { + // Renaming ROLE:WRITE itself would strip it from every holder, so the last-admin protocol applies + userDao.acquireLastAdminLock(); + Permission current = requirePermission(id); + Permission wanted = normalizePermission(input); + String details = "permission=" + code(current) + " -> " + code(wanted); + boolean changed = !current.getResource().equals(wanted.getResource()) + || !current.getAction().equals(wanted.getAction()); + if (changed && permissionDao.existsByResourceAndAction(wanted.getResource(), wanted.getAction())) { + throw reject("UPDATE_PERMISSION", PERMISSION, id, details, "Permission already exists"); + } + long before = userDao.countLastAdminCandidates(); + wanted.setId(id); + try { + permissionDao.update(wanted); + } catch (DuplicateKeyException e) { + throw reject("UPDATE_PERMISSION", PERMISSION, id, details, "Permission already exists", e); + } + guardLastAdmin(before, "UPDATE_PERMISSION", PERMISSION, id, details); + audit.log("UPDATE_PERMISSION", PERMISSION, id, details); + return permissionMapper.toDto(wanted); + } + + @Override + @Transactional + public void deletePermission(Long id) { + Permission permission = requirePermission(id); + String details = "permission=" + code(permission); + if (permissionDao.countRolesWithPermission(id) > 0) { + throw reject("DELETE_PERMISSION", PERMISSION, id, details, "Permission is still assigned to roles"); + } + try { + permissionDao.delete(id); + } catch (DataIntegrityViolationException e) { + // A role received the permission after the check above: the foreign key refuses the delete + throw reject("DELETE_PERMISSION", PERMISSION, id, details, "Permission is still assigned to roles", e); + } + audit.log("DELETE_PERMISSION", PERMISSION, id, details); + } + + // ---------------------------------------------------------------- helpers + + /** + * Last-admin rule: compares the number of enabled, unlocked ROLE:WRITE holders before and after the change + * and refuses when it dropped from at least one to zero. JDBC statements of one transaction see their own + * uncommitted writes, so no flush is needed (there is no persistence context here). The count reads the + * database state, not live tokens: permissions embedded in a JWT only change with the user's next token. + * Throwing rolls the change back; the refusal row is written first, in its own transaction. + */ + private void guardLastAdmin(long before, String action, String entityType, Long entityId, String details) { + long after = userDao.countLastAdminCandidates(); + if (before > 0 && after == 0) { + throw reject(action, entityType, entityId, details, LAST_ADMIN_MESSAGE); + } + } + + private BusinessRuleException reject(String action, String entityType, Long entityId, String details, + String message) { + return reject(action, entityType, entityId, details, message, null); + } + + /** Audits the refusal (independent transaction) and builds the exception the caller must throw. */ + private BusinessRuleException reject(String action, String entityType, Long entityId, String details, + String message, Throwable cause) { + audit.logRejected(action, entityType, entityId, details + ", reason=" + message); + return cause == null ? new BusinessRuleException(message) : new BusinessRuleException(message, cause); + } + + private User requireUser(Long id) { + return userDao.findById(id).orElseThrow(() -> new ResourceNotFoundException("User not found: " + id)); + } + + private Role requireRole(Long id) { + return roleDao.findById(id).orElseThrow(() -> new ResourceNotFoundException("Role not found: " + id)); + } + + private Permission requirePermission(Long id) { + return permissionDao.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Permission not found: " + id)); + } + + private UserDto toUserDto(User user) { + UserDto dto = userMapper.toDto(user); + dto.setRoles(userDao.findRoleNamesByUserId(user.getId())); + dto.setPermissions(userDao.findPermissionCodesByUserId(user.getId())); + return dto; + } + + private RoleDto toRoleDto(Role role) { + RoleDto dto = roleMapper.toDto(role); + List permissions = new ArrayList<>(); + for (Permission permission : roleDao.findPermissionsByRoleId(role.getId())) { + permissions.add(permissionMapper.toDto(permission)); + } + dto.setPermissions(permissions); + return dto; + } + + private static String code(Permission permission) { + return permission.getResource() + ":" + permission.getAction(); + } + + private static String normalizeRoleName(RoleDto input) { + if (input == null || input.getRoleName() == null || input.getRoleName().isBlank()) { + throw new BusinessRuleException("Role name is required"); + } + String name = input.getRoleName().trim(); + if (name.length() > MAX_NAME_LENGTH) { + throw new BusinessRuleException("Role name must not exceed " + MAX_NAME_LENGTH + " characters"); + } + return name; + } + + private static Permission normalizePermission(PermissionDto input) { + if (input == null) { + throw new BusinessRuleException("Permission is required"); + } + return new Permission(null, normalizeToken(input.getResource(), "Resource"), + normalizeToken(input.getAction(), "Action")); + } + + private static String normalizeToken(String value, String label) { + if (value == null || value.isBlank()) { + throw new BusinessRuleException(label + " is required"); + } + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (normalized.length() > MAX_NAME_LENGTH || !normalized.matches("[A-Z_]+")) { + throw new BusinessRuleException(label + " must contain only letters A-Z and underscores"); + } + return normalized; + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/AuditLogDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/AuditLogDaoImplTest.java new file mode 100644 index 0000000..6b1dce3 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/AuditLogDaoImplTest.java @@ -0,0 +1,98 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.auth.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.auth.dao.AuditLogDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.AuditLog; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests AuditLogDaoImpl against the real PostgreSQL, with its own fixture file audit-log-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/audit-log-dao-dataset.sql") +class AuditLogDaoImplTest { + + private static final Instant NOW = Instant.parse("2026-09-19T10:15:30Z"); + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private AuditLogDao auditLogDao; + + @Test + void _01_ShouldReturnEntriesOrderedById_WhenFindingAll() { + List all = auditLogDao.findAll(); + + assertThat(all).extracting(AuditLog::getId).containsExactly(1L, 2L); + assertThat(all.get(0).getAction()).isEqualTo("CREATE_ROLE"); + assertThat(all.get(0).getActorUserId()).isEqualTo(7L); + assertThat(all.get(0).getEntityId()).isEqualTo(3L); + assertThat(all.get(0).getCreatedAt()).isEqualTo(Instant.parse("2026-09-19T09:00:00Z")); + } + + @Test + void _02_ShouldReadNullActorAndNullEntityId_WhenTheRowHasNeither() { + AuditLog row = auditLogDao.findAll().get(1); + + assertThat(row.getActorUserId()).isNull(); + assertThat(row.getEntityId()).isNull(); + assertThat(row.getAction()).isEqualTo("BOOTSTRAP_ADMIN"); + } + + @Test + void _03_ShouldGenerateIdAndRoundTripEveryField_WhenEntryIsInserted() { + AuditLog created = auditLogDao.insert( + new AuditLog(null, 5L, "DELETE_ROLE", "ROLE", 9L, "roleName=EDITOR", NOW)); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + AuditLog reloaded = auditLogDao.findAll().stream() + .filter(a -> a.getId().equals(created.getId())).findFirst().orElseThrow(); + assertThat(reloaded.getActorUserId()).isEqualTo(5L); + assertThat(reloaded.getAction()).isEqualTo("DELETE_ROLE"); + assertThat(reloaded.getEntityType()).isEqualTo("ROLE"); + assertThat(reloaded.getEntityId()).isEqualTo(9L); + assertThat(reloaded.getDetails()).isEqualTo("roleName=EDITOR"); + assertThat(reloaded.getCreatedAt()).isEqualTo(NOW); + } + + @Test + void _04_ShouldStoreNulls_WhenActorAndEntityIdAreNull() { + AuditLog created = auditLogDao.insert( + new AuditLog(null, null, "REJECTED_CREATE_ROLE", "ROLE", null, null, NOW)); + + AuditLog reloaded = auditLogDao.findAll().stream() + .filter(a -> a.getId().equals(created.getId())).findFirst().orElseThrow(); + assertThat(reloaded.getActorUserId()).isNull(); + assertThat(reloaded.getEntityId()).isNull(); + assertThat(reloaded.getDetails()).isNull(); + } + + @Test + void _05_ShouldPlaceNewEntryLast_WhenFindingAllAfterAnInsert() { + AuditLog created = auditLogDao.insert(new AuditLog(null, 1L, "CREATE_ROLE", "ROLE", 1L, "d", NOW)); + + List all = auditLogDao.findAll(); + + assertThat(all).hasSize(3); + assertThat(all.get(2).getId()).isEqualTo(created.getId()); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PermissionDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PermissionDaoImplTest.java new file mode 100644 index 0000000..6e35065 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PermissionDaoImplTest.java @@ -0,0 +1,123 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.auth.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.auth.dao.PermissionDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests PermissionDaoImpl against the real PostgreSQL, with its own fixture file permission-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/permission-dao-dataset.sql") +class PermissionDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private PermissionDao permissionDao; + + @Test + void _01_ShouldReturnPermissionsOrderedByResourceThenAction_WhenFindingAll() { + assertThat(permissionDao.findAll()).extracting(p -> p.getResource() + ":" + p.getAction()) + .containsExactly("ROLE:READ", "ROLE:WRITE", "USER:READ", "USER:WRITE"); + } + + @Test + void _02_ShouldReturnPermission_WhenIdExists() { + Permission permission = permissionDao.findById(4L).orElseThrow(); + + assertThat(permission.getResource()).isEqualTo("ROLE"); + assertThat(permission.getAction()).isEqualTo("WRITE"); + } + + @Test + void _03_ShouldReturnEmpty_WhenIdDoesNotExist() { + assertThat(permissionDao.findById(999L)).isEmpty(); + } + + @Test + void _04_ShouldReportExistence_WhenCheckingByResourceAndAction() { + assertThat(permissionDao.existsByResourceAndAction("USER", "READ")).isTrue(); + assertThat(permissionDao.existsByResourceAndAction("USER", "DELETE")).isFalse(); + } + + @Test + void _05_ShouldGenerateIdAndPersistRow_WhenPermissionIsInserted() { + Permission created = permissionDao.insert(new Permission(null, "CATEGORY", "READ")); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + Permission reloaded = permissionDao.findById(created.getId()).orElseThrow(); + assertThat(reloaded.getResource()).isEqualTo("CATEGORY"); + assertThat(reloaded.getAction()).isEqualTo("READ"); + } + + @Test + void _06_ShouldThrowDuplicateKeyException_WhenResourceAndActionAlreadyExist() { + assertThatThrownBy(() -> permissionDao.insert(new Permission(null, "USER", "READ"))) + .isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _07_ShouldAllowSameResourceWithAnotherAction_WhenOnlyTheActionDiffers() { + Permission created = permissionDao.insert(new Permission(null, "USER", "DELETE")); + + assertThat(permissionDao.existsByResourceAndAction("USER", "DELETE")).isTrue(); + assertThat(created.getId()).isNotNull(); + } + + @Test + void _08_ShouldPersistNewValues_WhenPermissionIsUpdated() { + permissionDao.update(new Permission(2L, "ACCOUNT", "MANAGE")); + + Permission reloaded = permissionDao.findById(2L).orElseThrow(); + assertThat(reloaded.getResource()).isEqualTo("ACCOUNT"); + assertThat(reloaded.getAction()).isEqualTo("MANAGE"); + } + + @Test + void _09_ShouldThrowDuplicateKeyException_WhenUpdatingToAnExistingPair() { + assertThatThrownBy(() -> permissionDao.update(new Permission(2L, "USER", "READ"))) + .isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _10_ShouldDeletePermission_WhenNoRoleHoldsIt() { + permissionDao.delete(2L); + + assertThat(permissionDao.findById(2L)).isEmpty(); + } + + @Test + void _11_ShouldThrowDataIntegrityViolationException_WhenAtLeastOneRoleStillHoldsIt() { + assertThatThrownBy(() -> permissionDao.delete(1L)).isInstanceOf(DataIntegrityViolationException.class); + assertThat(permissionDao.findById(1L)).isPresent(); + } + + @Test + void _12_ShouldCountHoldingRoles_WhenCountingRolesWithPermission() { + assertThat(permissionDao.countRolesWithPermission(1L)).isEqualTo(2L); + assertThat(permissionDao.countRolesWithPermission(4L)).isEqualTo(1L); + assertThat(permissionDao.countRolesWithPermission(3L)).isZero(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/RoleDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/RoleDaoImplTest.java new file mode 100644 index 0000000..7491d5d --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/RoleDaoImplTest.java @@ -0,0 +1,173 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.auth.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.auth.dao.RoleDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; +import com.edgareldy.springjdbctutorial.core.auth.entity.Role; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests RoleDaoImpl against the real PostgreSQL, with its own fixture file role-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as the other DAO tests: Testcontainers PostgreSQL migrated by Flyway, a Spring context built +// from the two real configuration classes, the container url injected by @DynamicPropertySource, and this +// class's own fixture file executed by @Sql before EACH test so every test starts from the same rows. +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/role-dao-dataset.sql") +class RoleDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private RoleDao roleDao; + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void _01_ShouldReturnRolesOrderedByName_WhenFindingAll() { + assertThat(roleDao.findAll()).extracting(Role::getRoleName) + .containsExactly("ADMIN", "EDITOR", "GUEST", "VIEWER"); + } + + @Test + void _02_ShouldReturnRole_WhenIdExists() { + assertThat(roleDao.findById(2L)).get().extracting(Role::getRoleName).isEqualTo("EDITOR"); + } + + @Test + void _03_ShouldReturnEmpty_WhenIdDoesNotExist() { + assertThat(roleDao.findById(999L)).isEmpty(); + } + + @Test + void _04_ShouldReturnRole_WhenNameExists() { + assertThat(roleDao.findByRoleName("VIEWER")).get().extracting(Role::getId).isEqualTo(3L); + } + + @Test + void _05_ShouldReturnEmpty_WhenNameDoesNotExist() { + assertThat(roleDao.findByRoleName("NOBODY")).isEmpty(); + } + + @Test + void _06_ShouldReportExistence_WhenCheckingByName() { + assertThat(roleDao.existsByRoleName("ADMIN")).isTrue(); + assertThat(roleDao.existsByRoleName("NOBODY")).isFalse(); + } + + @Test + void _07_ShouldGenerateIdAndPersistRow_WhenRoleIsInserted() { + Role created = roleDao.insert(new Role(null, "AUDITOR")); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + assertThat(roleDao.findById(created.getId())).get().extracting(Role::getRoleName).isEqualTo("AUDITOR"); + } + + @Test + void _08_ShouldThrowDuplicateKeyException_WhenRoleNameAlreadyExists() { + assertThatThrownBy(() -> roleDao.insert(new Role(null, "ADMIN"))).isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _09_ShouldRenameRole_WhenNameIsUpdated() { + roleDao.updateName(4L, "VISITOR"); + + assertThat(roleDao.findById(4L)).get().extracting(Role::getRoleName).isEqualTo("VISITOR"); + } + + @Test + void _10_ShouldThrowDuplicateKeyException_WhenRenamingToAnExistingName() { + assertThatThrownBy(() -> roleDao.updateName(4L, "ADMIN")).isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _11_ShouldDeleteRole_WhenNoUserHoldsIt() { + roleDao.delete(4L); + + assertThat(roleDao.findById(4L)).isEmpty(); + } + + @Test + void _12_ShouldDeleteItsPermissionAssignmentsToo_WhenRoleIsDeleted() { + roleDao.delete(3L); + + Long left = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM role_permission WHERE role_id = 3", Long.class); + assertThat(left).isZero(); + } + + @Test + void _13_ShouldThrowDataIntegrityViolationException_WhenRoleIsStillAssignedToAUser() { + assertThatThrownBy(() -> roleDao.delete(2L)).isInstanceOf(DataIntegrityViolationException.class); + assertThat(roleDao.findById(2L)).isPresent(); + } + + @Test + void _14_ShouldCountAssignedUsers_WhenCountingUsersWithRole() { + assertThat(roleDao.countUsersWithRole(1L)).isEqualTo(1L); + assertThat(roleDao.countUsersWithRole(2L)).isEqualTo(2L); + assertThat(roleDao.countUsersWithRole(3L)).isZero(); + } + + @Test + void _15_ShouldReturnPermissionsOrderedByResourceThenAction_WhenReadingThePermissionsOfARole() { + assertThat(roleDao.findPermissionsByRoleId(1L)).extracting(p -> p.getResource() + ":" + p.getAction()) + .containsExactly("ROLE:READ", "ROLE:WRITE", "USER:READ", "USER:WRITE"); + } + + @Test + void _16_ShouldReturnEmptyList_WhenRoleHasNoPermission() { + assertThat(roleDao.findPermissionsByRoleId(4L)).isEmpty(); + } + + @Test + void _17_ShouldAssignPermission_WhenAddingIt() { + roleDao.addPermission(4L, 2L); + + assertThat(roleDao.hasPermission(4L, 2L)).isTrue(); + assertThat(roleDao.findPermissionsByRoleId(4L)).extracting(Permission::getId).containsExactly(2L); + } + + @Test + void _18_ShouldThrowDuplicateKeyException_WhenPermissionIsAlreadyAssigned() { + assertThatThrownBy(() -> roleDao.addPermission(1L, 1L)).isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _19_ShouldThrowDataIntegrityViolationException_WhenPermissionDoesNotExist() { + assertThatThrownBy(() -> roleDao.addPermission(4L, 999L)).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void _20_ShouldReturnOneThenZero_WhenRemovingTheSamePermissionTwice() { + assertThat(roleDao.removePermission(1L, 4L)).isEqualTo(1); + assertThat(roleDao.removePermission(1L, 4L)).isZero(); + assertThat(roleDao.hasPermission(1L, 4L)).isFalse(); + } + + @Test + void _21_ShouldReportAssignment_WhenCheckingHasPermission() { + assertThat(roleDao.hasPermission(2L, 1L)).isTrue(); + assertThat(roleDao.hasPermission(2L, 2L)).isFalse(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoRbacTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoRbacTest.java new file mode 100644 index 0000000..0ff518a --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoRbacTest.java @@ -0,0 +1,201 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.auth.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Tests the RBAC additions of UserDaoImpl (paging, role assignment, last-admin count and lock) against the + * real PostgreSQL, with its own fixture file user-rbac-dao-dataset.sql. The pre-existing UserDao methods are + * covered by UserDaoImplTest and its own user-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/user-rbac-dao-dataset.sql") +class UserDaoRbacTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private UserDao userDao; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private PlatformTransactionManager transactionManager; + + // ------------------------------------------------------------------ paging + + @Test + void _01_ShouldReturnFirstUsersOrderedById_WhenPageIsZero() { + assertThat(userDao.findPage(0, 4)).extracting(User::getId).containsExactly(1L, 2L, 3L, 4L); + } + + @Test + void _02_ShouldApplyOffsetOfPageTimesSize_WhenPageIsOne() { + assertThat(userDao.findPage(1, 4)).extracting(User::getId).containsExactly(5L, 6L); + assertThat(userDao.findPage(2, 2)).extracting(User::getId).containsExactly(5L, 6L); + } + + @Test + void _03_ShouldReturnEmptyList_WhenPageIsBeyondTheLastOne() { + assertThat(userDao.findPage(50, 4)).isEmpty(); + } + + @Test + void _04_ShouldCountEveryUser_WhenCountingAll() { + assertThat(userDao.countAll()).isEqualTo(6L); + } + + // ------------------------------------------------------------------ role assignment + + @Test + void _05_ShouldReportAssignment_WhenCheckingHasRole() { + assertThat(userDao.hasRole(1L, 1L)).isTrue(); + assertThat(userDao.hasRole(5L, 1L)).isFalse(); + } + + @Test + void _06_ShouldAssignRole_WhenAddingIt() { + userDao.addRole(5L, 3L); + + assertThat(userDao.hasRole(5L, 3L)).isTrue(); + assertThat(userDao.findRoleNamesByUserId(5L)).containsExactly("VIEWER"); + } + + @Test + void _07_ShouldThrowDuplicateKeyException_WhenRoleIsAlreadyAssigned() { + assertThatThrownBy(() -> userDao.addRole(1L, 1L)).isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _08_ShouldThrowDataIntegrityViolationException_WhenRoleDoesNotExist() { + assertThatThrownBy(() -> userDao.addRole(5L, 999L)).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void _09_ShouldReturnOneThenZero_WhenRemovingTheSameRoleTwice() { + assertThat(userDao.removeRole(4L, 3L)).isEqualTo(1); + assertThat(userDao.removeRole(4L, 3L)).isZero(); + assertThat(userDao.hasRole(4L, 3L)).isFalse(); + } + + // ------------------------------------------------------------------ last-admin candidates + + @Test + void _10_ShouldCountOnlyEnabledUnlockedHolders_WhenDisabledAndLockedHoldersExist() { + // alice and frank only: bob is disabled, carol is locked, dave holds no ROLE:WRITE, erin no role + assertThat(userDao.countLastAdminCandidates()).isEqualTo(2L); + } + + @Test + void _11_ShouldCountAUserOnce_WhenSeveralOfItsRolesGrantRoleWrite() { + // alice holds ADMIN and EDITOR, both granting ROLE:WRITE: still one candidate + userDao.updateEnabled(6L, false); + + assertThat(userDao.countLastAdminCandidates()).isEqualTo(1L); + } + + @Test + void _12_ShouldExcludeUser_WhenItIsDisabled() { + userDao.updateEnabled(1L, false); + + assertThat(userDao.countLastAdminCandidates()).isEqualTo(1L); + } + + @Test + void _13_ShouldExcludeUser_WhenItIsLocked() { + jdbcTemplate.update("UPDATE users SET account_locked = TRUE WHERE id = 6"); + + assertThat(userDao.countLastAdminCandidates()).isEqualTo(1L); + } + + @Test + void _14_ShouldIncludeUser_WhenItIsGrantedARoleHoldingRoleWrite() { + userDao.addRole(5L, 5L); + + assertThat(userDao.countLastAdminCandidates()).isEqualTo(3L); + } + + @Test + void _15_ShouldKeepUserCounted_WhenOnlyOneOfItsTwoRoleWriteRolesIsRemoved() { + userDao.removeRole(1L, 1L); + + assertThat(userDao.countLastAdminCandidates()).isEqualTo(2L); + } + + @Test + void _16_ShouldDropToZero_WhenEveryRoleWritePathIsRemoved() { + jdbcTemplate.update("DELETE FROM role_permission WHERE permission_id = 1"); + + assertThat(userDao.countLastAdminCandidates()).isZero(); + } + + // ------------------------------------------------------------------ advisory lock + + @Test + void _17_ShouldAcquireTheLockWithoutError_WhenCalledInsideATransaction() { + TransactionTemplate template = new TransactionTemplate(transactionManager); + + assertThatCode(() -> template.executeWithoutResult(status -> userDao.acquireLastAdminLock())) + .doesNotThrowAnyException(); + } + + // A transaction-scoped advisory lock makes a second transaction wait until the first one ends. + // The waiting transaction runs in another thread (each thread borrows its own pooled connection). + @Test + void _18_ShouldMakeASecondTransactionWait_WhenTheFirstOneHoldsTheLock() throws Exception { + TransactionTemplate template = new TransactionTemplate(transactionManager); + ExecutorService executor = Executors.newSingleThreadExecutor(); + AtomicReference> second = new AtomicReference<>(); + AtomicBoolean doneWhileLocked = new AtomicBoolean(true); + try { + template.executeWithoutResult(status -> { + userDao.acquireLastAdminLock(); + second.set(executor.submit(() -> template.executeWithoutResult(s -> userDao.acquireLastAdminLock()))); + try { + Thread.sleep(700); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + doneWhileLocked.set(second.get().isDone()); + }); + + // The first transaction has committed, which released the lock: the second one can finish now + assertThat(doneWhileLocked).isFalse(); + second.get().get(10, TimeUnit.SECONDS); + assertThat(second.get().isDone()).isTrue(); + } finally { + executor.shutdownNow(); + } + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogMapperTest.java new file mode 100644 index 0000000..84d1bed --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogMapperTest.java @@ -0,0 +1,67 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.auth.dto.AuditLogDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.AuditLog; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Tests AuditLogMapper (entity to dto and back) on hand-built objects, no mocks. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class AuditLogMapperTest { + + private static final Instant CREATED = Instant.parse("2026-09-19T10:15:30Z"); + + private final AuditLogMapper mapper = new AuditLogMapper(); + + @Test + void _01_ShouldCopyEveryField_WhenConvertingEntityToDto() { + AuditLogDto dto = mapper.toDto(new AuditLog(1L, 2L, "CREATE_ROLE", "ROLE", 3L, "details", CREATED)); + + assertThat(dto.getId()).isEqualTo(1L); + assertThat(dto.getActorUserId()).isEqualTo(2L); + assertThat(dto.getAction()).isEqualTo("CREATE_ROLE"); + assertThat(dto.getEntityType()).isEqualTo("ROLE"); + assertThat(dto.getEntityId()).isEqualTo(3L); + assertThat(dto.getDetails()).isEqualTo("details"); + assertThat(dto.getCreatedAt()).isEqualTo(CREATED); + } + + @Test + void _02_ShouldKeepNullActorAndNullEntityId_WhenConvertingEntityToDto() { + AuditLogDto dto = mapper.toDto(new AuditLog(1L, null, "BOOTSTRAP_ADMIN", "USER", null, null, CREATED)); + + assertThat(dto.getActorUserId()).isNull(); + assertThat(dto.getEntityId()).isNull(); + } + + @Test + void _03_ShouldReturnNull_WhenEntityIsNull() { + assertThat(mapper.toDto(null)).isNull(); + } + + @Test + void _04_ShouldCopyEveryField_WhenConvertingDtoToEntity() { + AuditLog log = mapper.toEntity(new AuditLogDto(1L, 2L, "CREATE_ROLE", "ROLE", 3L, "details", CREATED)); + + assertThat(log.getId()).isEqualTo(1L); + assertThat(log.getActorUserId()).isEqualTo(2L); + assertThat(log.getAction()).isEqualTo("CREATE_ROLE"); + assertThat(log.getEntityType()).isEqualTo("ROLE"); + assertThat(log.getEntityId()).isEqualTo(3L); + assertThat(log.getDetails()).isEqualTo("details"); + assertThat(log.getCreatedAt()).isEqualTo(CREATED); + } + + @Test + void _05_ShouldReturnNull_WhenDtoIsNull() { + assertThat(mapper.toEntity(null)).isNull(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogRowMapperTest.java new file mode 100644 index 0000000..da60fe4 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogRowMapperTest.java @@ -0,0 +1,74 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.entity.AuditLog; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Tests AuditLogRowMapper against a mocked ResultSet, including the NULL actor and entity id cases. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class AuditLogRowMapperTest { + + private static final Instant CREATED = Instant.parse("2026-09-19T10:15:30Z"); + + private final AuditLogRowMapper mapper = new AuditLogRowMapper(); + + @Test + void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(11L); + when(rs.getObject("actor_user_id", Long.class)).thenReturn(3L); + when(rs.getString("action")).thenReturn("CREATE_ROLE"); + when(rs.getString("entity_type")).thenReturn("ROLE"); + when(rs.getObject("entity_id", Long.class)).thenReturn(9L); + when(rs.getString("details")).thenReturn("roleName=EDITOR"); + when(rs.getTimestamp("created_at")).thenReturn(Timestamp.from(CREATED)); + + AuditLog log = mapper.mapRow(rs, 0); + + assertThat(log.getId()).isEqualTo(11L); + assertThat(log.getActorUserId()).isEqualTo(3L); + assertThat(log.getAction()).isEqualTo("CREATE_ROLE"); + assertThat(log.getEntityType()).isEqualTo("ROLE"); + assertThat(log.getEntityId()).isEqualTo(9L); + assertThat(log.getDetails()).isEqualTo("roleName=EDITOR"); + assertThat(log.getCreatedAt()).isEqualTo(CREATED); + } + + // rs.getLong would return 0 for SQL NULL and hide the absence: the mapper must read nullable + // BIGINT columns as objects, so an unstubbed getObject (null) has to give a null Long, not 0. + @Test + void _02_ShouldMapNullActorAndNullEntityId_WhenColumnsAreSqlNull() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(12L); + when(rs.getString("action")).thenReturn("BOOTSTRAP_ADMIN"); + when(rs.getString("entity_type")).thenReturn("USER"); + when(rs.getTimestamp("created_at")).thenReturn(Timestamp.from(CREATED)); + + AuditLog log = mapper.mapRow(rs, 0); + + assertThat(log.getActorUserId()).isNull(); + assertThat(log.getEntityId()).isNull(); + assertThat(log.getDetails()).isNull(); + } + + @Test + void _03_ShouldMapNullInstant_WhenCreatedAtIsSqlNull() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(13L); + + assertThat(mapper.mapRow(rs, 0).getCreatedAt()).isNull(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionMapperTest.java new file mode 100644 index 0000000..3e7c220 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionMapperTest.java @@ -0,0 +1,48 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.auth.dto.PermissionDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; +import org.junit.jupiter.api.Test; + +/** + * Tests PermissionMapper (entity to dto and back) on hand-built objects, no mocks. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class PermissionMapperTest { + + private final PermissionMapper mapper = new PermissionMapper(); + + @Test + void _01_ShouldCopyEveryField_WhenConvertingEntityToDto() { + PermissionDto dto = mapper.toDto(new Permission(5L, "ROLE", "WRITE")); + + assertThat(dto.getId()).isEqualTo(5L); + assertThat(dto.getResource()).isEqualTo("ROLE"); + assertThat(dto.getAction()).isEqualTo("WRITE"); + } + + @Test + void _02_ShouldReturnNull_WhenEntityIsNull() { + assertThat(mapper.toDto(null)).isNull(); + } + + @Test + void _03_ShouldCopyEveryField_WhenConvertingDtoToEntity() { + Permission permission = mapper.toEntity(new PermissionDto(5L, "ROLE", "WRITE")); + + assertThat(permission.getId()).isEqualTo(5L); + assertThat(permission.getResource()).isEqualTo("ROLE"); + assertThat(permission.getAction()).isEqualTo("WRITE"); + } + + @Test + void _04_ShouldReturnNull_WhenDtoIsNull() { + assertThat(mapper.toEntity(null)).isNull(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionRowMapperTest.java new file mode 100644 index 0000000..22e369c --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionRowMapperTest.java @@ -0,0 +1,37 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** + * Tests PermissionRowMapper against a mocked ResultSet, without any database. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class PermissionRowMapperTest { + + private final PermissionRowMapper mapper = new PermissionRowMapper(); + + @Test + void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(8L); + when(rs.getString("resource")).thenReturn("ROLE"); + when(rs.getString("action")).thenReturn("WRITE"); + + Permission permission = mapper.mapRow(rs, 0); + + assertThat(permission.getId()).isEqualTo(8L); + assertThat(permission.getResource()).isEqualTo("ROLE"); + assertThat(permission.getAction()).isEqualTo("WRITE"); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleMapperTest.java new file mode 100644 index 0000000..ee4ff47 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleMapperTest.java @@ -0,0 +1,51 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.auth.dto.PermissionDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.RoleDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.Role; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests RoleMapper (entity to dto and back) on hand-built objects, no mocks. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class RoleMapperTest { + + private final RoleMapper mapper = new RoleMapper(); + + @Test + void _01_ShouldCopyIdAndNameWithEmptyPermissions_WhenConvertingEntityToDto() { + RoleDto dto = mapper.toDto(new Role(3L, "EDITOR")); + + assertThat(dto.getId()).isEqualTo(3L); + assertThat(dto.getRoleName()).isEqualTo("EDITOR"); + assertThat(dto.getPermissions()).isNotNull().isEmpty(); + } + + @Test + void _02_ShouldReturnNull_WhenEntityIsNull() { + assertThat(mapper.toDto(null)).isNull(); + } + + @Test + void _03_ShouldCopyIdAndNameAndIgnorePermissions_WhenConvertingDtoToEntity() { + RoleDto dto = new RoleDto(3L, "EDITOR", List.of(new PermissionDto(1L, "USER", "READ"))); + + Role role = mapper.toEntity(dto); + + assertThat(role.getId()).isEqualTo(3L); + assertThat(role.getRoleName()).isEqualTo("EDITOR"); + } + + @Test + void _04_ShouldReturnNull_WhenDtoIsNull() { + assertThat(mapper.toEntity(null)).isNull(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleRowMapperTest.java new file mode 100644 index 0000000..a2ad504 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleRowMapperTest.java @@ -0,0 +1,37 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.entity.Role; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** + * Tests RoleRowMapper against a mocked ResultSet, without any database. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class RoleRowMapperTest { + + private final RoleRowMapper mapper = new RoleRowMapper(); + + // Mockito.mock(ResultSet.class) is a fake row whose getters return what the test stubs, column by + // column: the test only checks the column-to-field mapping, with no database and no driver. + @Test + void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(4L); + when(rs.getString("role_name")).thenReturn("EDITOR"); + + Role role = mapper.mapRow(rs, 0); + + assertThat(role.getId()).isEqualTo(4L); + assertThat(role.getRoleName()).isEqualTo("EDITOR"); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AdminBootstrapTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AdminBootstrapTest.java new file mode 100644 index 0000000..fb1a8dc --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AdminBootstrapTest.java @@ -0,0 +1,201 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.dao.RoleDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.Role; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import com.edgareldy.springjdbctutorial.core.auth.service.AuditLogger; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.CannotCreateTransactionException; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Unit tests of AdminBootstrap: DAOs, audit logger, encoder and transaction manager are mocks. The bootstrap + * must create the first administrator only when configured, and must never throw whatever goes wrong. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@ExtendWith(MockitoExtension.class) +class AdminBootstrapTest { + + private static final String PASSWORD = "Dev-only-Passw0rd!"; + + @Mock + private UserDao userDao; + @Mock + private RoleDao roleDao; + @Mock + private AuditLogger audit; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private PlatformTransactionManager transactionManager; + + private TransactionTemplate transactionTemplate; + + @BeforeEach + void setUp() { + // A real TransactionTemplate over a mocked manager: the callback really runs, no database is needed + transactionTemplate = new TransactionTemplate(transactionManager); + } + + private AdminBootstrap bootstrap(String email, String password) { + return new AdminBootstrap(userDao, roleDao, audit, passwordEncoder, transactionTemplate, email, password); + } + + @Test + void _01_ShouldDoNothing_WhenBothPropertiesAreAbsent() { + bootstrap(null, null).run(); + + verifyNoInteractions(userDao, roleDao, audit, passwordEncoder, transactionManager); + } + + @Test + void _02_ShouldDoNothing_WhenOnlyOneOfTheTwoPropertiesIsSetOrBlank() { + bootstrap("admin@example.com", null).run(); + bootstrap(null, PASSWORD).run(); + bootstrap(" ", PASSWORD).run(); + bootstrap("admin@example.com", "").run(); + + verifyNoInteractions(userDao, roleDao, audit, passwordEncoder, transactionManager); + } + + @Test + void _03_ShouldCreateEnabledUnlockedAdminWithAdminRoleAndAuditRow_WhenConfiguredAndAbsent() { + when(userDao.existsByEmail("admin@example.com")).thenReturn(false); + when(roleDao.findByRoleName("ADMIN")).thenReturn(Optional.of(new Role(1L, "ADMIN"))); + when(passwordEncoder.encode(PASSWORD)).thenReturn("encoded-hash"); + when(userDao.insert(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + user.setId(77L); + return user; + }); + + bootstrap(" Admin@Example.COM ", PASSWORD).run(); + + ArgumentCaptor created = ArgumentCaptor.forClass(User.class); + verify(userDao).insert(created.capture()); + assertThat(created.getValue().getEmail()).isEqualTo("admin@example.com"); + assertThat(created.getValue().getPassword()).isEqualTo("encoded-hash").isNotEqualTo(PASSWORD); + assertThat(created.getValue().isEnabled()).isTrue(); + assertThat(created.getValue().isAccountLocked()).isFalse(); + verify(userDao).addRole(77L, 1L); + verify(audit).log("BOOTSTRAP_ADMIN", "USER", 77L, "email=admin@example.com"); + verify(transactionManager).commit(any()); + } + + @Test + void _04_ShouldSkipCreation_WhenTheUserAlreadyExists() { + when(userDao.existsByEmail("admin@example.com")).thenReturn(true); + + bootstrap("admin@example.com", PASSWORD).run(); + + verify(userDao, never()).insert(any()); + verify(userDao, never()).addRole(anyLong(), anyLong()); + verifyNoInteractions(audit, roleDao); + } + + @Test + void _05_ShouldSkipCreationWithoutThrowing_WhenTheAdminRoleIsMissing() { + when(userDao.existsByEmail("admin@example.com")).thenReturn(false); + when(roleDao.findByRoleName("ADMIN")).thenReturn(Optional.empty()); + + assertThatCode(() -> bootstrap("admin@example.com", PASSWORD).run()).doesNotThrowAnyException(); + + verify(userDao, never()).insert(any()); + verifyNoInteractions(audit); + verify(transactionManager).rollback(any()); + } + + @Test + void _06_ShouldSkipCreation_WhenThePasswordExceeds72Bytes() { + bootstrap("admin@example.com", "a".repeat(73)).run(); + + verifyNoInteractions(userDao, roleDao, audit, transactionManager); + } + + @Test + void _07_ShouldSkipCreation_WhenThePasswordIsUnder72CharactersButOver72Bytes() { + // 40 characters, 80 bytes in UTF-8: bcrypt would silently ignore the tail, so the bootstrap refuses it + bootstrap("admin@example.com", "é".repeat(40)).run(); + + verifyNoInteractions(userDao, roleDao, audit, transactionManager); + } + + @Test + void _08_ShouldCreateTheAdmin_WhenThePasswordIsExactly72Bytes() { + String limit = "a".repeat(72); + when(userDao.existsByEmail("admin@example.com")).thenReturn(false); + when(roleDao.findByRoleName("ADMIN")).thenReturn(Optional.of(new Role(1L, "ADMIN"))); + when(passwordEncoder.encode(limit)).thenReturn("encoded"); + when(userDao.insert(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + bootstrap("admin@example.com", limit).run(); + + verify(userDao).insert(any(User.class)); + } + + @Test + void _09_ShouldNeverThrow_WhenTheDaoThrows() { + when(userDao.existsByEmail("admin@example.com")) + .thenThrow(new DataAccessResourceFailureException("database down")); + + assertThatCode(() -> bootstrap("admin@example.com", PASSWORD).run()).doesNotThrowAnyException(); + + verify(userDao, never()).insert(any()); + } + + @Test + void _10_ShouldNeverThrow_WhenInsertingTheUserFails() { + when(userDao.existsByEmail("admin@example.com")).thenReturn(false); + when(roleDao.findByRoleName("ADMIN")).thenReturn(Optional.of(new Role(1L, "ADMIN"))); + when(passwordEncoder.encode(PASSWORD)).thenReturn("encoded"); + when(userDao.insert(any(User.class))).thenThrow(new IllegalStateException("boom")); + + assertThatCode(() -> bootstrap("admin@example.com", PASSWORD).run()).doesNotThrowAnyException(); + + verify(userDao, never()).addRole(anyLong(), anyLong()); + verifyNoInteractions(audit); + } + + @Test + void _11_ShouldNeverThrow_WhenTheTransactionCannotBeStarted() { + when(transactionManager.getTransaction(any())).thenThrow(new CannotCreateTransactionException("no connection")); + + assertThatCode(() -> bootstrap("admin@example.com", PASSWORD).run()).doesNotThrowAnyException(); + + verifyNoInteractions(userDao, roleDao, audit); + } + + @Test + void _12_ShouldNeverThrow_WhenTheEncoderThrows() { + when(userDao.existsByEmail("admin@example.com")).thenReturn(false); + when(roleDao.findByRoleName("ADMIN")).thenReturn(Optional.of(new Role(1L, "ADMIN"))); + when(passwordEncoder.encode(PASSWORD)).thenThrow(new IllegalArgumentException("bad password")); + + assertThatCode(() -> bootstrap("admin@example.com", PASSWORD).run()).doesNotThrowAnyException(); + + verify(userDao, never()).insert(any()); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuditLoggerImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuditLoggerImplTest.java new file mode 100644 index 0000000..26f5149 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuditLoggerImplTest.java @@ -0,0 +1,125 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.dao.AuditLogDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.AuditLog; +import com.edgareldy.springjdbctutorial.core.auth.service.ActorProvider; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * Unit tests of AuditLoggerImpl: the DAO and the actor provider are mocks and the clock is fixed, so the + * persisted entry (actor, REJECTED_ prefix, timestamp) is asserted exactly. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@ExtendWith(MockitoExtension.class) +class AuditLoggerImplTest { + + private static final Instant NOW = Instant.parse("2026-09-19T12:00:00Z"); + + @Mock + private AuditLogDao auditLogDao; + @Mock + private ActorProvider actorProvider; + + private AuditLoggerImpl logger; + + @BeforeEach + void setUp() { + // Clock.fixed always answers the same instant: the created_at of the entry is then deterministic + logger = new AuditLoggerImpl(auditLogDao, actorProvider, Clock.fixed(NOW, ZoneOffset.UTC)); + } + + private AuditLog inserted() { + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditLog.class); + verify(auditLogDao).insert(captor.capture()); + return captor.getValue(); + } + + @Test + void _01_ShouldInsertEntryWithActorActionAndClockTime_WhenLoggingASuccess() { + when(actorProvider.currentUserId()).thenReturn(42L); + + logger.log("CREATE_ROLE", "ROLE", 9L, "roleName=EDITOR"); + + AuditLog entry = inserted(); + assertThat(entry.getActorUserId()).isEqualTo(42L); + assertThat(entry.getAction()).isEqualTo("CREATE_ROLE"); + assertThat(entry.getEntityType()).isEqualTo("ROLE"); + assertThat(entry.getEntityId()).isEqualTo(9L); + assertThat(entry.getDetails()).isEqualTo("roleName=EDITOR"); + assertThat(entry.getCreatedAt()).isEqualTo(NOW); + } + + @Test + void _02_ShouldInsertNullActor_WhenThereIsNoAuthenticatedActor() { + when(actorProvider.currentUserId()).thenReturn(null); + + logger.log("BOOTSTRAP_ADMIN", "USER", 1L, "email=admin@example.com"); + + assertThat(inserted().getActorUserId()).isNull(); + } + + @Test + void _03_ShouldKeepNullEntityId_WhenTheOperationTargetsNoExistingRow() { + when(actorProvider.currentUserId()).thenReturn(42L); + + logger.logRejected("CREATE_ROLE", "ROLE", null, "roleName=EDITOR, reason=Role name already exists"); + + assertThat(inserted().getEntityId()).isNull(); + } + + @Test + void _04_ShouldPrefixTheActionWithRejected_WhenLoggingARefusal() { + when(actorProvider.currentUserId()).thenReturn(42L); + + logger.logRejected("DELETE_ROLE", "ROLE", 3L, "roleName=EDITOR, reason=Role is still assigned to users"); + + AuditLog entry = inserted(); + assertThat(entry.getAction()).isEqualTo("REJECTED_DELETE_ROLE"); + assertThat(entry.getActorUserId()).isEqualTo(42L); + assertThat(entry.getCreatedAt()).isEqualTo(NOW); + assertThat(entry.getDetails()).contains("reason="); + } + + @Test + void _05_ShouldNeverPrefixTheAction_WhenLoggingASuccess() { + when(actorProvider.currentUserId()).thenReturn(42L); + + logger.log("DELETE_ROLE", "ROLE", 3L, "d"); + + assertThat(inserted().getAction()).isEqualTo("DELETE_ROLE"); + } + + // Reflection on the annotations proves the transaction attributes the Spring proxy will apply. The real + // effect (the refusal row surviving the rollback) is proved end to end by the ws integration tests. + @Test + void _06_ShouldJoinTheCallersTransactionForSuccessAndOpenANewOneForRefusals_WhenReadingTheAnnotations() + throws NoSuchMethodException { + Transactional success = AuditLoggerImpl.class + .getMethod("log", String.class, String.class, Long.class, String.class) + .getAnnotation(Transactional.class); + Transactional refusal = AuditLoggerImpl.class + .getMethod("logRejected", String.class, String.class, Long.class, String.class) + .getAnnotation(Transactional.class); + + assertThat(success.propagation()).isEqualTo(Propagation.REQUIRED); + assertThat(refusal.propagation()).isEqualTo(Propagation.REQUIRES_NEW); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/RbacServiceImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/RbacServiceImplTest.java new file mode 100644 index 0000000..39bb243 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/RbacServiceImplTest.java @@ -0,0 +1,929 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; + +import com.edgareldy.springjdbctutorial.core.auth.dao.PermissionDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.RoleDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.dto.PermissionDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.RoleDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; +import com.edgareldy.springjdbctutorial.core.auth.entity.Role; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import com.edgareldy.springjdbctutorial.core.auth.service.AuditLogger; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; + +/** + * Unit tests of RbacServiceImpl: every DAO and the AuditLogger are Mockito mocks, assertions are made on the dto + * objects returned, on the audit calls (success row, REJECTED_ row, none for a 404) and on the order of the + * last-admin protocol (advisory lock first, count before and after). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// MockitoExtension builds the @Mock fields before each test and fails a test whose stubbing is never used +// (strict stubs). The service is a plain object here: no Spring context, no transaction, so the +// @Transactional advice is proved by the integration tests instead. +@ExtendWith(MockitoExtension.class) +class RbacServiceImplTest { + + private static final String LAST_ADMIN = "Cannot remove the last account holding ROLE:WRITE"; + + @Mock + private UserDao userDao; + @Mock + private RoleDao roleDao; + @Mock + private PermissionDao permissionDao; + @Mock + private AuditLogger audit; + + private RbacServiceImpl service; + + @BeforeEach + void setUp() { + service = new RbacServiceImpl(userDao, roleDao, permissionDao, audit); + } + + private static User alice() { + return new User(5L, "Alice", "Martin", "alice@example.com", "secret-hash", true, false); + } + + private static Role editor() { + return new Role(3L, "EDITOR"); + } + + private static Permission roleWrite() { + return new Permission(7L, "ROLE", "WRITE"); + } + + private void stubRoleInsert(long id) { + when(roleDao.insert(any(Role.class))).thenAnswer(invocation -> { + Role role = invocation.getArgument(0); + role.setId(id); + return role; + }); + } + + private void stubPermissionInsert(long id) { + when(permissionDao.insert(any(Permission.class))).thenAnswer(invocation -> { + Permission permission = invocation.getArgument(0); + permission.setId(id); + return permission; + }); + } + + private static RoleDto roleInput(String name) { + return new RoleDto(null, name, null); + } + + private static PermissionDto permissionInput(String resource, String action) { + return new PermissionDto(null, resource, action); + } + + // ------------------------------------------------------------------ listUsers + + @Test + void _01_ShouldReturnPageWithRolesAndPermissions_WhenPageAndSizeAreValid() { + when(userDao.findPage(1, 2)).thenReturn(List.of(alice())); + when(userDao.countAll()).thenReturn(3L); + when(userDao.findRoleNamesByUserId(5L)).thenReturn(List.of("ADMIN")); + when(userDao.findPermissionCodesByUserId(5L)).thenReturn(List.of("ROLE:WRITE")); + + PageDto page = service.listUsers(1, 2); + + assertThat(page.getPage()).isEqualTo(1); + assertThat(page.getSize()).isEqualTo(2); + assertThat(page.getTotalElements()).isEqualTo(3L); + assertThat(page.getContent()).hasSize(1); + UserDto dto = page.getContent().get(0); + assertThat(dto.getEmail()).isEqualTo("alice@example.com"); + assertThat(dto.getPassword()).isNull(); + assertThat(dto.getRoles()).containsExactly("ADMIN"); + assertThat(dto.getPermissions()).containsExactly("ROLE:WRITE"); + } + + @Test + void _02_ShouldReturnEmptyContentButRealTotal_WhenPageIsBeyondTheLastOne() { + when(userDao.findPage(50, 10)).thenReturn(List.of()); + when(userDao.countAll()).thenReturn(3L); + + PageDto page = service.listUsers(50, 10); + + assertThat(page.getContent()).isEmpty(); + assertThat(page.getTotalElements()).isEqualTo(3L); + } + + @Test + void _03_ShouldRefuseAndNotQuery_WhenPageIsNegative() { + assertThatThrownBy(() -> service.listUsers(-1, 10)).isInstanceOf(BusinessRuleException.class); + + verifyNoInteractions(userDao, audit); + } + + @Test + void _04_ShouldRefuseAndNotQuery_WhenSizeIsZero() { + assertThatThrownBy(() -> service.listUsers(0, 0)).isInstanceOf(BusinessRuleException.class); + + verifyNoInteractions(userDao, audit); + } + + @Test + void _05_ShouldRefuseAndNotQuery_WhenSizeExceedsOneHundred() { + assertThatThrownBy(() -> service.listUsers(0, 101)).isInstanceOf(BusinessRuleException.class); + + verifyNoInteractions(userDao, audit); + } + + @Test + void _06_ShouldAcceptBothBounds_WhenSizeIsOneAndOneHundred() { + when(userDao.findPage(0, 1)).thenReturn(List.of()); + when(userDao.findPage(0, 100)).thenReturn(List.of()); + + assertThat(service.listUsers(0, 1).getSize()).isEqualTo(1); + assertThat(service.listUsers(0, 100).getSize()).isEqualTo(100); + } + + // ------------------------------------------------------------------ getUser + + @Test + void _07_ShouldReturnUserWithRoles_WhenUserExists() { + when(userDao.findById(5L)).thenReturn(Optional.of(alice())); + when(userDao.findRoleNamesByUserId(5L)).thenReturn(List.of("EDITOR")); + + UserDto dto = service.getUser(5L); + + assertThat(dto.getId()).isEqualTo(5L); + assertThat(dto.getRoles()).containsExactly("EDITOR"); + assertThat(dto.getPassword()).isNull(); + } + + @Test + void _08_ShouldThrowNotFoundWithoutAuditing_WhenUserDoesNotExist() { + when(userDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getUser(99L)).isInstanceOf(ResourceNotFoundException.class); + + verifyNoInteractions(audit); + } + + // ------------------------------------------------------------------ assignRoleToUser + + @Test + void _09_ShouldAssignAndAuditSuccess_WhenRoleIsNotYetAssigned() { + when(userDao.findById(5L)).thenReturn(Optional.of(alice())); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(userDao.hasRole(5L, 3L)).thenReturn(false); + when(userDao.findRoleNamesByUserId(5L)).thenReturn(List.of("EDITOR")); + + UserDto dto = service.assignRoleToUser(5L, 3L); + + verify(userDao).addRole(5L, 3L); + verify(audit).log("ASSIGN_ROLE_TO_USER", "USER", 5L, "role=EDITOR, userId=5"); + verify(audit, never()).logRejected(anyString(), anyString(), any(), anyString()); + assertThat(dto.getRoles()).containsExactly("EDITOR"); + } + + @Test + void _10_ShouldAuditRefusalAndNotAssign_WhenRoleIsAlreadyAssigned() { + when(userDao.findById(5L)).thenReturn(Optional.of(alice())); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(userDao.hasRole(5L, 3L)).thenReturn(true); + + assertThatThrownBy(() -> service.assignRoleToUser(5L, 3L)) + .isInstanceOf(BusinessRuleException.class).hasMessage("Role is already assigned to this user"); + + verify(audit).logRejected("ASSIGN_ROLE_TO_USER", "USER", 5L, + "role=EDITOR, userId=5, reason=Role is already assigned to this user"); + verify(userDao, never()).addRole(anyLong(), anyLong()); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _11_ShouldTurnRaceIntoBusinessRuleException_WhenAddRoleThrowsDuplicateKey() { + DuplicateKeyException race = new DuplicateKeyException("pk"); + when(userDao.findById(5L)).thenReturn(Optional.of(alice())); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(userDao.hasRole(5L, 3L)).thenReturn(false); + doThrow(race).when(userDao).addRole(5L, 3L); + + assertThatThrownBy(() -> service.assignRoleToUser(5L, 3L)) + .isInstanceOf(BusinessRuleException.class).hasCause(race); + + verify(audit).logRejected(anyString(), anyString(), any(), anyString()); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _12_ShouldThrowNotFoundWithoutAuditing_WhenUserToAssignToDoesNotExist() { + when(userDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.assignRoleToUser(99L, 3L)).isInstanceOf(ResourceNotFoundException.class); + + verifyNoInteractions(audit); + } + + @Test + void _13_ShouldThrowNotFoundWithoutAuditing_WhenRoleToAssignDoesNotExist() { + when(userDao.findById(5L)).thenReturn(Optional.of(alice())); + when(roleDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.assignRoleToUser(5L, 99L)).isInstanceOf(ResourceNotFoundException.class); + + verifyNoInteractions(audit); + verify(userDao, never()).addRole(anyLong(), anyLong()); + } + + // ------------------------------------------------------------------ removeRoleFromUser + + @Test + void _14_ShouldTakeTheLockFirstThenCountBeforeRemovalAndAfter_WhenRemovingARole() { + when(userDao.findById(5L)).thenReturn(Optional.of(alice())); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(userDao.countLastAdminCandidates()).thenReturn(2L, 2L); + when(userDao.removeRole(5L, 3L)).thenReturn(1); + + service.removeRoleFromUser(5L, 3L); + + // The advisory lock must precede every read, otherwise two concurrent removals could both see "2 admins" + InOrder order = inOrder(userDao, roleDao); + order.verify(userDao).acquireLastAdminLock(); + order.verify(userDao).findById(5L); + order.verify(roleDao).findById(3L); + order.verify(userDao).countLastAdminCandidates(); + order.verify(userDao).removeRole(5L, 3L); + order.verify(userDao).countLastAdminCandidates(); + } + + @Test + void _15_ShouldAuditSuccess_WhenAnotherHolderRemains() { + when(userDao.findById(5L)).thenReturn(Optional.of(alice())); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(userDao.countLastAdminCandidates()).thenReturn(2L, 1L); + when(userDao.removeRole(5L, 3L)).thenReturn(1); + + UserDto dto = service.removeRoleFromUser(5L, 3L); + + verify(audit).log("REMOVE_ROLE_FROM_USER", "USER", 5L, "role=EDITOR, userId=5"); + verify(audit, never()).logRejected(anyString(), anyString(), any(), anyString()); + assertThat(dto.getId()).isEqualTo(5L); + } + + @Test + void _16_ShouldAuditRefusalAndThrow_WhenRemovalLeavesNoRoleWriteHolder() { + when(userDao.findById(5L)).thenReturn(Optional.of(alice())); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(userDao.countLastAdminCandidates()).thenReturn(1L, 0L); + when(userDao.removeRole(5L, 3L)).thenReturn(1); + + assertThatThrownBy(() -> service.removeRoleFromUser(5L, 3L)) + .isInstanceOf(BusinessRuleException.class).hasMessage(LAST_ADMIN); + + verify(audit).logRejected("REMOVE_ROLE_FROM_USER", "USER", 5L, + "role=EDITOR, userId=5, reason=" + LAST_ADMIN); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _17_ShouldNotRefuse_WhenThereWasNoRoleWriteHolderBeforeTheRemoval() { + when(userDao.findById(5L)).thenReturn(Optional.of(alice())); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(userDao.countLastAdminCandidates()).thenReturn(0L, 0L); + when(userDao.removeRole(5L, 3L)).thenReturn(1); + + service.removeRoleFromUser(5L, 3L); + + verify(audit).log("REMOVE_ROLE_FROM_USER", "USER", 5L, "role=EDITOR, userId=5"); + } + + @Test + void _18_ShouldNotRefuse_WhenTheOnlyHolderIsUnaffectedByTheRemoval() { + when(userDao.findById(5L)).thenReturn(Optional.of(alice())); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(userDao.countLastAdminCandidates()).thenReturn(1L, 1L); + when(userDao.removeRole(5L, 3L)).thenReturn(1); + + service.removeRoleFromUser(5L, 3L); + + verify(audit).log("REMOVE_ROLE_FROM_USER", "USER", 5L, "role=EDITOR, userId=5"); + } + + @Test + void _19_ShouldThrowNotFoundWithoutAuditing_WhenRoleIsNotAssignedToTheUser() { + when(userDao.findById(5L)).thenReturn(Optional.of(alice())); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(userDao.countLastAdminCandidates()).thenReturn(1L); + when(userDao.removeRole(5L, 3L)).thenReturn(0); + + assertThatThrownBy(() -> service.removeRoleFromUser(5L, 3L)).isInstanceOf(ResourceNotFoundException.class); + + verifyNoInteractions(audit); + } + + @Test + void _20_ShouldTakeTheLockThenThrowNotFoundWithoutAuditing_WhenUserDoesNotExist() { + when(userDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.removeRoleFromUser(99L, 3L)).isInstanceOf(ResourceNotFoundException.class); + + InOrder order = inOrder(userDao); + order.verify(userDao).acquireLastAdminLock(); + order.verify(userDao).findById(99L); + verifyNoInteractions(audit); + } + + @Test + void _21_ShouldThrowNotFoundWithoutAuditing_WhenRoleToRemoveDoesNotExist() { + when(userDao.findById(5L)).thenReturn(Optional.of(alice())); + when(roleDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.removeRoleFromUser(5L, 99L)).isInstanceOf(ResourceNotFoundException.class); + + verifyNoInteractions(audit); + verify(userDao, never()).removeRole(anyLong(), anyLong()); + } + + // ------------------------------------------------------------------ listRoles + + @Test + void _22_ShouldReturnEveryRoleWithItsPermissions_WhenListingRoles() { + when(roleDao.findAll()).thenReturn(List.of(editor(), new Role(4L, "GUEST"))); + when(roleDao.findPermissionsByRoleId(3L)).thenReturn(List.of(roleWrite())); + when(roleDao.findPermissionsByRoleId(4L)).thenReturn(List.of()); + + List roles = service.listRoles(); + + assertThat(roles).extracting(RoleDto::getRoleName).containsExactly("EDITOR", "GUEST"); + assertThat(roles.get(0).getPermissions()).extracting(PermissionDto::getResource).containsExactly("ROLE"); + assertThat(roles.get(1).getPermissions()).isEmpty(); + } + + // ------------------------------------------------------------------ createRole + + @Test + void _23_ShouldTrimInsertAndAuditSuccess_WhenRoleNameIsNew() { + when(roleDao.existsByRoleName("Reviewer")).thenReturn(false); + stubRoleInsert(9L); + + RoleDto dto = service.createRole(roleInput(" Reviewer ")); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(Role.class); + verify(roleDao).insert(inserted.capture()); + assertThat(inserted.getValue().getRoleName()).isEqualTo("Reviewer"); + verify(audit).log("CREATE_ROLE", "ROLE", 9L, "roleName=Reviewer"); + assertThat(dto.getId()).isEqualTo(9L); + assertThat(dto.getRoleName()).isEqualTo("Reviewer"); + } + + @Test + void _24_ShouldAuditRefusalAndNotInsert_WhenRoleNameAlreadyExists() { + when(roleDao.existsByRoleName("EDITOR")).thenReturn(true); + + assertThatThrownBy(() -> service.createRole(roleInput("EDITOR"))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Role name already exists"); + + verify(audit).logRejected("CREATE_ROLE", "ROLE", null, "roleName=EDITOR, reason=Role name already exists"); + verify(roleDao, never()).insert(any()); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _25_ShouldTurnRaceIntoBusinessRuleException_WhenInsertThrowsDuplicateKey() { + DuplicateKeyException race = new DuplicateKeyException("uq_roles_role_name"); + when(roleDao.existsByRoleName("EDITOR")).thenReturn(false); + when(roleDao.insert(any(Role.class))).thenThrow(race); + + assertThatThrownBy(() -> service.createRole(roleInput("EDITOR"))) + .isInstanceOf(BusinessRuleException.class).hasCause(race); + + verify(audit).logRejected(anyString(), anyString(), any(), anyString()); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _26_ShouldRefuseWithoutAuditOrDaoCall_WhenRoleNameIsNullBlankOrInputIsNull() { + assertThatThrownBy(() -> service.createRole(null)).isInstanceOf(BusinessRuleException.class); + assertThatThrownBy(() -> service.createRole(roleInput(null))).isInstanceOf(BusinessRuleException.class); + assertThatThrownBy(() -> service.createRole(roleInput(" "))).isInstanceOf(BusinessRuleException.class); + + verifyNoInteractions(roleDao, audit); + } + + @Test + void _27_ShouldRefuseAtLengthOneHundredAndOne_WhenRoleNameIsTooLong() { + assertThatThrownBy(() -> service.createRole(roleInput("x".repeat(101)))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("100"); + + verifyNoInteractions(roleDao, audit); + } + + @Test + void _28_ShouldAcceptLengthOneHundred_WhenRoleNameIsAtTheLimit() { + String name = "x".repeat(100); + when(roleDao.existsByRoleName(name)).thenReturn(false); + stubRoleInsert(10L); + + assertThat(service.createRole(roleInput(name)).getRoleName()).isEqualTo(name); + } + + // ------------------------------------------------------------------ updateRole + + @Test + void _29_ShouldRenameAndAuditSuccess_WhenNewNameIsFree() { + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(roleDao.existsByRoleName("Editors")).thenReturn(false); + + RoleDto dto = service.updateRole(3L, roleInput(" Editors ")); + + verify(roleDao).updateName(3L, "Editors"); + verify(audit).log("UPDATE_ROLE", "ROLE", 3L, "roleName=EDITOR -> Editors"); + assertThat(dto.getRoleName()).isEqualTo("Editors"); + } + + @Test + void _30_ShouldNotCheckUniquenessAgainstItself_WhenNameIsUnchanged() { + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + + service.updateRole(3L, roleInput("EDITOR")); + + verify(roleDao, never()).existsByRoleName(anyString()); + verify(roleDao).updateName(3L, "EDITOR"); + verify(audit).log("UPDATE_ROLE", "ROLE", 3L, "roleName=EDITOR -> EDITOR"); + } + + @Test + void _31_ShouldAuditRefusalAndNotRename_WhenNewNameBelongsToAnotherRole() { + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(roleDao.existsByRoleName("ADMIN")).thenReturn(true); + + assertThatThrownBy(() -> service.updateRole(3L, roleInput("ADMIN"))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Role name already exists"); + + verify(audit).logRejected("UPDATE_ROLE", "ROLE", 3L, + "roleName=EDITOR -> ADMIN, reason=Role name already exists"); + verify(roleDao, never()).updateName(anyLong(), anyString()); + } + + @Test + void _32_ShouldTurnRaceIntoBusinessRuleException_WhenUpdateThrowsDuplicateKey() { + DuplicateKeyException race = new DuplicateKeyException("uq_roles_role_name"); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(roleDao.existsByRoleName("ADMIN")).thenReturn(false); + doThrow(race).when(roleDao).updateName(3L, "ADMIN"); + + assertThatThrownBy(() -> service.updateRole(3L, roleInput("ADMIN"))) + .isInstanceOf(BusinessRuleException.class).hasCause(race); + + verify(audit).logRejected(anyString(), anyString(), any(), anyString()); + } + + @Test + void _33_ShouldThrowNotFoundWithoutAuditing_WhenRoleToUpdateDoesNotExist() { + when(roleDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateRole(99L, roleInput("X"))).isInstanceOf(ResourceNotFoundException.class); + + verifyNoInteractions(audit); + verify(roleDao, never()).updateName(anyLong(), anyString()); + } + + @Test + void _34_ShouldRefuseWithoutRenaming_WhenUpdatedNameIsBlank() { + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + + assertThatThrownBy(() -> service.updateRole(3L, roleInput(" "))).isInstanceOf(BusinessRuleException.class); + + verify(roleDao, never()).updateName(anyLong(), anyString()); + verifyNoInteractions(audit); + } + + // ------------------------------------------------------------------ deleteRole + + @Test + void _35_ShouldDeleteAndAuditSuccess_WhenNoUserHoldsTheRole() { + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(roleDao.countUsersWithRole(3L)).thenReturn(0L); + + service.deleteRole(3L); + + verify(roleDao).delete(3L); + verify(audit).log("DELETE_ROLE", "ROLE", 3L, "roleName=EDITOR"); + } + + @Test + void _36_ShouldAuditRefusalAndNotDelete_WhenUsersStillHoldTheRole() { + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(roleDao.countUsersWithRole(3L)).thenReturn(2L); + + assertThatThrownBy(() -> service.deleteRole(3L)) + .isInstanceOf(BusinessRuleException.class).hasMessage("Role is still assigned to users"); + + verify(audit).logRejected("DELETE_ROLE", "ROLE", 3L, + "roleName=EDITOR, reason=Role is still assigned to users"); + verify(roleDao, never()).delete(anyLong()); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _37_ShouldTurnRaceIntoBusinessRuleException_WhenTheForeignKeyRefusesTheDelete() { + DataIntegrityViolationException race = new DataIntegrityViolationException("fk_role_user_role"); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(roleDao.countUsersWithRole(3L)).thenReturn(0L); + doThrow(race).when(roleDao).delete(3L); + + assertThatThrownBy(() -> service.deleteRole(3L)).isInstanceOf(BusinessRuleException.class).hasCause(race); + + verify(audit).logRejected("DELETE_ROLE", "ROLE", 3L, "roleName=EDITOR, reason=Role is still assigned to users"); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _38_ShouldThrowNotFoundWithoutAuditing_WhenRoleToDeleteDoesNotExist() { + when(roleDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.deleteRole(99L)).isInstanceOf(ResourceNotFoundException.class); + + verifyNoInteractions(audit); + verify(roleDao, never()).delete(anyLong()); + } + + // ------------------------------------------------------------------ assignPermissionToRole + + @Test + void _39_ShouldAssignAndAuditSuccess_WhenPermissionIsNotYetAssigned() { + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(roleDao.hasPermission(3L, 7L)).thenReturn(false); + when(roleDao.findPermissionsByRoleId(3L)).thenReturn(List.of(roleWrite())); + + RoleDto dto = service.assignPermissionToRole(3L, 7L); + + verify(roleDao).addPermission(3L, 7L); + verify(audit).log("ASSIGN_PERMISSION_TO_ROLE", "ROLE", 3L, "role=EDITOR, permission=ROLE:WRITE"); + assertThat(dto.getPermissions()).extracting(PermissionDto::getAction).containsExactly("WRITE"); + } + + @Test + void _40_ShouldAuditRefusalAndNotAssign_WhenPermissionIsAlreadyAssigned() { + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(roleDao.hasPermission(3L, 7L)).thenReturn(true); + + assertThatThrownBy(() -> service.assignPermissionToRole(3L, 7L)) + .isInstanceOf(BusinessRuleException.class).hasMessage("Permission is already assigned to this role"); + + verify(audit).logRejected("ASSIGN_PERMISSION_TO_ROLE", "ROLE", 3L, + "role=EDITOR, permission=ROLE:WRITE, reason=Permission is already assigned to this role"); + verify(roleDao, never()).addPermission(anyLong(), anyLong()); + } + + @Test + void _41_ShouldTurnRaceIntoBusinessRuleException_WhenAddPermissionThrowsDuplicateKey() { + DuplicateKeyException race = new DuplicateKeyException("pk"); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(roleDao.hasPermission(3L, 7L)).thenReturn(false); + doThrow(race).when(roleDao).addPermission(3L, 7L); + + assertThatThrownBy(() -> service.assignPermissionToRole(3L, 7L)) + .isInstanceOf(BusinessRuleException.class).hasCause(race); + + verify(audit).logRejected(anyString(), anyString(), any(), anyString()); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _42_ShouldThrowNotFoundWithoutAuditing_WhenRoleOrPermissionToAssignDoesNotExist() { + when(roleDao.findById(99L)).thenReturn(Optional.empty()); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(permissionDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.assignPermissionToRole(99L, 7L)).isInstanceOf(ResourceNotFoundException.class); + assertThatThrownBy(() -> service.assignPermissionToRole(3L, 99L)).isInstanceOf(ResourceNotFoundException.class); + + verifyNoInteractions(audit); + verify(roleDao, never()).addPermission(anyLong(), anyLong()); + } + + // ------------------------------------------------------------------ removePermissionFromRole + + @Test + void _43_ShouldTakeTheLockFirstThenCountBeforeRemovalAndAfter_WhenRemovingAPermission() { + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(userDao.countLastAdminCandidates()).thenReturn(2L, 1L); + when(roleDao.removePermission(3L, 7L)).thenReturn(1); + + service.removePermissionFromRole(3L, 7L); + + InOrder order = inOrder(userDao, roleDao, permissionDao); + order.verify(userDao).acquireLastAdminLock(); + order.verify(roleDao).findById(3L); + order.verify(permissionDao).findById(7L); + order.verify(userDao).countLastAdminCandidates(); + order.verify(roleDao).removePermission(3L, 7L); + order.verify(userDao).countLastAdminCandidates(); + verify(audit).log("REMOVE_PERMISSION_FROM_ROLE", "ROLE", 3L, "role=EDITOR, permission=ROLE:WRITE"); + } + + @Test + void _44_ShouldAuditRefusalAndThrow_WhenRemovingRoleWriteFromTheLastAdminRole() { + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(userDao.countLastAdminCandidates()).thenReturn(1L, 0L); + when(roleDao.removePermission(3L, 7L)).thenReturn(1); + + assertThatThrownBy(() -> service.removePermissionFromRole(3L, 7L)) + .isInstanceOf(BusinessRuleException.class).hasMessage(LAST_ADMIN); + + verify(audit).logRejected("REMOVE_PERMISSION_FROM_ROLE", "ROLE", 3L, + "role=EDITOR, permission=ROLE:WRITE, reason=" + LAST_ADMIN); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _45_ShouldNotRefuse_WhenThereWasNoRoleWriteHolderBeforeTheRemoval() { + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(userDao.countLastAdminCandidates()).thenReturn(0L, 0L); + when(roleDao.removePermission(3L, 7L)).thenReturn(1); + + service.removePermissionFromRole(3L, 7L); + + verify(audit).log("REMOVE_PERMISSION_FROM_ROLE", "ROLE", 3L, "role=EDITOR, permission=ROLE:WRITE"); + } + + @Test + void _46_ShouldThrowNotFoundWithoutAuditing_WhenPermissionIsNotAssignedToTheRole() { + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(userDao.countLastAdminCandidates()).thenReturn(1L); + when(roleDao.removePermission(3L, 7L)).thenReturn(0); + + assertThatThrownBy(() -> service.removePermissionFromRole(3L, 7L)) + .isInstanceOf(ResourceNotFoundException.class); + + verifyNoInteractions(audit); + } + + @Test + void _47_ShouldTakeTheLockThenThrowNotFoundWithoutAuditing_WhenRoleOrPermissionDoesNotExist() { + when(roleDao.findById(99L)).thenReturn(Optional.empty()); + when(roleDao.findById(3L)).thenReturn(Optional.of(editor())); + when(permissionDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.removePermissionFromRole(99L, 7L)) + .isInstanceOf(ResourceNotFoundException.class); + assertThatThrownBy(() -> service.removePermissionFromRole(3L, 99L)) + .isInstanceOf(ResourceNotFoundException.class); + + verify(userDao, times(2)).acquireLastAdminLock(); + verifyNoInteractions(audit); + verify(roleDao, never()).removePermission(anyLong(), anyLong()); + } + + // ------------------------------------------------------------------ permissions + + @Test + void _48_ShouldReturnEveryPermission_WhenListingPermissions() { + when(permissionDao.findAll()).thenReturn(List.of(roleWrite(), new Permission(8L, "USER", "READ"))); + + List permissions = service.listPermissions(); + + assertThat(permissions).extracting(p -> p.getResource() + ":" + p.getAction()) + .containsExactly("ROLE:WRITE", "USER:READ"); + } + + @Test + void _49_ShouldNormalizeToUpperCaseInsertAndAudit_WhenPermissionIsNew() { + when(permissionDao.existsByResourceAndAction("ROLE", "WRITE")).thenReturn(false); + stubPermissionInsert(12L); + + PermissionDto dto = service.createPermission(permissionInput(" role ", "write")); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(Permission.class); + verify(permissionDao).insert(inserted.capture()); + assertThat(inserted.getValue().getResource()).isEqualTo("ROLE"); + assertThat(inserted.getValue().getAction()).isEqualTo("WRITE"); + verify(audit).log("CREATE_PERMISSION", "PERMISSION", 12L, "permission=ROLE:WRITE"); + assertThat(dto.getId()).isEqualTo(12L); + assertThat(dto.getResource()).isEqualTo("ROLE"); + } + + @Test + void _50_ShouldAuditRefusalAndNotInsert_WhenPermissionAlreadyExists() { + when(permissionDao.existsByResourceAndAction("ROLE", "WRITE")).thenReturn(true); + + assertThatThrownBy(() -> service.createPermission(permissionInput("role", "write"))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Permission already exists"); + + verify(audit).logRejected("CREATE_PERMISSION", "PERMISSION", null, + "permission=ROLE:WRITE, reason=Permission already exists"); + verify(permissionDao, never()).insert(any()); + } + + @Test + void _51_ShouldTurnRaceIntoBusinessRuleException_WhenPermissionInsertThrowsDuplicateKey() { + DuplicateKeyException race = new DuplicateKeyException("uq_permissions_resource_action"); + when(permissionDao.existsByResourceAndAction("ROLE", "WRITE")).thenReturn(false); + when(permissionDao.insert(any(Permission.class))).thenThrow(race); + + assertThatThrownBy(() -> service.createPermission(permissionInput("ROLE", "WRITE"))) + .isInstanceOf(BusinessRuleException.class).hasCause(race); + + verify(audit).logRejected(anyString(), anyString(), any(), anyString()); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _52_ShouldRefuseWithoutAuditOrDaoCall_WhenPermissionInputIsNullBlankOrHasForbiddenCharacters() { + assertThatThrownBy(() -> service.createPermission(null)).isInstanceOf(BusinessRuleException.class); + assertThatThrownBy(() -> service.createPermission(permissionInput(null, "READ"))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("Resource"); + assertThatThrownBy(() -> service.createPermission(permissionInput("USER", " "))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("Action"); + assertThatThrownBy(() -> service.createPermission(permissionInput("USER1", "READ"))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("Resource"); + assertThatThrownBy(() -> service.createPermission(permissionInput("USER", "RE-AD"))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("Action"); + assertThatThrownBy(() -> service.createPermission(permissionInput("A".repeat(101), "READ"))) + .isInstanceOf(BusinessRuleException.class); + + verifyNoInteractions(permissionDao, audit); + } + + @Test + void _53_ShouldAcceptUnderscores_WhenResourceIsMadeOfSeveralWords() { + when(permissionDao.existsByResourceAndAction("AUDIT_LOG", "READ")).thenReturn(false); + stubPermissionInsert(13L); + + assertThat(service.createPermission(permissionInput("audit_log", "read")).getResource()).isEqualTo("AUDIT_LOG"); + } + + @Test + void _54_ShouldTakeTheLockFirstThenCountAroundTheUpdate_WhenUpdatingAPermission() { + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(permissionDao.existsByResourceAndAction("ROLE", "MANAGE")).thenReturn(false); + when(userDao.countLastAdminCandidates()).thenReturn(2L, 2L); + + PermissionDto dto = service.updatePermission(7L, permissionInput("role", "manage")); + + InOrder order = inOrder(userDao, permissionDao); + order.verify(userDao).acquireLastAdminLock(); + order.verify(permissionDao).findById(7L); + order.verify(permissionDao).existsByResourceAndAction("ROLE", "MANAGE"); + order.verify(userDao).countLastAdminCandidates(); + order.verify(permissionDao).update(any(Permission.class)); + order.verify(userDao).countLastAdminCandidates(); + ArgumentCaptor updated = ArgumentCaptor.forClass(Permission.class); + verify(permissionDao).update(updated.capture()); + assertThat(updated.getValue().getId()).isEqualTo(7L); + assertThat(updated.getValue().getAction()).isEqualTo("MANAGE"); + verify(audit).log("UPDATE_PERMISSION", "PERMISSION", 7L, "permission=ROLE:WRITE -> ROLE:MANAGE"); + assertThat(dto.getId()).isEqualTo(7L); + assertThat(dto.getAction()).isEqualTo("MANAGE"); + } + + @Test + void _55_ShouldAuditRefusalAndThrow_WhenUpdateStripsRoleWriteFromTheLastAdmin() { + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(permissionDao.existsByResourceAndAction("ROLE", "MANAGE")).thenReturn(false); + when(userDao.countLastAdminCandidates()).thenReturn(1L, 0L); + + assertThatThrownBy(() -> service.updatePermission(7L, permissionInput("ROLE", "MANAGE"))) + .isInstanceOf(BusinessRuleException.class).hasMessage(LAST_ADMIN); + + verify(audit).logRejected("UPDATE_PERMISSION", "PERMISSION", 7L, + "permission=ROLE:WRITE -> ROLE:MANAGE, reason=" + LAST_ADMIN); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _56_ShouldAuditRefusalAndNotUpdate_WhenNewPairBelongsToAnotherPermission() { + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(permissionDao.existsByResourceAndAction("USER", "READ")).thenReturn(true); + + assertThatThrownBy(() -> service.updatePermission(7L, permissionInput("USER", "READ"))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Permission already exists"); + + verify(audit).logRejected("UPDATE_PERMISSION", "PERMISSION", 7L, + "permission=ROLE:WRITE -> USER:READ, reason=Permission already exists"); + verify(permissionDao, never()).update(any()); + } + + @Test + void _57_ShouldNotCheckUniquenessAgainstItself_WhenResourceAndActionAreUnchanged() { + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(userDao.countLastAdminCandidates()).thenReturn(1L, 1L); + + service.updatePermission(7L, permissionInput("role", "write")); + + verify(permissionDao, never()).existsByResourceAndAction(anyString(), anyString()); + verify(permissionDao).update(any(Permission.class)); + verify(audit).log("UPDATE_PERMISSION", "PERMISSION", 7L, "permission=ROLE:WRITE -> ROLE:WRITE"); + } + + @Test + void _58_ShouldTurnRaceIntoBusinessRuleException_WhenPermissionUpdateThrowsDuplicateKey() { + DuplicateKeyException race = new DuplicateKeyException("uq_permissions_resource_action"); + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(permissionDao.existsByResourceAndAction("ROLE", "MANAGE")).thenReturn(false); + when(userDao.countLastAdminCandidates()).thenReturn(1L); + doThrow(race).when(permissionDao).update(any(Permission.class)); + + assertThatThrownBy(() -> service.updatePermission(7L, permissionInput("ROLE", "MANAGE"))) + .isInstanceOf(BusinessRuleException.class).hasCause(race); + + verify(audit).logRejected(anyString(), anyString(), any(), anyString()); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _59_ShouldTakeTheLockThenThrowNotFoundWithoutAuditing_WhenPermissionToUpdateDoesNotExist() { + when(permissionDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updatePermission(99L, permissionInput("ROLE", "MANAGE"))) + .isInstanceOf(ResourceNotFoundException.class); + + InOrder order = inOrder(userDao, permissionDao); + order.verify(userDao).acquireLastAdminLock(); + order.verify(permissionDao).findById(99L); + verifyNoInteractions(audit); + } + + @Test + void _60_ShouldDeleteAndAuditSuccess_WhenNoRoleHoldsThePermission() { + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(permissionDao.countRolesWithPermission(7L)).thenReturn(0L); + + service.deletePermission(7L); + + verify(permissionDao).delete(7L); + verify(audit).log("DELETE_PERMISSION", "PERMISSION", 7L, "permission=ROLE:WRITE"); + } + + @Test + void _61_ShouldAuditRefusalAndNotDelete_WhenRolesStillHoldThePermission() { + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(permissionDao.countRolesWithPermission(7L)).thenReturn(1L); + + assertThatThrownBy(() -> service.deletePermission(7L)) + .isInstanceOf(BusinessRuleException.class).hasMessage("Permission is still assigned to roles"); + + verify(audit).logRejected("DELETE_PERMISSION", "PERMISSION", 7L, + "permission=ROLE:WRITE, reason=Permission is still assigned to roles"); + verify(permissionDao, never()).delete(anyLong()); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _62_ShouldTurnRaceIntoBusinessRuleException_WhenTheForeignKeyRefusesThePermissionDelete() { + DataIntegrityViolationException race = new DataIntegrityViolationException("fk_role_permission_permission"); + when(permissionDao.findById(7L)).thenReturn(Optional.of(roleWrite())); + when(permissionDao.countRolesWithPermission(7L)).thenReturn(0L); + doThrow(race).when(permissionDao).delete(7L); + + assertThatThrownBy(() -> service.deletePermission(7L)).isInstanceOf(BusinessRuleException.class).hasCause(race); + + verify(audit).logRejected("DELETE_PERMISSION", "PERMISSION", 7L, + "permission=ROLE:WRITE, reason=Permission is still assigned to roles"); + verify(audit, never()).log(anyString(), anyString(), any(), anyString()); + } + + @Test + void _63_ShouldThrowNotFoundWithoutAuditing_WhenPermissionToDeleteDoesNotExist() { + when(permissionDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.deletePermission(99L)).isInstanceOf(ResourceNotFoundException.class); + + verifyNoInteractions(audit); + verify(permissionDao, never()).delete(anyLong()); + } +} diff --git a/core/auth/src/test/resources/audit-log-dao-dataset.sql b/core/auth/src/test/resources/audit-log-dao-dataset.sql new file mode 100644 index 0000000..0e21d10 --- /dev/null +++ b/core/auth/src/test/resources/audit-log-dao-dataset.sql @@ -0,0 +1,12 @@ +-- Fixture of AuditLogDaoImplTest only. Wipes the identity tables and the audit trail, then loads two audit +-- rows inserted out of chronological order on purpose (id 2 is older than id 1): row 1 has an actor and an +-- entity, row 2 has neither (NULL actor_user_id and NULL entity_id, as written by a bootstrap or a job). +-- audit_logs has no foreign key, so no user needs to exist. +TRUNCATE TABLE audit_logs, users, roles, permissions RESTART IDENTITY CASCADE; + +INSERT INTO audit_logs (id, actor_user_id, action, entity_type, entity_id, details, created_at) VALUES + (2, NULL, 'BOOTSTRAP_ADMIN', 'USER', NULL, 'first row by id', '2026-09-19T08:00:00Z'), + (1, 7, 'CREATE_ROLE', 'ROLE', 3, 'roleName=EDITOR', '2026-09-19T09:00:00Z'); + +-- Explicit ids do not advance the sequence: move it high so generated ids never collide +ALTER SEQUENCE audit_logs_id_seq RESTART WITH 100; diff --git a/core/auth/src/test/resources/permission-dao-dataset.sql b/core/auth/src/test/resources/permission-dao-dataset.sql new file mode 100644 index 0000000..14b4703 --- /dev/null +++ b/core/auth/src/test/resources/permission-dao-dataset.sql @@ -0,0 +1,15 @@ +-- Fixture of PermissionDaoImplTest only. Wipes the identity tables, then loads four permissions and three +-- roles. USER:READ is held by ADMIN and EDITOR (2 roles), ROLE:WRITE by ADMIN only (1 role), USER:WRITE and +-- ROLE:READ by no role (0), so countRolesWithPermission and the "still held" refusal have known answers. +TRUNCATE TABLE audit_logs, users, roles, permissions RESTART IDENTITY CASCADE; + +INSERT INTO roles (id, role_name) VALUES (1, 'ADMIN'), (2, 'EDITOR'), (3, 'VIEWER'); + +INSERT INTO permissions (id, resource, action) VALUES + (1, 'USER', 'READ'), (2, 'USER', 'WRITE'), (3, 'ROLE', 'READ'), (4, 'ROLE', 'WRITE'); + +INSERT INTO role_permission (role_id, permission_id) VALUES (1, 1), (1, 4), (2, 1); + +-- Explicit ids do not advance the sequences: move them high so generated ids never collide +ALTER SEQUENCE roles_id_seq RESTART WITH 100; +ALTER SEQUENCE permissions_id_seq RESTART WITH 100; diff --git a/core/auth/src/test/resources/role-dao-dataset.sql b/core/auth/src/test/resources/role-dao-dataset.sql new file mode 100644 index 0000000..6cdec8c --- /dev/null +++ b/core/auth/src/test/resources/role-dao-dataset.sql @@ -0,0 +1,22 @@ +-- Fixture of RoleDaoImplTest only. Wipes the identity tables, then loads two users, four roles and four +-- permissions. ADMIN holds all four permissions, EDITOR holds USER:READ, VIEWER holds ROLE:READ and has no +-- user, GUEST has neither permission nor user. alice (1) holds ADMIN and EDITOR, bob (2) holds EDITOR. +TRUNCATE TABLE audit_logs, users, roles, permissions RESTART IDENTITY CASCADE; + +INSERT INTO users (id, first_name, last_name, email, password, enabled, account_locked) VALUES + (1, 'Alice', 'Martin', 'alice@example.com', 'hash-alice', TRUE, FALSE), + (2, 'Bob', 'Durand', 'bob@example.com', 'hash-bob', TRUE, FALSE); + +INSERT INTO roles (id, role_name) VALUES (1, 'ADMIN'), (2, 'EDITOR'), (3, 'VIEWER'), (4, 'GUEST'); + +INSERT INTO permissions (id, resource, action) VALUES + (1, 'USER', 'READ'), (2, 'USER', 'WRITE'), (3, 'ROLE', 'READ'), (4, 'ROLE', 'WRITE'); + +INSERT INTO role_permission (role_id, permission_id) VALUES (1, 1), (1, 2), (1, 3), (1, 4), (2, 1), (3, 3); + +INSERT INTO role_user (role_id, user_id) VALUES (1, 1), (2, 1), (2, 2); + +-- Explicit ids do not advance the sequences: move them high so generated ids never collide +ALTER SEQUENCE users_id_seq RESTART WITH 100; +ALTER SEQUENCE roles_id_seq RESTART WITH 100; +ALTER SEQUENCE permissions_id_seq RESTART WITH 100; diff --git a/core/auth/src/test/resources/user-rbac-dao-dataset.sql b/core/auth/src/test/resources/user-rbac-dao-dataset.sql new file mode 100644 index 0000000..090b0a0 --- /dev/null +++ b/core/auth/src/test/resources/user-rbac-dao-dataset.sql @@ -0,0 +1,32 @@ +-- Fixture of UserDaoRbacTest only (the RBAC additions of UserDao). Wipes the identity tables, then loads six +-- users and five roles. ROLE:WRITE is granted by ADMIN, EDITOR and SUPER (roles 1, 2, 5); VIEWER (3) only +-- grants ROLE:READ; NONE (4) grants nothing. +-- alice (1) enabled, unlocked, holds ADMIN and EDITOR (two roles granting ROLE:WRITE: counted once) +-- bob (2) DISABLED, holds ADMIN -> excluded from the last-admin count +-- carol (3) enabled but LOCKED, holds ADMIN -> excluded +-- dave (4) enabled, unlocked, holds VIEWER (no ROLE:WRITE) -> not a candidate +-- erin (5) enabled, unlocked, no role +-- frank (6) enabled, unlocked, holds SUPER -> candidate +-- So the last-admin candidates are alice and frank (2). +TRUNCATE TABLE audit_logs, users, roles, permissions RESTART IDENTITY CASCADE; + +INSERT INTO users (id, first_name, last_name, email, password, enabled, account_locked) VALUES + (1, 'Alice', 'Martin', 'alice@example.com', 'hash-alice', TRUE, FALSE), + (2, 'Bob', 'Durand', 'bob@example.com', 'hash-bob', FALSE, FALSE), + (3, 'Carol', 'Petit', 'carol@example.com', 'hash-carol', TRUE, TRUE), + (4, 'Dave', 'Roux', 'dave@example.com', 'hash-dave', TRUE, FALSE), + (5, 'Erin', 'Blanc', 'erin@example.com', 'hash-erin', TRUE, FALSE), + (6, 'Frank', 'Noir', 'frank@example.com', 'hash-frank', TRUE, FALSE); + +INSERT INTO roles (id, role_name) VALUES (1, 'ADMIN'), (2, 'EDITOR'), (3, 'VIEWER'), (4, 'NONE'), (5, 'SUPER'); + +INSERT INTO permissions (id, resource, action) VALUES (1, 'ROLE', 'WRITE'), (2, 'ROLE', 'READ'); + +INSERT INTO role_permission (role_id, permission_id) VALUES (1, 1), (2, 1), (3, 2), (5, 1); + +INSERT INTO role_user (role_id, user_id) VALUES (1, 1), (2, 1), (1, 2), (1, 3), (3, 4), (5, 6); + +-- Explicit ids do not advance the sequences: move them high so generated ids never collide +ALTER SEQUENCE users_id_seq RESTART WITH 100; +ALTER SEQUENCE roles_id_seq RESTART WITH 100; +ALTER SEQUENCE permissions_id_seq RESTART WITH 100; diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dto/PageDto.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dto/PageDto.java new file mode 100644 index 0000000..d510278 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dto/PageDto.java @@ -0,0 +1,74 @@ +package com.edgareldy.springjdbctutorial.core.common.dto; + +import java.util.List; +import java.util.Objects; + +/** + * One page of a paginated list, as returned by the services. The web layer converts it to its own response type. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PageDto { + + private final List content; + + private final int page; + + private final int size; + + private final long totalElements; + + public PageDto(List content, int page, int size, long totalElements) { + this.content = content; + this.page = page; + this.size = size; + this.totalElements = totalElements; + } + + public List getContent() { + return content; + } + + public int getPage() { + return page; + } + + public int getSize() { + return size; + } + + public long getTotalElements() { + return totalElements; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PageDto that = (PageDto) o; + return page == that.page && size == that.size && totalElements == that.totalElements + && Objects.equals(content, that.content); + } + + @Override + public int hashCode() { + return Objects.hash(content, page, size, totalElements); + } + + @Override + public String toString() { + return "PageDto{" + + "content=" + content + + ", page=" + page + + ", size=" + size + + ", totalElements=" + totalElements + + "}"; + } +} diff --git a/core/common/src/main/resources/db/migration/V2__seed_permissions_and_admin_role.sql b/core/common/src/main/resources/db/migration/V2__seed_permissions_and_admin_role.sql new file mode 100644 index 0000000..3966a08 --- /dev/null +++ b/core/common/src/main/resources/db/migration/V2__seed_permissions_and_admin_role.sql @@ -0,0 +1,22 @@ +-- Baseline RBAC seed: the permissions every resource of this project defines and an ADMIN role holding all of them. +-- Chicken and egg: creating a role or granting a permission requires ROLE:WRITE, so without a seeded role that +-- already carries it, nobody could ever obtain ROLE:WRITE and the RBAC endpoints would be unreachable. +-- No user is seeded here: an administrator account is created only by the optional, configuration driven bootstrap. +-- Idempotent on purpose: every statement can run against a database that already holds part of this data. + +INSERT INTO permissions (resource, action) +SELECT r.resource, a.action +FROM (VALUES ('USER'), ('ROLE'), ('PERMISSION'), ('CATEGORY'), ('PRODUCT'), ('CUSTOMER'), ('ORDER')) AS r (resource) +CROSS JOIN (VALUES ('READ'), ('WRITE')) AS a (action) +ON CONFLICT (resource, action) DO NOTHING; + +INSERT INTO roles (role_name) +VALUES ('ADMIN') +ON CONFLICT (role_name) DO NOTHING; + +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +CROSS JOIN permissions p +WHERE r.role_name = 'ADMIN' +ON CONFLICT (role_id, permission_id) DO NOTHING; diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/FlywayMigrationTest.java b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/FlywayMigrationTest.java index c5111e9..078a83c 100644 --- a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/FlywayMigrationTest.java +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/FlywayMigrationTest.java @@ -14,7 +14,8 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; /** - * Checks that Flyway builds the whole V1 schema and that its key constraints hold on a real PostgreSQL. + * Checks that Flyway builds the whole V1 schema, that its key constraints hold and that the V2 seed + * (baseline permissions and the ADMIN role) is exactly what the README requires, on a real PostgreSQL. *

* Created edgar.muhamyangabo on 9/19/26 * Author : edgar.muhamyangabo @@ -71,4 +72,38 @@ void _03_ShouldRefuseCategoryDeletion_WhenAProductStillReferencesIt() { jdbcTemplate.update("DELETE FROM categories WHERE id = ?", categoryId); } } + + @Test + void _04_ShouldSeedExactlyFourteenPermissions_WhenV2HasRun() { + List codes = jdbcTemplate.queryForList( + "SELECT resource || ':' || action FROM permissions ORDER BY resource, action", String.class); + + // Seven resources times READ and WRITE, and nothing else + assertThat(codes).containsExactly( + "CATEGORY:READ", "CATEGORY:WRITE", "CUSTOMER:READ", "CUSTOMER:WRITE", "ORDER:READ", "ORDER:WRITE", + "PERMISSION:READ", "PERMISSION:WRITE", "PRODUCT:READ", "PRODUCT:WRITE", "ROLE:READ", "ROLE:WRITE", + "USER:READ", "USER:WRITE"); + } + + @Test + void _05_ShouldSeedOneAdminRoleHoldingAllFourteenPermissions_WhenV2HasRun() { + List roles = jdbcTemplate.queryForList("SELECT role_name FROM roles", String.class); + Long granted = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM role_permission rp JOIN roles r ON r.id = rp.role_id " + + "WHERE r.role_name = 'ADMIN'", Long.class); + Long total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM role_permission", Long.class); + + assertThat(roles).containsExactly("ADMIN"); + assertThat(granted).isEqualTo(14L); + assertThat(total).isEqualTo(14L); + } + + @Test + void _06_ShouldNotSeedAnyUser_WhenV2HasRun() { + Long users = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users", Long.class); + Long assignments = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM role_user", Long.class); + + assertThat(users).isZero(); + assertThat(assignments).isZero(); + } } diff --git a/dev-keys/README.md b/dev-keys/README.md index eaedd25..b3b40c8 100644 --- a/dev-keys/README.md +++ b/dev-keys/README.md @@ -11,3 +11,16 @@ Production keys are generated separately into `./keys` (ignored by git) and mounted into the container, see `docker-compose.yml`. There is no default key: the application refuses to start without `app.jwt.private-key-location` and `app.jwt.public-key-location`. + +## Development admin bootstrap (optional) + +There is no seeded user. To get a first administrator locally, export these +variables BEFORE starting Tomcat (dummy values, development only, never set them +in a test or production environment): + + export APP_BOOTSTRAP_ADMIN_EMAIL=admin@example.test + export APP_BOOTSTRAP_ADMIN_PASSWORD=dev-only-Admin-1 + +When both are present and no user has that email, the account is created enabled +with the seeded ADMIN role. When absent, nothing happens. A failure here never +stops the application from starting. diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/SecurityConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/SecurityConfig.java index 64a4bcf..1073b14 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/SecurityConfig.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/SecurityConfig.java @@ -1,16 +1,22 @@ package com.edgareldy.springjdbctutorial.ws.config; import com.fasterxml.jackson.databind.ObjectMapper; +import com.edgareldy.springjdbctutorial.core.auth.service.ActorProvider; import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; import com.edgareldy.springjdbctutorial.ws.security.ApiAccessDeniedHandler; import com.edgareldy.springjdbctutorial.ws.security.ApiAuthenticationEntryPoint; import com.edgareldy.springjdbctutorial.ws.security.JwtAuthFilter; +import com.edgareldy.springjdbctutorial.ws.security.CustomPermissionEvaluator; import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import com.edgareldy.springjdbctutorial.ws.security.SecurityContextActorProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; import org.springframework.core.io.ResourceLoader; import org.springframework.http.HttpMethod; +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; +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; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -33,8 +39,13 @@ // @EnableWebSecurity registers the Spring Security infrastructure and the servlet filter named // "springSecurityFilterChain" (a FilterChainProxy) that the DelegatingFilterProxy of WebAppInitializer // delegates to. Every SecurityFilterChain bean declared here becomes one ordered list of security filters. +// @EnableMethodSecurity turns on @PreAuthorize/@PostAuthorize: Spring wraps the beans that carry them in +// AOP proxies (CGLIB class proxies here, controllers implement no interface) whose interceptor evaluates +// the expression before the method runs and throws AccessDeniedException when it is false. Controllers +// carry the rule declaratively, never a manual permission check. @Configuration @EnableWebSecurity +@EnableMethodSecurity(proxyTargetClass = true) public class SecurityConfig { // Read from the Environment: the two key locations have no default (startup fails clearly when @@ -84,4 +95,27 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtService jwt .addFilterBefore(new JwtAuthFilter(jwtService, authService), UsernamePasswordAuthenticationFilter.class); return http.build(); } + + @Bean + public CustomPermissionEvaluator customPermissionEvaluator() { + return new CustomPermissionEvaluator(); + } + + // The expression handler is what evaluates hasPermission(...) inside @PreAuthorize: registering the + // evaluator on it is how hasPermission('ROLE','WRITE') reaches CustomPermissionEvaluator. The bean + // method is static because the method security infrastructure (a BeanPostProcessor) needs it very + // early: a static @Bean can be created without instantiating this configuration class first, which + // avoids the "not eligible for auto-proxying" warning and early-initialisation problems. + @Bean + public static MethodSecurityExpressionHandler methodSecurityExpressionHandler( + CustomPermissionEvaluator customPermissionEvaluator) { + DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); + handler.setPermissionEvaluator(customPermissionEvaluator); + return handler; + } + + @Bean + public ActorProvider actorProvider() { + return new SecurityContextActorProvider(); + } } diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/PermissionController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/PermissionController.java new file mode 100644 index 0000000..30c3c22 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/PermissionController.java @@ -0,0 +1,67 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.auth.service.RbacService; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import com.edgareldy.springjdbctutorial.ws.converter.PermissionConverter; +import com.edgareldy.springjdbctutorial.ws.payload.rbac.PermissionRequest; +import com.edgareldy.springjdbctutorial.ws.payload.rbac.PermissionResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import java.util.List; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points to manage permissions. No business logic. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @PreAuthorize is evaluated by the method security proxy around this controller (see SecurityConfig): +// hasPermission('X','Y') is answered by CustomPermissionEvaluator from the JWT authorities. +@RestController +@RequestMapping("/api/v1/permissions") +public class PermissionController { + + private final RbacService rbacService; + + public PermissionController(RbacService rbacService) { + this.rbacService = rbacService; + } + + @GetMapping + @PreAuthorize("hasPermission('PERMISSION','READ')") + public ApiResponse> list() { + return ApiResponse.success(PermissionConverter.toResponses(rbacService.listPermissions()), "Permissions"); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasPermission('PERMISSION','WRITE')") + public ApiResponse create(@Valid @RequestBody PermissionRequest request) { + return ApiResponse.success(PermissionConverter.toResponse(rbacService.createPermission(PermissionConverter.toDto(request))), "Permission created"); + } + + @PutMapping("/{id}") + @PreAuthorize("hasPermission('PERMISSION','WRITE')") + public ApiResponse update(@PathVariable Long id, @Valid @RequestBody PermissionRequest request) { + return ApiResponse.success(PermissionConverter.toResponse(rbacService.updatePermission(id, PermissionConverter.toDto(request))), "Permission updated"); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasPermission('PERMISSION','WRITE')") + public ApiResponse delete(@PathVariable Long id) { + rbacService.deletePermission(id); + return ApiResponse.success(null, "Permission deleted"); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/RoleController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/RoleController.java new file mode 100644 index 0000000..4b6e15f --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/RoleController.java @@ -0,0 +1,79 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.auth.service.RbacService; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import com.edgareldy.springjdbctutorial.ws.converter.RoleConverter; +import com.edgareldy.springjdbctutorial.ws.payload.rbac.RoleRequest; +import com.edgareldy.springjdbctutorial.ws.payload.rbac.RoleResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import java.util.List; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points to manage roles and the permissions assigned onto them. No business logic. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @PreAuthorize is evaluated by the method security proxy around this controller (see SecurityConfig): +// hasPermission('X','Y') is answered by CustomPermissionEvaluator from the JWT authorities. +@RestController +@RequestMapping("/api/v1/roles") +public class RoleController { + + private final RbacService rbacService; + + public RoleController(RbacService rbacService) { + this.rbacService = rbacService; + } + + @GetMapping + @PreAuthorize("hasPermission('ROLE','READ')") + public ApiResponse> list() { + return ApiResponse.success(RoleConverter.toResponses(rbacService.listRoles()), "Roles"); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasPermission('ROLE','WRITE')") + public ApiResponse create(@Valid @RequestBody RoleRequest request) { + return ApiResponse.success(RoleConverter.toResponse(rbacService.createRole(RoleConverter.toDto(request))), "Role created"); + } + + @PutMapping("/{id}") + @PreAuthorize("hasPermission('ROLE','WRITE')") + public ApiResponse update(@PathVariable Long id, @Valid @RequestBody RoleRequest request) { + return ApiResponse.success(RoleConverter.toResponse(rbacService.updateRole(id, RoleConverter.toDto(request))), "Role updated"); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasPermission('ROLE','WRITE')") + public ApiResponse delete(@PathVariable Long id) { + rbacService.deleteRole(id); + return ApiResponse.success(null, "Role deleted"); + } + + @PostMapping("/{id}/permissions/{permissionId}") + @PreAuthorize("hasPermission('ROLE','WRITE')") + public ApiResponse assignPermission(@PathVariable Long id, @PathVariable Long permissionId) { + return ApiResponse.success(RoleConverter.toResponse(rbacService.assignPermissionToRole(id, permissionId)), "Permission assigned"); + } + + @DeleteMapping("/{id}/permissions/{permissionId}") + @PreAuthorize("hasPermission('ROLE','WRITE')") + public ApiResponse removePermission(@PathVariable Long id, @PathVariable Long permissionId) { + return ApiResponse.success(RoleConverter.toResponse(rbacService.removePermissionFromRole(id, permissionId)), "Permission removed"); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/UserController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/UserController.java new file mode 100644 index 0000000..7f42ee9 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/UserController.java @@ -0,0 +1,65 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.auth.service.RbacService; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import com.edgareldy.springjdbctutorial.ws.converter.UserConverter; +import com.edgareldy.springjdbctutorial.ws.payload.auth.UserResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points to list users and assign or remove their roles. No business logic. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @PreAuthorize is evaluated by the method security proxy around this controller (see SecurityConfig): +// hasPermission('X','Y') is answered by CustomPermissionEvaluator from the JWT authorities. +@RestController +@RequestMapping("/api/v1/users") +public class UserController { + + private final RbacService rbacService; + + public UserController(RbacService rbacService) { + this.rbacService = rbacService; + } + + // @Min/@Max on request parameters are checked by Spring MVC 6.1 built-in method validation before the + // call; a violation raises HandlerMethodValidationException, mapped to 400 by GlobalExceptionHandler. + @GetMapping + @PreAuthorize("hasPermission('USER','READ')") + public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ApiResponse.success(UserConverter.toPageResponse(rbacService.listUsers(page, size)), "Users"); + } + + @GetMapping("/{id}") + @PreAuthorize("hasPermission('USER','READ')") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.success(UserConverter.toResponse(rbacService.getUser(id)), "User"); + } + + @PatchMapping("/{id}/roles/{roleId}") + @PreAuthorize("hasPermission('USER','WRITE')") + public ApiResponse assignRole(@PathVariable Long id, @PathVariable Long roleId) { + return ApiResponse.success(UserConverter.toResponse(rbacService.assignRoleToUser(id, roleId)), "Role assigned"); + } + + @DeleteMapping("/{id}/roles/{roleId}") + @PreAuthorize("hasPermission('USER','WRITE')") + public ApiResponse removeRole(@PathVariable Long id, @PathVariable Long roleId) { + return ApiResponse.success(UserConverter.toResponse(rbacService.removeRoleFromUser(id, roleId)), "Role removed"); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/PermissionConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/PermissionConverter.java new file mode 100644 index 0000000..a2a3c7a --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/PermissionConverter.java @@ -0,0 +1,43 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.auth.dto.PermissionDto; +import com.edgareldy.springjdbctutorial.ws.payload.rbac.PermissionRequest; +import com.edgareldy.springjdbctutorial.ws.payload.rbac.PermissionResponse; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts between the core PermissionDto and the permission HTTP payloads. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public final class PermissionConverter { + + private PermissionConverter() { + } + + public static PermissionDto toDto(PermissionRequest request) { + PermissionDto dto = new PermissionDto(); + dto.setResource(request.getResource()); + dto.setAction(request.getAction()); + return dto; + } + + public static PermissionResponse toResponse(PermissionDto dto) { + return new PermissionResponse(dto.getId(), dto.getResource(), dto.getAction()); + } + + public static List toResponses(List dtos) { + List responses = new ArrayList<>(); + if (dtos != null) { + for (PermissionDto dto : dtos) { + responses.add(toResponse(dto)); + } + } + return responses; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/RoleConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/RoleConverter.java new file mode 100644 index 0000000..a5cdf0b --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/RoleConverter.java @@ -0,0 +1,40 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.auth.dto.RoleDto; +import com.edgareldy.springjdbctutorial.ws.payload.rbac.RoleRequest; +import com.edgareldy.springjdbctutorial.ws.payload.rbac.RoleResponse; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts between the core RoleDto and the role HTTP payloads. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public final class RoleConverter { + + private RoleConverter() { + } + + public static RoleDto toDto(RoleRequest request) { + RoleDto dto = new RoleDto(); + dto.setRoleName(request.getRoleName()); + return dto; + } + + public static RoleResponse toResponse(RoleDto dto) { + return new RoleResponse(dto.getId(), dto.getRoleName(), PermissionConverter.toResponses(dto.getPermissions())); + } + + public static List toResponses(List dtos) { + List responses = new ArrayList<>(); + for (RoleDto dto : dtos) { + responses.add(toResponse(dto)); + } + return responses; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/UserConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/UserConverter.java index 9dbf970..7129b5e 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/UserConverter.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/UserConverter.java @@ -3,6 +3,10 @@ import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; import com.edgareldy.springjdbctutorial.ws.payload.auth.RegisterRequest; import com.edgareldy.springjdbctutorial.ws.payload.auth.UserResponse; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import java.util.ArrayList; +import java.util.List; /** * Converts between the core UserDto and the auth HTTP payloads. @@ -30,4 +34,12 @@ public static UserResponse toResponse(UserDto dto) { return new UserResponse(dto.getId(), dto.getFirstName(), dto.getLastName(), dto.getEmail(), dto.isEnabled(), dto.getRoles(), dto.getPermissions()); } + + public static PageResponse toPageResponse(PageDto page) { + List content = new ArrayList<>(); + for (UserDto dto : page.getContent()) { + content.add(toResponse(dto)); + } + return PageResponse.of(content, page.getPage(), page.getSize(), page.getTotalElements()); + } } diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java index 393f832..1e34e89 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java @@ -13,7 +13,10 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.validation.FieldError; import org.springframework.web.ErrorResponse; import org.springframework.web.HttpMediaTypeNotAcceptableException; @@ -28,6 +31,7 @@ import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException; +import java.util.Objects; import java.util.stream.Collectors; /** @@ -73,8 +77,11 @@ public ResponseEntity> handleInvalidBody(MethodArgumentNotVali @ExceptionHandler(HandlerMethodValidationException.class) public ResponseEntity> handleMethodValidation(HandlerMethodValidationException e) { - String message = e.getAllErrors().stream() - .map(error -> error.getDefaultMessage()) + // One "parameter: message" entry per violated constraint, like the "field: message" of body errors + String message = e.getParameterValidationResults().stream() + .flatMap(result -> result.getResolvableErrors().stream() + .map(error -> Objects.requireNonNullElse(result.getMethodParameter().getParameterName(), + "parameter") + ": " + error.getDefaultMessage())) .sorted() .collect(Collectors.joining("; ")); return build(HttpStatus.BAD_REQUEST, message); @@ -142,9 +149,14 @@ public ResponseEntity> handleSpringAuthentication(Authenticati } // A denied @PreAuthorize inside a controller surfaces as AccessDeniedException: without this handler - // the catch-all would turn it into a 500. + // the catch-all would turn it into a 500. With no real authentication (null or anonymous) the caller + // is unauthenticated, so the answer is 401 rather than 403. @ExceptionHandler(AccessDeniedException.class) public ResponseEntity> handleAccessDenied(AccessDeniedException e) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { + return build(HttpStatus.UNAUTHORIZED, "Authentication required"); + } return build(HttpStatus.FORBIDDEN, "Access denied"); } diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/PermissionRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/PermissionRequest.java new file mode 100644 index 0000000..e40d160 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/PermissionRequest.java @@ -0,0 +1,72 @@ +package com.edgareldy.springjdbctutorial.ws.payload.rbac; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.Objects; + +/** + * Body of the create and update permission calls (resource and action, letters and underscore only). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PermissionRequest { + + @NotBlank + @Size(max = 100) + @Pattern(regexp = "[A-Za-z_]+") + private String resource; + + @NotBlank + @Size(max = 100) + @Pattern(regexp = "[A-Za-z_]+") + private String action; + + public PermissionRequest() { + } + + public String getResource() { + return resource; + } + + public void setResource(String resource) { + this.resource = resource; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PermissionRequest that = (PermissionRequest) o; + return Objects.equals(resource, that.resource) && Objects.equals(action, that.action); + } + + @Override + public int hashCode() { + return Objects.hash(resource, action); + } + + @Override + public String toString() { + return "PermissionRequest{" + + "resource=" + resource + + ", action=" + action + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/PermissionResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/PermissionResponse.java new file mode 100644 index 0000000..13ee6a3 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/PermissionResponse.java @@ -0,0 +1,79 @@ +package com.edgareldy.springjdbctutorial.ws.payload.rbac; + +import java.util.Objects; + +/** + * A permission as returned by the API. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PermissionResponse { + + private Long id; + + private String resource; + + private String action; + + public PermissionResponse() { + } + + public PermissionResponse(Long id, String resource, String action) { + this.id = id; + this.resource = resource; + this.action = action; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getResource() { + return resource; + } + + public void setResource(String resource) { + this.resource = resource; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PermissionResponse that = (PermissionResponse) o; + return Objects.equals(id, that.id) && Objects.equals(resource, that.resource) && Objects.equals(action, that.action); + } + + @Override + public int hashCode() { + return Objects.hash(id, resource, action); + } + + @Override + public String toString() { + return "PermissionResponse{" + + "id=" + id + + ", resource=" + resource + + ", action=" + action + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/RoleRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/RoleRequest.java new file mode 100644 index 0000000..6bbe1bb --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/RoleRequest.java @@ -0,0 +1,56 @@ +package com.edgareldy.springjdbctutorial.ws.payload.rbac; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.Objects; + +/** + * Body of the create and rename role calls. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class RoleRequest { + + @NotBlank + @Size(max = 100) + private String roleName; + + public RoleRequest() { + } + + public String getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RoleRequest that = (RoleRequest) o; + return Objects.equals(roleName, that.roleName); + } + + @Override + public int hashCode() { + return Objects.hash(roleName); + } + + @Override + public String toString() { + return "RoleRequest{" + + "roleName=" + roleName + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/RoleResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/RoleResponse.java new file mode 100644 index 0000000..49c48ca --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/RoleResponse.java @@ -0,0 +1,80 @@ +package com.edgareldy.springjdbctutorial.ws.payload.rbac; + +import java.util.List; +import java.util.Objects; + +/** + * A role with its permissions as returned by the API. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class RoleResponse { + + private Long id; + + private String roleName; + + private List permissions; + + public RoleResponse() { + } + + public RoleResponse(Long id, String roleName, List permissions) { + this.id = id; + this.roleName = roleName; + this.permissions = permissions; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RoleResponse that = (RoleResponse) o; + return Objects.equals(id, that.id) && Objects.equals(roleName, that.roleName) && Objects.equals(permissions, that.permissions); + } + + @Override + public int hashCode() { + return Objects.hash(id, roleName, permissions); + } + + @Override + public String toString() { + return "RoleResponse{" + + "id=" + id + + ", roleName=" + roleName + + ", permissions=" + permissions + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/CustomPermissionEvaluator.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/CustomPermissionEvaluator.java new file mode 100644 index 0000000..914a222 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/CustomPermissionEvaluator.java @@ -0,0 +1,56 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import org.springframework.security.access.PermissionEvaluator; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +import java.io.Serializable; +import java.util.Locale; + +/** + * The only place where permissions are resolved: answers hasPermission(resource, action) from the authorities of the Authentication. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// A PermissionEvaluator is the Spring Security extension point behind the hasPermission(...) function of +// @PreAuthorize expressions. The authorities ("RESOURCE:ACTION") were rebuilt by JwtAuthFilter from the +// permissions embedded in the JWT at login, so a check is a pure in-memory lookup: no database call per +// request. +public class CustomPermissionEvaluator implements PermissionEvaluator { + + @Override + public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) { + try { + if (authentication == null || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken + || !(targetDomainObject instanceof String resource) || !(permission instanceof String action)) { + return false; + } + String wanted = normalize(resource) + ":" + normalize(action); + for (GrantedAuthority authority : authentication.getAuthorities()) { + String value = authority == null ? null : authority.getAuthority(); + if (value != null && wanted.equals(normalize(value))) { + return true; + } + } + return false; + } catch (RuntimeException e) { + return false; + } + } + + /** Ids are not used by this project's permissions: the (id, type, permission) form always denies. */ + @Override + public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, + Object permission) { + return false; + } + + private static String normalize(String value) { + return value.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/SecurityContextActorProvider.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/SecurityContextActorProvider.java new file mode 100644 index 0000000..33b36a9 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/SecurityContextActorProvider.java @@ -0,0 +1,27 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import com.edgareldy.springjdbctutorial.core.auth.service.ActorProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Tells the core audit logger who the current actor is, read from the SecurityContext (null when anonymous). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// core has no web or security dependency: it only knows the ActorProvider interface, and ws supplies +// this implementation so audit rows carry the id of the authenticated user. +public class SecurityContextActorProvider implements ActorProvider { + + @Override + public Long currentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof AuthenticatedUser user) { + return user.getUserId(); + } + return null; + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AdminBootstrapFailureIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AdminBootstrapFailureIntegrationTest.java new file mode 100644 index 0000000..44e7e94 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AdminBootstrapFailureIntegrationTest.java @@ -0,0 +1,52 @@ +package com.edgareldy.springjdbctutorial.ws.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.auth.service.impl.AdminBootstrap; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.web.context.WebApplicationContext; + +/** + * A bootstrap that cannot succeed (password over 72 bytes, refused before anything is written) must not fail + * the startup of the real application context: the context loads, the bean exists, no account is created. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// If AdminBootstrap threw, the initMethod would make the whole context fail and JUnit would report every +// test of this class as an error before running it: merely reaching the test body proves the startup survived. +@SpringJUnitWebConfig(WebMvcConfig.class) +@TestPropertySource(properties = { + "app.bootstrap.admin-email=too.long@example.com", + "app.bootstrap.admin-password=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) +class AdminBootstrapFailureIntegrationTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private WebApplicationContext context; + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void _01_ShouldStartTheContextAndCreateNoAccount_WhenTheConfiguredPasswordIsTooLong() { + assertThat(context.getBean(AdminBootstrap.class)).isNotNull(); + + Long users = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE LOWER(email) = 'too.long@example.com'", Long.class); + assertThat(users).isZero(); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AdminBootstrapIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AdminBootstrapIntegrationTest.java new file mode 100644 index 0000000..2291bc0 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AdminBootstrapIntegrationTest.java @@ -0,0 +1,112 @@ +package com.edgareldy.springjdbctutorial.ws.integration; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.support.AuthTestSupport; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * The optional admin bootstrap on the REAL application context: with app.bootstrap.admin-email and + * app.bootstrap.admin-password set, the context creates the administrator at startup (enabled, ADMIN role, + * one BOOTSTRAP_ADMIN audit row without actor) who can then log in through HTTP and use the RBAC routes. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @TestPropertySource adds the two bootstrap properties to the Environment of THIS context only (a separate +// cached context from the other integration tests, which run without them). The bootstrap bean runs once +// while the context starts, so the account already exists when the first test begins. The values are +// dummy development ones and must never be set in a real test/prod configuration. +@SpringJUnitWebConfig(WebMvcConfig.class) +@TestPropertySource(properties = { + "app.bootstrap.admin-email=Bootstrap.Admin@Example.com", + "app.bootstrap.admin-password=Dev-only-Passw0rd!"}) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AdminBootstrapIntegrationTest { + + private static final String EMAIL = "bootstrap.admin@example.com"; + private static final String PASSWORD = "Dev-only-Passw0rd!"; + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private WebApplicationContext context; + @Autowired + private JdbcTemplate jdbcTemplate; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + } + + // The bootstrap admin holds ROLE:WRITE and would count in the last-admin rule of the other classes + // (their isolation helper would disable it, but leaving no trace is cleaner): remove it after the class. + @AfterAll + void removeTheBootstrapAdmin() { + jdbcTemplate.update("DELETE FROM users WHERE LOWER(email) = ?", EMAIL); + } + + @Test + void _01_ShouldHaveCreatedAnEnabledUnlockedAdminWithTheAdminRoleAndAHashedPassword_WhenTheContextStarted() { + List roles = jdbcTemplate.queryForList("SELECT r.role_name FROM roles r " + + "JOIN role_user ru ON ru.role_id = r.id JOIN users u ON u.id = ru.user_id " + + "WHERE LOWER(u.email) = ?", String.class, EMAIL); + Boolean enabled = jdbcTemplate.queryForObject("SELECT enabled FROM users WHERE LOWER(email) = ?", Boolean.class, EMAIL); + Boolean locked = jdbcTemplate.queryForObject("SELECT account_locked FROM users WHERE LOWER(email) = ?", Boolean.class, EMAIL); + String storedPassword = jdbcTemplate.queryForObject("SELECT password FROM users WHERE LOWER(email) = ?", String.class, EMAIL); + + assertThat(roles).containsExactly("ADMIN"); + assertThat(enabled).isTrue(); + assertThat(locked).isFalse(); + assertThat(storedPassword).isNotEqualTo(PASSWORD).startsWith("$2"); + } + + @Test + void _02_ShouldLogInAndUseTheRbacRoutes_WhenTheBootstrapAdminPresentsTheConfiguredCredentials() throws Exception { + try (AuthTestSupport support = new AuthTestSupport(mockMvc)) { + String jwt = support.loginToken(EMAIL, PASSWORD); + + JsonNode me = assertSuccess(support.getAs(jwt, "/api/v1/auth/me"), 200); + assertThat(me.get("data").get("roles").get(0).asText()).isEqualTo("ADMIN"); + assertThat(me.get("data").get("permissions")).hasSize(14); + assertSuccess(support.getAs(jwt, "/api/v1/users"), 200); + assertSuccess(support.getAs(jwt, "/api/v1/roles"), 200); + assertError(support.login(EMAIL, "wrong-Passw0rd!"), 401); + } + } + + @Test + void _03_ShouldHaveWrittenOneBootstrapAuditRowWithoutActor_WhenTheContextStarted() { + Long rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM audit_logs a JOIN users u ON u.id = a.entity_id " + + "WHERE a.action = 'BOOTSTRAP_ADMIN' AND a.entity_type = 'USER' AND a.actor_user_id IS NULL " + + "AND LOWER(u.email) = ?", Long.class, EMAIL); + + assertThat(rows).isEqualTo(1L); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/LastAdminRuleIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/LastAdminRuleIntegrationTest.java new file mode 100644 index 0000000..ca3cb92 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/LastAdminRuleIntegrationTest.java @@ -0,0 +1,274 @@ +package com.edgareldy.springjdbctutorial.ws.integration; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport; +import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport.AuditRow; +import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport.TestUser; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * The last-admin rule through the REAL stack: refused in both directions (removing ROLE:WRITE from a role, removing + * a role from a user), allowed while another enabled and unlocked holder exists, not fooled by disabled or locked + * holders, not applied to unrelated permissions, and race free (two admins removing each other at the same time). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// The rule counts every enabled, unlocked ROLE:WRITE holder of the shared database. setUp() disables the +// holders left by other tests (RbacTestSupport.isolateLastAdminState) and tearDown() restores them, so each +// test starts with ZERO holders and creates exactly the admins it needs, on its own it-... roles: the +// seeded ADMIN role is never modified. +@SpringJUnitWebConfig(WebMvcConfig.class) +class LastAdminRuleIntegrationTest { + + private static final String[] ADMIN_PERMISSIONS = {"ROLE:WRITE", "ROLE:READ", "USER:WRITE", "USER:READ", + "PERMISSION:READ", "PERMISSION:WRITE"}; + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private WebApplicationContext context; + @Autowired + private JdbcTemplate jdbcTemplate; + + private RbacTestSupport support; + + @BeforeEach + void setUp() { + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + support = new RbacTestSupport(mockMvc, jdbcTemplate); + support.isolateLastAdminState(); + } + + @AfterEach + void tearDown() { + support.close(); + } + + private long count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Long.class, args); + } + + private String removePermissionUrl(long roleId, String code) { + return "/api/v1/roles/" + roleId + "/permissions/" + support.permissionId(code); + } + + private static String removeRoleUrl(TestUser user, long roleId) { + return "/api/v1/users/" + user.getId() + "/roles/" + roleId; + } + + // ---------------------------------------------------------------- removing ROLE:WRITE from a role + + @Test + void _01_ShouldReturn422AndChangeNothing_WhenRoleWriteIsRemovedFromTheRoleOfTheOnlyAdmin() throws Exception { + long roleId = support.createRole(ADMIN_PERMISSIONS); + TestUser admin = support.createUser(true, false, roleId); + long base = support.auditBaseline(); + + JsonNode json = assertError(support.delete(admin.getJwt(), removePermissionUrl(roleId, "ROLE:WRITE")), 422); + + assertThat(json.get("message").asText()).isEqualTo("Cannot remove the last account holding ROLE:WRITE"); + assertThat(count("SELECT COUNT(*) FROM role_permission WHERE role_id = ? AND permission_id = ?", + roleId, support.permissionId("ROLE:WRITE"))).isOne(); + assertThat(support.countLastAdminCandidates()).isOne(); + List rejected = support.auditSince(base, "REJECTED_REMOVE_PERMISSION_FROM_ROLE"); + assertThat(rejected).hasSize(1); + assertThat(rejected.get(0).getEntityId()).isEqualTo(roleId); + assertThat(rejected.get(0).getActorUserId()).isEqualTo(admin.getId()); + assertThat(support.auditSince(base, "REMOVE_PERMISSION_FROM_ROLE")).isEmpty(); + } + + @Test + void _02_ShouldAllowIt_WhenAnotherAdminHoldsRoleWriteThroughAnotherRole() throws Exception { + long roleA = support.createRole(ADMIN_PERMISSIONS); + long roleB = support.createRole(ADMIN_PERMISSIONS); + TestUser adminA = support.createUser(true, false, roleA); + TestUser adminB = support.createUser(true, false, roleB); + long base = support.auditBaseline(); + + assertSuccess(support.delete(adminA.getJwt(), removePermissionUrl(roleA, "ROLE:WRITE")), 200); + + assertThat(support.countLastAdminCandidates()).isOne(); + assertThat(support.auditSince(base, "REMOVE_PERMISSION_FROM_ROLE")).hasSize(1); + // Now adminB is the last holder: the same operation on its role is refused + assertError(support.delete(adminB.getJwt(), removePermissionUrl(roleB, "ROLE:WRITE")), 422); + assertThat(support.countLastAdminCandidates()).isOne(); + } + + @Test + void _03_ShouldAllowIt_WhenTheRemovedPermissionIsNotRoleWrite() throws Exception { + long roleId = support.createRole(ADMIN_PERMISSIONS); + TestUser admin = support.createUser(true, false, roleId); + + assertSuccess(support.delete(admin.getJwt(), removePermissionUrl(roleId, "PERMISSION:READ")), 200); + + assertThat(support.countLastAdminCandidates()).isOne(); + } + + @Test + void _04_ShouldReturn422AndRestoreThePermission_WhenAPermissionUpdateWouldStripRoleWriteFromTheOnlyAdmin() + throws Exception { + long roleId = support.createRole(ADMIN_PERMISSIONS); + TestUser admin = support.createUser(true, false, roleId); + long roleWriteId = support.permissionId("ROLE:WRITE"); + try { + JsonNode json = assertError(support.put(admin.getJwt(), "/api/v1/permissions/" + roleWriteId, + "{\"resource\":\"ROLE\",\"action\":\"MANAGE\"}"), 422); + + assertThat(json.get("message").asText()).isEqualTo("Cannot remove the last account holding ROLE:WRITE"); + assertThat(count("SELECT COUNT(*) FROM permissions WHERE id = ? AND resource = 'ROLE' AND action = 'WRITE'", + roleWriteId)).isOne(); + assertThat(support.countLastAdminCandidates()).isOne(); + } finally { + // Safety net: if the rule ever failed, never leave the seeded permission renamed for the other tests + jdbcTemplate.update("UPDATE permissions SET resource = 'ROLE', action = 'WRITE' WHERE id = ?", roleWriteId); + } + } + + // ---------------------------------------------------------------- removing the role from a user + + @Test + void _05_ShouldReturn422AndChangeNothing_WhenTheRoleIsRemovedFromTheOnlyAdmin() throws Exception { + long roleId = support.createRole(ADMIN_PERMISSIONS); + TestUser admin = support.createUser(true, false, roleId); + long base = support.auditBaseline(); + + JsonNode json = assertError(support.delete(admin.getJwt(), removeRoleUrl(admin, roleId)), 422); + + assertThat(json.get("message").asText()).isEqualTo("Cannot remove the last account holding ROLE:WRITE"); + assertThat(count("SELECT COUNT(*) FROM role_user WHERE role_id = ? AND user_id = ?", roleId, admin.getId())).isOne(); + assertThat(support.countLastAdminCandidates()).isOne(); + List rejected = support.auditSince(base, "REJECTED_REMOVE_ROLE_FROM_USER"); + assertThat(rejected).hasSize(1); + assertThat(rejected.get(0).getEntityType()).isEqualTo("USER"); + assertThat(rejected.get(0).getEntityId()).isEqualTo(admin.getId()); + assertThat(support.auditSince(base, "REMOVE_ROLE_FROM_USER")).isEmpty(); + } + + @Test + void _06_ShouldAllowItThenRefuseTheLast_WhenASecondEnabledUnlockedAdminExists() throws Exception { + long roleId = support.createRole(ADMIN_PERMISSIONS); + TestUser adminA = support.createUser(true, false, roleId); + TestUser adminB = support.createUser(true, false, roleId); + long base = support.auditBaseline(); + + assertSuccess(support.delete(adminA.getJwt(), removeRoleUrl(adminB, roleId)), 200); + + assertThat(support.countLastAdminCandidates()).isOne(); + List done = support.auditSince(base, "REMOVE_ROLE_FROM_USER"); + assertThat(done).hasSize(1); + assertThat(done.get(0).getEntityId()).isEqualTo(adminB.getId()); + assertThat(done.get(0).getActorUserId()).isEqualTo(adminA.getId()); + // adminA is now alone + assertError(support.delete(adminA.getJwt(), removeRoleUrl(adminA, roleId)), 422); + assertThat(support.countLastAdminCandidates()).isOne(); + } + + @Test + void _07_ShouldStillRefuse_WhenTheOtherRoleWriteHolderIsDisabled() throws Exception { + long roleId = support.createRole(ADMIN_PERMISSIONS); + TestUser enabledAdmin = support.createUser(true, false, roleId); + support.createUser(false, false, roleId); + + assertError(support.delete(enabledAdmin.getJwt(), removeRoleUrl(enabledAdmin, roleId)), 422); + + assertThat(count("SELECT COUNT(*) FROM role_user WHERE user_id = ?", enabledAdmin.getId())).isOne(); + } + + @Test + void _08_ShouldStillRefuse_WhenTheOtherRoleWriteHolderIsLocked() throws Exception { + long roleId = support.createRole(ADMIN_PERMISSIONS); + TestUser enabledAdmin = support.createUser(true, false, roleId); + support.createUser(true, true, roleId); + + assertError(support.delete(enabledAdmin.getJwt(), removeRoleUrl(enabledAdmin, roleId)), 422); + + assertThat(count("SELECT COUNT(*) FROM role_user WHERE user_id = ?", enabledAdmin.getId())).isOne(); + } + + @Test + void _09_ShouldAllowRemovingARoleWithoutRoleWrite_WhenTheAdminKeepsItsAdminRole() throws Exception { + long adminRole = support.createRole(ADMIN_PERMISSIONS); + long readerRole = support.createRole("USER:READ"); + TestUser admin = support.createUser(true, false, adminRole); + support.assignRoleInDb(admin.getId(), readerRole); + + assertSuccess(support.delete(admin.getJwt(), removeRoleUrl(admin, readerRole)), 200); + + assertThat(support.countLastAdminCandidates()).isOne(); + } + + // ---------------------------------------------------------------- concurrency + + // Two admins are the only ROLE:WRITE holders and each removes the role of one holder at the same instant. + // Without the advisory lock both transactions would count "2 holders before", each would see itself leave + // one behind, and both would commit: zero admins. With the lock the second one waits, then counts 1 before + // and 0 after, and is refused. Repeated three times to give a race every chance to show. + @Test + void _10_ShouldLetExactlyOneSucceed_WhenTwoAdminsRemoveEachOthersRoleAtTheSameTime() throws Exception { + long roleId = support.createRole(ADMIN_PERMISSIONS); + TestUser adminA = support.createUser(true, false, roleId); + TestUser adminB = support.createUser(true, false, roleId); + long base = support.auditBaseline(); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + for (int round = 1; round <= 3; round++) { + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + Future removeA = pool.submit(removal(adminA, adminA, roleId, ready, start)); + Future removeB = pool.submit(removal(adminB, adminB, roleId, ready, start)); + assertThat(ready.await(30, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + int statusA = removeA.get(60, TimeUnit.SECONDS); + int statusB = removeB.get(60, TimeUnit.SECONDS); + + assertThat(List.of(statusA, statusB)).as("round " + round).containsExactlyInAnyOrder(200, 422); + assertThat(support.countLastAdminCandidates()).as("round " + round).isOne(); + assertThat(support.auditSince(base, "REMOVE_ROLE_FROM_USER")).hasSize(round); + assertThat(support.auditSince(base, "REJECTED_REMOVE_ROLE_FROM_USER")).hasSize(round); + // Give the role back to the one who lost it, so the next round starts with two holders again + support.assignRoleInDb((statusA == 200 ? adminA : adminB).getId(), roleId); + } + } finally { + pool.shutdownNow(); + } + } + + private Callable removal(TestUser caller, TestUser target, long roleId, CountDownLatch ready, + CountDownLatch start) { + return () -> { + ready.countDown(); + start.await(); + return support.delete(caller.getJwt(), removeRoleUrl(target, roleId)).getResponse().getStatus(); + }; + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/RbacFlowIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/RbacFlowIntegrationTest.java new file mode 100644 index 0000000..c1f99e2 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/RbacFlowIntegrationTest.java @@ -0,0 +1,455 @@ +package com.edgareldy.springjdbctutorial.ws.integration; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport; +import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport.AuditRow; +import com.edgareldy.springjdbctutorial.ws.support.RbacTestSupport.TestUser; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * End-to-end RBAC flows through the REAL stack (real WebMvcConfig, real services, real PostgreSQL migrated by + * Flyway): permission enforcement, role/permission/user-role CRUD, JWT permissions applied at the next login, + * refused operations with their REJECTED_ audit row (written through the transactional proxy), audit rows for + * every mutation, 404s that are not audited, and page/size validation. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// The full application context: DataSourceConfig (Flyway + Hikari), the auth services, SecurityConfig and the +// controllers. Nothing is mocked. All test classes of the JVM share the one Testcontainers database, so each +// test creates its own uniquely named users/roles and RbacTestSupport removes them afterwards. +@SpringJUnitWebConfig(WebMvcConfig.class) +class RbacFlowIntegrationTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private WebApplicationContext context; + @Autowired + private JdbcTemplate jdbcTemplate; + + private MockMvc mockMvc; + private RbacTestSupport support; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + support = new RbacTestSupport(mockMvc, jdbcTemplate); + // Other tests may have left ROLE:WRITE holders behind: disable them so the last-admin rule only sees this test + support.isolateLastAdminState(); + } + + @AfterEach + void tearDown() { + support.close(); + } + + private static String roleBody(String name) { + return "{\"roleName\":\"" + name + "\"}"; + } + + private static String permissionBody(String resource, String action) { + return "{\"resource\":\"" + resource + "\",\"action\":\"" + action + "\"}"; + } + + private static long idOf(JsonNode json) { + return json.get("data").get("id").asLong(); + } + + private static List actions(List rows) { + return rows.stream().map(AuditRow::getAction).toList(); + } + + private boolean listContainsRole(String jwt, String roleName) throws Exception { + JsonNode json = assertSuccess(support.get(jwt, "/api/v1/roles"), 200); + for (JsonNode role : json.get("data")) { + if (role.get("roleName").asText().equals(roleName)) { + return true; + } + } + return false; + } + + private long count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Long.class, args); + } + + // ---------------------------------------------------------------- permission enforcement + + @Test + void _01_ShouldEnforcePermissionsEndToEnd_WhenSimpleUserAndAdminCallTheListRoutes() throws Exception { + TestUser simple = support.createSimpleUser(); + TestUser admin = support.createAdmin(); + + for (String url : List.of("/api/v1/users", "/api/v1/roles", "/api/v1/permissions")) { + assertError(support.get(null, url), 401); + assertError(support.get(simple.getJwt(), url), 403); + assertSuccess(support.get(admin.getJwt(), url), 200); + } + // A user without permission is still authenticated: routes that need no permission stay open + assertSuccess(support.get(simple.getJwt(), "/api/v1/auth/me"), 200); + } + + @Test + void _02_ShouldRefuseEveryMutation_WhenSimpleUserHasNoPermission() throws Exception { + TestUser simple = support.createSimpleUser(); + long base = support.auditBaseline(); + + assertError(support.post(simple.getJwt(), "/api/v1/roles", roleBody(RbacTestSupport.uniqueRoleName())), 403); + assertError(support.post(simple.getJwt(), "/api/v1/permissions", permissionBody("IT_NOPE", "READ")), 403); + assertError(support.patch(simple.getJwt(), "/api/v1/users/" + simple.getId() + "/roles/" + support.adminRoleId()), 403); + + // The escalation attempt changed nothing and left no audit row (the service was never reached) + assertThat(count("SELECT COUNT(*) FROM role_user WHERE user_id = ?", simple.getId())).isZero(); + assertThat(support.auditSince(base)).isEmpty(); + } + + @Test + void _03_ShouldExposeTheSeededAdminRoleWithFourteenPermissions_WhenAdminListsRolesAndPermissions() throws Exception { + TestUser admin = support.createAdmin(); + + JsonNode roles = assertSuccess(support.get(admin.getJwt(), "/api/v1/roles"), 200).get("data"); + JsonNode adminRole = null; + for (JsonNode role : roles) { + if (role.get("roleName").asText().equals("ADMIN")) { + adminRole = role; + } + } + JsonNode permissions = assertSuccess(support.get(admin.getJwt(), "/api/v1/permissions"), 200).get("data"); + + assertThat(adminRole).isNotNull(); + assertThat(adminRole.get("permissions")).hasSize(14); + assertThat(permissions.size()).isGreaterThanOrEqualTo(14); + } + + // ---------------------------------------------------------------- CRUD flows and audit rows + + @Test + void _04_ShouldCreateRenameAndDeleteARoleWithAnAuditRowEach_WhenTwoAdminsCollaborate() throws Exception { + TestUser adminA = support.createAdmin(); + TestUser adminB = support.createAdmin(); + long base = support.auditBaseline(); + String name = RbacTestSupport.uniqueRoleName(); + String renamed = RbacTestSupport.uniqueRoleName(); + + JsonNode created = assertSuccess(support.post(adminA.getJwt(), "/api/v1/roles", roleBody(name)), 201); + long roleId = idOf(created); + assertThat(created.get("data").get("permissions")).isEmpty(); + JsonNode updated = assertSuccess(support.put(adminB.getJwt(), "/api/v1/roles/" + roleId, roleBody(renamed)), 200); + assertThat(updated.get("data").get("roleName").asText()).isEqualTo(renamed); + assertThat(listContainsRole(adminA.getJwt(), renamed)).isTrue(); + assertSuccess(support.delete(adminA.getJwt(), "/api/v1/roles/" + roleId), 200); + assertThat(listContainsRole(adminA.getJwt(), renamed)).isFalse(); + + List rows = support.auditSince(base); + assertThat(actions(rows)).containsExactly("CREATE_ROLE", "UPDATE_ROLE", "DELETE_ROLE"); + assertThat(rows).allSatisfy(row -> { + assertThat(row.getEntityType()).isEqualTo("ROLE"); + assertThat(row.getEntityId()).isEqualTo(roleId); + }); + // The actor of each row is the caller of that very request + assertThat(rows.get(0).getActorUserId()).isEqualTo(adminA.getId()); + assertThat(rows.get(1).getActorUserId()).isEqualTo(adminB.getId()); + assertThat(rows.get(2).getActorUserId()).isEqualTo(adminA.getId()); + assertThat(rows.get(1).getDetails()).contains(name).contains(renamed); + } + + @Test + void _05_ShouldCreateNormalizeUpdateAndDeleteAPermissionWithAnAuditRowEach_WhenAdminManagesPermissions() + throws Exception { + TestUser admin = support.createAdmin(); + long base = support.auditBaseline(); + + JsonNode created = assertSuccess(support.post(admin.getJwt(), "/api/v1/permissions", + permissionBody("it_invoice", "read")), 201); + long id = idOf(created); + assertThat(created.get("data").get("resource").asText()).isEqualTo("IT_INVOICE"); + assertThat(created.get("data").get("action").asText()).isEqualTo("READ"); + JsonNode updated = assertSuccess(support.put(admin.getJwt(), "/api/v1/permissions/" + id, + permissionBody("it_invoice", "write")), 200); + assertThat(updated.get("data").get("action").asText()).isEqualTo("WRITE"); + assertSuccess(support.delete(admin.getJwt(), "/api/v1/permissions/" + id), 200); + + List rows = support.auditSince(base); + assertThat(actions(rows)).containsExactly("CREATE_PERMISSION", "UPDATE_PERMISSION", "DELETE_PERMISSION"); + assertThat(rows).allSatisfy(row -> { + assertThat(row.getEntityType()).isEqualTo("PERMISSION"); + assertThat(row.getEntityId()).isEqualTo(id); + assertThat(row.getActorUserId()).isEqualTo(admin.getId()); + }); + assertThat(count("SELECT COUNT(*) FROM permissions WHERE id = ?", id)).isZero(); + } + + @Test + void _06_ShouldAssignAndRemoveInTheRightDirections_WhenPermissionsGoOntoARoleAndTheRoleOntoAUser() + throws Exception { + TestUser admin = support.createAdmin(); + TestUser target = support.createSimpleUser(); + long base = support.auditBaseline(); + long roleId = idOf(assertSuccess(support.post(admin.getJwt(), "/api/v1/roles", + roleBody(RbacTestSupport.uniqueRoleName())), 201)); + long permissionId = idOf(assertSuccess(support.post(admin.getJwt(), "/api/v1/permissions", + permissionBody("it_report", "read")), 201)); + + JsonNode withPermission = assertSuccess(support.post(admin.getJwt(), + "/api/v1/roles/" + roleId + "/permissions/" + permissionId, null), 200); + assertThat(withPermission.get("data").get("permissions")).hasSize(1); + JsonNode withRole = assertSuccess(support.patch(admin.getJwt(), + "/api/v1/users/" + target.getId() + "/roles/" + roleId), 200); + assertThat(withRole.get("data").get("roles")).hasSize(1); + assertThat(withRole.get("data").get("permissions").get(0).asText()).isEqualTo("IT_REPORT:READ"); + JsonNode detail = assertSuccess(support.get(admin.getJwt(), "/api/v1/users/" + target.getId()), 200); + assertThat(detail.get("data").get("permissions").get(0).asText()).isEqualTo("IT_REPORT:READ"); + assertThat(detail.get("data").has("password")).isFalse(); + + JsonNode withoutRole = assertSuccess(support.delete(admin.getJwt(), + "/api/v1/users/" + target.getId() + "/roles/" + roleId), 200); + assertThat(withoutRole.get("data").get("roles")).isEmpty(); + JsonNode withoutPermission = assertSuccess(support.delete(admin.getJwt(), + "/api/v1/roles/" + roleId + "/permissions/" + permissionId), 200); + assertThat(withoutPermission.get("data").get("permissions")).isEmpty(); + + List rows = support.auditSince(base); + assertThat(actions(rows)).containsExactly("CREATE_ROLE", "CREATE_PERMISSION", "ASSIGN_PERMISSION_TO_ROLE", + "ASSIGN_ROLE_TO_USER", "REMOVE_ROLE_FROM_USER", "REMOVE_PERMISSION_FROM_ROLE"); + assertThat(rows.get(2).getEntityType()).isEqualTo("ROLE"); + assertThat(rows.get(2).getEntityId()).isEqualTo(roleId); + assertThat(rows.get(3).getEntityType()).isEqualTo("USER"); + assertThat(rows.get(3).getEntityId()).isEqualTo(target.getId()); + assertThat(rows.get(4).getEntityType()).isEqualTo("USER"); + assertThat(rows.get(4).getEntityId()).isEqualTo(target.getId()); + assertThat(rows.get(5).getEntityType()).isEqualTo("ROLE"); + assertThat(rows).allSatisfy(row -> assertThat(row.getActorUserId()).isEqualTo(admin.getId())); + } + + // ---------------------------------------------------------------- JWT permissions apply at the next login + + @Test + void _07_ShouldApplyRoleChangesOnlyAtTheNextLogin_WhenTheRoleIsAssignedAndRemovedAfterLogin() throws Exception { + TestUser admin = support.createAdmin(); + TestUser user = support.createSimpleUser(); + long roleId = support.createRole("ROLE:READ"); + String tokenBefore = user.getJwt(); + assertError(support.get(tokenBefore, "/api/v1/roles"), 403); + + assertSuccess(support.patch(admin.getJwt(), "/api/v1/users/" + user.getId() + "/roles/" + roleId), 200); + + // Permissions are embedded in the JWT: the old token still lacks ROLE:READ ... + assertError(support.get(tokenBefore, "/api/v1/roles"), 403); + // ... and a NEW login carries it + String tokenAfter = support.freshToken(user); + assertSuccess(support.get(tokenAfter, "/api/v1/roles"), 200); + + assertSuccess(support.delete(admin.getJwt(), "/api/v1/users/" + user.getId() + "/roles/" + roleId), 200); + + // Removal is symmetric: the token issued while the role was held keeps working until it expires ... + assertSuccess(support.get(tokenAfter, "/api/v1/roles"), 200); + // ... and the next login no longer has the permission + assertError(support.get(support.freshToken(user), "/api/v1/roles"), 403); + } + + // ---------------------------------------------------------------- refusals and their audit rows + + @Test + void _08_ShouldReturn422AndKeepTheRefusalRowAfterTheRollback_WhenDeletingARoleStillAssigned() throws Exception { + TestUser admin = support.createAdmin(); + long roleId = support.createRole("USER:READ"); + TestUser holder = support.createUser(true, false, roleId); + long base = support.auditBaseline(); + + JsonNode json = assertError(support.delete(admin.getJwt(), "/api/v1/roles/" + roleId), 422); + + assertThat(json.get("message").asText()).isEqualTo("Role is still assigned to users"); + assertThat(count("SELECT COUNT(*) FROM roles WHERE id = ?", roleId)).isOne(); + assertThat(count("SELECT COUNT(*) FROM role_user WHERE role_id = ? AND user_id = ?", roleId, holder.getId())).isOne(); + // The business transaction rolled back, yet the REQUIRES_NEW refusal row is committed: proof that the + // AuditLogger is really called through its transactional proxy + List rejected = support.auditSince(base, "REJECTED_DELETE_ROLE"); + assertThat(rejected).hasSize(1); + assertThat(rejected.get(0).getEntityType()).isEqualTo("ROLE"); + assertThat(rejected.get(0).getEntityId()).isEqualTo(roleId); + assertThat(rejected.get(0).getActorUserId()).isEqualTo(admin.getId()); + assertThat(rejected.get(0).getDetails()).contains("reason=Role is still assigned to users"); + assertThat(support.auditSince(base, "DELETE_ROLE")).isEmpty(); + } + + @Test + void _09_ShouldReturn422AndKeepTheRefusalRow_WhenDeletingAPermissionStillHeldByARole() throws Exception { + TestUser admin = support.createAdmin(); + long roleId = support.createRole("USER:READ"); + long permissionId = idOf(assertSuccess(support.post(admin.getJwt(), "/api/v1/permissions", + permissionBody("it_held", "read")), 201)); + assertSuccess(support.post(admin.getJwt(), "/api/v1/roles/" + roleId + "/permissions/" + permissionId, null), 200); + long base = support.auditBaseline(); + + JsonNode json = assertError(support.delete(admin.getJwt(), "/api/v1/permissions/" + permissionId), 422); + + assertThat(json.get("message").asText()).isEqualTo("Permission is still assigned to roles"); + assertThat(count("SELECT COUNT(*) FROM permissions WHERE id = ?", permissionId)).isOne(); + List rejected = support.auditSince(base, "REJECTED_DELETE_PERMISSION"); + assertThat(rejected).hasSize(1); + assertThat(rejected.get(0).getEntityId()).isEqualTo(permissionId); + assertThat(rejected.get(0).getActorUserId()).isEqualTo(admin.getId()); + assertThat(support.auditSince(base, "DELETE_PERMISSION")).isEmpty(); + } + + @Test + void _10_ShouldReturn422WithARefusalRowEach_WhenCreatingOrAssigningDuplicates() throws Exception { + TestUser admin = support.createAdmin(); + TestUser target = support.createSimpleUser(); + String roleName = RbacTestSupport.uniqueRoleName(); + long roleId = idOf(assertSuccess(support.post(admin.getJwt(), "/api/v1/roles", roleBody(roleName)), 201)); + long permissionId = idOf(assertSuccess(support.post(admin.getJwt(), "/api/v1/permissions", + permissionBody("it_dup", "read")), 201)); + assertSuccess(support.post(admin.getJwt(), "/api/v1/roles/" + roleId + "/permissions/" + permissionId, null), 200); + assertSuccess(support.patch(admin.getJwt(), "/api/v1/users/" + target.getId() + "/roles/" + roleId), 200); + long base = support.auditBaseline(); + + assertError(support.post(admin.getJwt(), "/api/v1/roles", roleBody(roleName)), 422); + // The same permission written in another case is the same permission (values are normalised) + assertError(support.post(admin.getJwt(), "/api/v1/permissions", permissionBody("IT_DUP", "READ")), 422); + assertError(support.post(admin.getJwt(), "/api/v1/roles/" + roleId + "/permissions/" + permissionId, null), 422); + assertError(support.patch(admin.getJwt(), "/api/v1/users/" + target.getId() + "/roles/" + roleId), 422); + + assertThat(actions(support.auditSince(base))).containsExactly("REJECTED_CREATE_ROLE", + "REJECTED_CREATE_PERMISSION", "REJECTED_ASSIGN_PERMISSION_TO_ROLE", "REJECTED_ASSIGN_ROLE_TO_USER"); + assertThat(support.auditSince(base)).allSatisfy(row -> + assertThat(row.getActorUserId()).isEqualTo(admin.getId())); + assertThat(count("SELECT COUNT(*) FROM roles WHERE role_name = ?", roleName)).isOne(); + assertThat(count("SELECT COUNT(*) FROM permissions WHERE resource = 'IT_DUP'")).isOne(); + } + + @Test + void _11_ShouldWriteNoAuditRow_WhenTheTargetDoesNotExist() throws Exception { + TestUser admin = support.createAdmin(); + TestUser simple = support.createSimpleUser(); + long roleId = support.createRole("USER:READ"); + long base = support.auditBaseline(); + String jwt = admin.getJwt(); + long missing = 999_999_999L; + + assertError(support.get(jwt, "/api/v1/users/" + missing), 404); + assertError(support.patch(jwt, "/api/v1/users/" + missing + "/roles/" + roleId), 404); + assertError(support.patch(jwt, "/api/v1/users/" + simple.getId() + "/roles/" + missing), 404); + assertError(support.delete(jwt, "/api/v1/users/" + missing + "/roles/" + roleId), 404); + // The role exists but is not assigned to this user + assertError(support.delete(jwt, "/api/v1/users/" + simple.getId() + "/roles/" + roleId), 404); + assertError(support.put(jwt, "/api/v1/roles/" + missing, roleBody("x")), 404); + assertError(support.delete(jwt, "/api/v1/roles/" + missing), 404); + assertError(support.post(jwt, "/api/v1/roles/" + missing + "/permissions/" + support.permissionId("USER:READ"), null), 404); + assertError(support.post(jwt, "/api/v1/roles/" + roleId + "/permissions/" + missing, null), 404); + // The permission exists but is not assigned to this role + assertError(support.delete(jwt, "/api/v1/roles/" + roleId + "/permissions/" + support.permissionId("USER:WRITE")), 404); + assertError(support.put(jwt, "/api/v1/permissions/" + missing, permissionBody("IT_X", "READ")), 404); + assertError(support.delete(jwt, "/api/v1/permissions/" + missing), 404); + + assertThat(support.auditSince(base)).isEmpty(); + } + + @Test + void _12_ShouldReturn400WithoutAnyAuditRow_WhenBodiesAreInvalid() throws Exception { + TestUser admin = support.createAdmin(); + long base = support.auditBaseline(); + + JsonNode role = assertError(support.post(admin.getJwt(), "/api/v1/roles", "{\"roleName\":\"\"}"), 400); + JsonNode permission = assertError(support.post(admin.getJwt(), "/api/v1/permissions", + permissionBody("bad-resource", "READ")), 400); + + assertThat(role.get("message").asText()).contains("roleName: "); + assertThat(permission.get("message").asText()).contains("resource: "); + assertThat(support.auditSince(base)).isEmpty(); + } + + // ---------------------------------------------------------------- GET /users paging and other HTTP errors + + @Test + void _13_ShouldReturn400NamingTheParameter_WhenPageOrSizeIsOutOfBounds() throws Exception { + TestUser admin = support.createAdmin(); + + JsonNode page = assertError(support.get(admin.getJwt(), "/api/v1/users?page=-1"), 400); + JsonNode zero = assertError(support.get(admin.getJwt(), "/api/v1/users?size=0"), 400); + JsonNode big = assertError(support.get(admin.getJwt(), "/api/v1/users?size=101"), 400); + JsonNode both = assertError(support.get(admin.getJwt(), "/api/v1/users?page=-1&size=0"), 400); + + assertThat(page.get("message").asText()).contains("page: "); + assertThat(zero.get("message").asText()).contains("size: "); + assertThat(big.get("message").asText()).contains("size: "); + assertThat(both.get("message").asText()).contains("page: ").contains("size: "); + } + + @Test + void _14_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + TestUser admin = support.createAdmin(); + + JsonNode json = assertSuccess(support.get(admin.getJwt(), "/api/v1/users?page=100000&size=10"), 200); + + JsonNode data = json.get("data"); + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("totalElements").asLong()).isGreaterThanOrEqualTo(1L); + assertThat(data.get("page").asInt()).isEqualTo(100000); + } + + @Test + void _15_ShouldReturnTheRequestedSlice_WhenSizeIsOne() throws Exception { + TestUser admin = support.createAdmin(); + support.createSimpleUser(); + + JsonNode data = assertSuccess(support.get(admin.getJwt(), "/api/v1/users?page=0&size=1"), 200).get("data"); + + assertThat(data.get("content")).hasSize(1); + assertThat(data.get("size").asInt()).isEqualTo(1); + assertThat(data.get("totalElements").asLong()).isGreaterThanOrEqualTo(2L); + assertThat(data.get("totalPages").asInt()).isGreaterThanOrEqualTo(2); + assertThat(data.get("content").get(0).has("password")).isFalse(); + } + + @Test + void _16_ShouldReturnRolesAndPermissionsAndNoPassword_WhenAdminReadsAnAdminUser() throws Exception { + TestUser admin = support.createAdmin(); + + JsonNode data = assertSuccess(support.get(admin.getJwt(), "/api/v1/users/" + admin.getId()), 200).get("data"); + + assertThat(data.get("roles").get(0).asText()).isEqualTo("ADMIN"); + assertThat(data.get("permissions")).hasSize(14); + assertThat(data.has("password")).isFalse(); + } + + @Test + void _17_ShouldReturn405And415AndBadRequestAsApiResponses_WhenTheRequestIsMalformed() throws Exception { + TestUser admin = support.createAdmin(); + + assertError(support.post(admin.getJwt(), "/api/v1/users", "{}"), 405); + MvcResult wrongType = mockMvc.perform(post("/api/v1/roles").header("Authorization", "Bearer " + admin.getJwt()) + .contentType(MediaType.TEXT_PLAIN).content("name")).andReturn(); + assertError(wrongType, 415); + assertError(support.get(admin.getJwt(), "/api/v1/users/not-a-number"), 400); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractRbacMvcTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractRbacMvcTest.java new file mode 100644 index 0000000..6c6a6a3 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractRbacMvcTest.java @@ -0,0 +1,98 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.mockito.Mockito.reset; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.auth.service.RbacService; +import com.edgareldy.springjdbctutorial.ws.support.JwtTestSupport; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; + +/** + * Common base of the RBAC controller tests: builds MockMvc on the shared RbacMvcTestConfig context, resets the + * mocked services before each test and offers request helpers (signed tokens, JSON bodies). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// MockMvc drives the DispatcherServlet without a real HTTP server. springSecurity() adds the +// springSecurityFilterChain bean of the context in front of it, exactly like the DelegatingFilterProxy does +// in Tomcat: without it the JWT filter and @PreAuthorize would be bypassed and every route would look open. +@SpringJUnitWebConfig(RbacMvcTestConfig.class) +abstract class AbstractRbacMvcTest { + + @Autowired + private WebApplicationContext context; + @Autowired + protected RbacService rbacService; + @Autowired + protected AuthService authService; + + protected MockMvc mockMvc; + + @BeforeEach + void setUpMockMvc() { + reset(rbacService, authService); + mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + } + + /** A signed dev-key JWT carrying exactly these permissions. */ + protected static String token(String... permissions) { + return JwtTestSupport.tokenWithPermissions(5L, List.of(permissions)); + } + + /** A signed dev-key JWT carrying every seeded permission except the given one. */ + protected static String tokenWithEverythingExcept(String permission) { + return JwtTestSupport.tokenWithPermissions(5L, + JwtTestSupport.ALL_PERMISSIONS.stream().filter(p -> !p.equals(permission)).toList()); + } + + protected static String bearer(String jwt) { + return "Bearer " + jwt; + } + + protected MvcResult call(MockHttpServletRequestBuilder request) throws Exception { + return mockMvc.perform(request).andReturn(); + } + + protected static MockHttpServletRequestBuilder getWith(String url, String jwt) { + return get(url).header("Authorization", bearer(jwt)); + } + + protected static MockHttpServletRequestBuilder deleteWith(String url, String jwt) { + return delete(url).header("Authorization", bearer(jwt)); + } + + protected static MockHttpServletRequestBuilder patchWith(String url, String jwt) { + return patch(url).header("Authorization", bearer(jwt)); + } + + protected static MockHttpServletRequestBuilder postWith(String url, String jwt) { + return post(url).header("Authorization", bearer(jwt)); + } + + protected static MockHttpServletRequestBuilder postJson(String url, String jwt, String body) { + return post(url).header("Authorization", bearer(jwt)).contentType(MediaType.APPLICATION_JSON).content(body); + } + + protected static MockHttpServletRequestBuilder putJson(String url, String jwt, String body) { + return put(url).header("Authorization", bearer(jwt)).contentType(MediaType.APPLICATION_JSON).content(body); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/PermissionControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/PermissionControllerTest.java new file mode 100644 index 0000000..0276ff6 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/PermissionControllerTest.java @@ -0,0 +1,166 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.dto.PermissionDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * MockMvc tests of PermissionController with RbacService mocked but the real security and validation: statuses, + * ApiResponse shape, per-field body validation (letters and underscore only) and the 422/404 refusals. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class PermissionControllerTest extends AbstractRbacMvcTest { + + private static final String VALID_BODY = "{\"resource\":\"invoice\",\"action\":\"read\"}"; + + private static PermissionDto permission(long id, String resource, String action) { + return new PermissionDto(id, resource, action); + } + + @Test + void _01_ShouldReturn200WithPermissions_WhenTokenHoldsPermissionRead() throws Exception { + when(rbacService.listPermissions()).thenReturn(List.of(permission(1L, "USER", "READ"))); + + JsonNode json = assertSuccess(call(getWith("/api/v1/permissions", token("PERMISSION:READ"))), 200); + + assertThat(json.get("data").get(0).get("id").asLong()).isEqualTo(1L); + assertThat(json.get("data").get(0).get("resource").asText()).isEqualTo("USER"); + assertThat(json.get("data").get(0).get("action").asText()).isEqualTo("READ"); + } + + @Test + void _02_ShouldReturn201WithCreatedPermission_WhenBodyIsValid() throws Exception { + when(rbacService.createPermission(any(PermissionDto.class))).thenReturn(permission(20L, "INVOICE", "READ")); + + JsonNode json = assertSuccess(call(postJson("/api/v1/permissions", token("PERMISSION:WRITE"), VALID_BODY)), 201); + + assertThat(json.get("data").get("resource").asText()).isEqualTo("INVOICE"); + assertThat(json.get("message").asText()).isEqualTo("Permission created"); + ArgumentCaptor sent = ArgumentCaptor.forClass(PermissionDto.class); + verify(rbacService).createPermission(sent.capture()); + assertThat(sent.getValue().getResource()).isEqualTo("invoice"); + assertThat(sent.getValue().getAction()).isEqualTo("read"); + } + + @Test + void _03_ShouldReturn400NamingBothFields_WhenResourceAndActionAreBlank() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/permissions", token("PERMISSION:WRITE"), + "{\"resource\":\"\",\"action\":\" \"}")), 400); + + assertThat(json.get("message").asText()).contains("resource: ").contains("action: "); + verifyNoInteractions(rbacService); + } + + @Test + void _04_ShouldReturn400NamingOnlyTheBadField_WhenResourceHasForbiddenCharacters() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/permissions", token("PERMISSION:WRITE"), + "{\"resource\":\"INVOICE-1\",\"action\":\"READ\"}")), 400); + + assertThat(json.get("message").asText()).contains("resource: ").doesNotContain("action: "); + verifyNoInteractions(rbacService); + } + + @Test + void _05_ShouldReturn400NamingTheField_WhenActionExceeds100Characters() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/permissions", token("PERMISSION:WRITE"), + "{\"resource\":\"INVOICE\",\"action\":\"" + "A".repeat(101) + "\"}")), 400); + + assertThat(json.get("message").asText()).contains("action: "); + } + + @Test + void _06_ShouldReturn400_WhenPermissionJsonIsMalformed() throws Exception { + assertError(call(postJson("/api/v1/permissions", token("PERMISSION:WRITE"), "not json")), 400); + } + + @Test + void _07_ShouldReturn422_WhenPermissionAlreadyExists() throws Exception { + when(rbacService.createPermission(any(PermissionDto.class))) + .thenThrow(new BusinessRuleException("Permission already exists")); + + JsonNode json = assertError(call(postJson("/api/v1/permissions", token("PERMISSION:WRITE"), VALID_BODY)), 422); + + assertThat(json.get("message").asText()).isEqualTo("Permission already exists"); + } + + @Test + void _08_ShouldReturn200WithUpdatedPermission_WhenBodyIsValid() throws Exception { + when(rbacService.updatePermission(anyLong(), any(PermissionDto.class))) + .thenReturn(permission(7L, "INVOICE", "READ")); + + JsonNode json = assertSuccess(call(putJson("/api/v1/permissions/7", token("PERMISSION:WRITE"), VALID_BODY)), 200); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(7L); + verify(rbacService).updatePermission(eq(7L), any(PermissionDto.class)); + } + + @Test + void _09_ShouldReturn400NamingTheFields_WhenUpdateBodyIsInvalid() throws Exception { + JsonNode json = assertError(call(putJson("/api/v1/permissions/7", token("PERMISSION:WRITE"), "{}")), 400); + + assertThat(json.get("message").asText()).contains("resource: ").contains("action: "); + verifyNoInteractions(rbacService); + } + + @Test + void _10_ShouldReturn404_WhenPermissionToUpdateDoesNotExist() throws Exception { + when(rbacService.updatePermission(anyLong(), any(PermissionDto.class))) + .thenThrow(new ResourceNotFoundException("Permission not found: 99")); + + assertError(call(putJson("/api/v1/permissions/99", token("PERMISSION:WRITE"), VALID_BODY)), 404); + } + + @Test + void _11_ShouldReturn422_WhenUpdateWouldStripRoleWriteFromTheLastAdmin() throws Exception { + when(rbacService.updatePermission(anyLong(), any(PermissionDto.class))) + .thenThrow(new BusinessRuleException("Cannot remove the last account holding ROLE:WRITE")); + + JsonNode json = assertError(call(putJson("/api/v1/permissions/7", token("PERMISSION:WRITE"), VALID_BODY)), 422); + + assertThat(json.get("message").asText()).contains("last account"); + } + + @Test + void _12_ShouldReturn200WithNoData_WhenPermissionIsDeleted() throws Exception { + JsonNode json = assertSuccess(call(deleteWith("/api/v1/permissions/7", token("PERMISSION:WRITE"))), 200); + + assertThat(json.get("message").asText()).isEqualTo("Permission deleted"); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + verify(rbacService).deletePermission(7L); + } + + @Test + void _13_ShouldReturn422_WhenPermissionIsStillAssignedToRoles() throws Exception { + doThrow(new BusinessRuleException("Permission is still assigned to roles")).when(rbacService).deletePermission(7L); + + JsonNode json = assertError(call(deleteWith("/api/v1/permissions/7", token("PERMISSION:WRITE"))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Permission is still assigned to roles"); + } + + @Test + void _14_ShouldReturn404_WhenPermissionToDeleteDoesNotExist() throws Exception { + doThrow(new ResourceNotFoundException("Permission not found: 99")).when(rbacService).deletePermission(99L); + + assertError(call(deleteWith("/api/v1/permissions/99", token("PERMISSION:WRITE"))), 404); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/RbacEndpointSecurityTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/RbacEndpointSecurityTest.java new file mode 100644 index 0000000..0887ae7 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/RbacEndpointSecurityTest.java @@ -0,0 +1,108 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * Permission matrix of the 14 RBAC endpoints of the README table, checked through the real filter chain and + * method security: no token gives 401, a token holding every permission EXCEPT the required one gives 403, both + * as ApiResponse errors and without ever reaching the (mocked) service. The 2xx paths with the right + * permission are asserted in the three controller tests. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class RbacEndpointSecurityTest extends AbstractRbacMvcTest { + + private static final String ROLE_BODY = "{\"roleName\":\"Reviewer\"}"; + private static final String PERMISSION_BODY = "{\"resource\":\"INVOICE\",\"action\":\"READ\"}"; + + // Valid bodies on purpose: request validation runs before the method security check, so an invalid body + // would answer 400 and hide the permission rule under test. + static Stream endpoints() { + return Stream.of( + Arguments.of(HttpMethod.GET, "/api/v1/users", null, "USER:READ"), + Arguments.of(HttpMethod.GET, "/api/v1/users/1", null, "USER:READ"), + Arguments.of(HttpMethod.PATCH, "/api/v1/users/1/roles/2", null, "USER:WRITE"), + Arguments.of(HttpMethod.DELETE, "/api/v1/users/1/roles/2", null, "USER:WRITE"), + Arguments.of(HttpMethod.GET, "/api/v1/roles", null, "ROLE:READ"), + Arguments.of(HttpMethod.POST, "/api/v1/roles", ROLE_BODY, "ROLE:WRITE"), + Arguments.of(HttpMethod.PUT, "/api/v1/roles/2", ROLE_BODY, "ROLE:WRITE"), + Arguments.of(HttpMethod.DELETE, "/api/v1/roles/2", null, "ROLE:WRITE"), + Arguments.of(HttpMethod.POST, "/api/v1/roles/2/permissions/3", null, "ROLE:WRITE"), + Arguments.of(HttpMethod.DELETE, "/api/v1/roles/2/permissions/3", null, "ROLE:WRITE"), + Arguments.of(HttpMethod.GET, "/api/v1/permissions", null, "PERMISSION:READ"), + Arguments.of(HttpMethod.POST, "/api/v1/permissions", PERMISSION_BODY, "PERMISSION:WRITE"), + Arguments.of(HttpMethod.PUT, "/api/v1/permissions/2", PERMISSION_BODY, "PERMISSION:WRITE"), + Arguments.of(HttpMethod.DELETE, "/api/v1/permissions/2", null, "PERMISSION:WRITE")); + } + + private static MockHttpServletRequestBuilder request(HttpMethod method, String url, String body, String jwt) { + MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.request(method, url); + if (jwt != null) { + builder.header("Authorization", bearer(jwt)); + } + if (body != null) { + builder.contentType(MediaType.APPLICATION_JSON).content(body); + } + return builder; + } + + @ParameterizedTest(name = "{0} {1}") + @MethodSource("endpoints") + void _01_ShouldReturn401ApiResponse_WhenNoTokenIsSent(HttpMethod method, String url, String body, + String required) throws Exception { + MvcResult result = call(request(method, url, body, null)); + + assertError(result, 401); + verifyNoInteractions(rbacService); + } + + @ParameterizedTest(name = "{0} {1} without {3}") + @MethodSource("endpoints") + void _02_ShouldReturn403ApiResponse_WhenTokenHoldsEveryPermissionExceptTheRequiredOne( + HttpMethod method, String url, String body, String required) throws Exception { + MvcResult result = call(request(method, url, body, tokenWithEverythingExcept(required))); + + JsonNode json = assertError(result, 403); + assertThat(json.get("message").asText()).isEqualTo("Access denied"); + verifyNoInteractions(rbacService); + } + + @ParameterizedTest(name = "{0} {1} with an empty token") + @MethodSource("endpoints") + void _03_ShouldReturn403ApiResponse_WhenTokenHoldsNoPermissionAtAll(HttpMethod method, String url, String body, + String required) throws Exception { + MvcResult result = call(request(method, url, body, token())); + + assertError(result, 403); + verifyNoInteractions(rbacService); + } + + @ParameterizedTest(name = "{0} {1} with a revoked token") + @MethodSource("endpoints") + void _04_ShouldReturn401_WhenTheTokenIsBlacklisted(HttpMethod method, String url, String body, + String required) throws Exception { + String jwt = token(required); + when(authService.isTokenBlacklisted(JwtService.sha256Hex(jwt))).thenReturn(true); + + assertError(call(request(method, url, body, jwt)), 401); + verifyNoInteractions(rbacService); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/RbacMvcTestConfig.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/RbacMvcTestConfig.java new file mode 100644 index 0000000..e9053bd --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/RbacMvcTestConfig.java @@ -0,0 +1,94 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.mockito.Mockito.mock; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.auth.service.RbacService; +import com.edgareldy.springjdbctutorial.ws.config.SecurityConfig; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.controller.PermissionController; +import com.edgareldy.springjdbctutorial.ws.controller.RoleController; +import com.edgareldy.springjdbctutorial.ws.controller.UserController; +import com.edgareldy.springjdbctutorial.ws.exception.GlobalExceptionHandler; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Clock; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.validation.Validator; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Shared test context of the RBAC controller tests: MVC, the REAL SecurityConfig (JWT filter, method security + * with CustomPermissionEvaluator, 401/403 handlers), the real controllers and advice, and Mockito mocks in + * place of RbacService and AuthService (no database). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// Kept in ws.mvc, never in ws.controller: WebMvcConfig component-scans ws.controller and ws.exception and +// would pick up this test configuration. Every controller test class points @SpringJUnitWebConfig at this +// class, so Spring's test context cache builds the context once and shares it; each test resets the mocks. +@Configuration +@EnableWebMvc +@Import(SecurityConfig.class) +public class RbacMvcTestConfig implements WebMvcConfigurer { + + private final WebMvcConfig real = new WebMvcConfig(); + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + + @Bean + ObjectMapper objectMapper() { + return real.objectMapper(); + } + + // The security filter chain needs an AuthService to ask "is this token blacklisted?" + @Bean + AuthService authService() { + return mock(AuthService.class); + } + + @Bean + RbacService rbacService() { + return mock(RbacService.class); + } + + @Bean + UserController userController(RbacService rbacService) { + return new UserController(rbacService); + } + + @Bean + RoleController roleController(RbacService rbacService) { + return new RoleController(rbacService); + } + + @Bean + PermissionController permissionController(RbacService rbacService) { + return new PermissionController(rbacService); + } + + @Bean + GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Override + public void extendMessageConverters(List> converters) { + real.extendMessageConverters(converters); + } + + @Override + public Validator getValidator() { + return real.getValidator(); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/RoleControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/RoleControllerTest.java new file mode 100644 index 0000000..acbde45 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/RoleControllerTest.java @@ -0,0 +1,242 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import com.edgareldy.springjdbctutorial.core.auth.dto.PermissionDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.RoleDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; + +/** + * MockMvc tests of RoleController with RbacService mocked but the real security and validation: statuses, + * ApiResponse shape, body validation per field, and the 422/404 mapping of the service refusals. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class RoleControllerTest extends AbstractRbacMvcTest { + + private static RoleDto role(long id, String name) { + return new RoleDto(id, name, List.of(new PermissionDto(7L, "ROLE", "WRITE"))); + } + + // ---------------------------------------------------------------- GET /roles + + @Test + void _01_ShouldReturn200WithRolesAndTheirPermissions_WhenTokenHoldsRoleRead() throws Exception { + when(rbacService.listRoles()).thenReturn(List.of(role(1L, "ADMIN"))); + + JsonNode json = assertSuccess(call(getWith("/api/v1/roles", token("ROLE:READ"))), 200); + + JsonNode first = json.get("data").get(0); + assertThat(first.get("id").asLong()).isEqualTo(1L); + assertThat(first.get("roleName").asText()).isEqualTo("ADMIN"); + assertThat(first.get("permissions").get(0).get("resource").asText()).isEqualTo("ROLE"); + assertThat(first.get("permissions").get(0).get("action").asText()).isEqualTo("WRITE"); + } + + @Test + void _02_ShouldReturn200WithEmptyArray_WhenThereIsNoRole() throws Exception { + when(rbacService.listRoles()).thenReturn(List.of()); + + JsonNode json = assertSuccess(call(getWith("/api/v1/roles", token("ROLE:READ"))), 200); + + assertThat(json.get("data").isArray()).isTrue(); + assertThat(json.get("data")).isEmpty(); + } + + // ---------------------------------------------------------------- POST /roles + + @Test + void _03_ShouldReturn201WithCreatedRole_WhenBodyIsValid() throws Exception { + when(rbacService.createRole(any(RoleDto.class))).thenReturn(role(9L, "Reviewer")); + + JsonNode json = assertSuccess(call(postJson("/api/v1/roles", token("ROLE:WRITE"), "{\"roleName\":\"Reviewer\"}")), 201); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(9L); + assertThat(json.get("message").asText()).isEqualTo("Role created"); + ArgumentCaptor sent = ArgumentCaptor.forClass(RoleDto.class); + verify(rbacService).createRole(sent.capture()); + assertThat(sent.getValue().getRoleName()).isEqualTo("Reviewer"); + } + + @Test + void _04_ShouldReturn400NamingTheField_WhenRoleNameIsBlankOrMissing() throws Exception { + JsonNode blank = assertError(call(postJson("/api/v1/roles", token("ROLE:WRITE"), "{\"roleName\":\" \"}")), 400); + JsonNode missing = assertError(call(postJson("/api/v1/roles", token("ROLE:WRITE"), "{}")), 400); + + assertThat(blank.get("message").asText()).contains("roleName: "); + assertThat(missing.get("message").asText()).contains("roleName: "); + verifyNoInteractions(rbacService); + } + + @Test + void _05_ShouldReturn400NamingTheField_WhenRoleNameExceeds100Characters() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/roles", token("ROLE:WRITE"), + "{\"roleName\":\"" + "x".repeat(101) + "\"}")), 400); + + assertThat(json.get("message").asText()).contains("roleName: "); + verifyNoInteractions(rbacService); + } + + @Test + void _06_ShouldReturn400_WhenRoleJsonIsMalformed() throws Exception { + assertError(call(postJson("/api/v1/roles", token("ROLE:WRITE"), "{\"roleName\":")), 400); + } + + @Test + void _07_ShouldReturn415_WhenRoleContentTypeIsNotJson() throws Exception { + assertError(call(post("/api/v1/roles").header("Authorization", bearer(token("ROLE:WRITE"))) + .contentType(MediaType.TEXT_PLAIN).content("Reviewer")), 415); + } + + @Test + void _08_ShouldReturn422_WhenRoleNameAlreadyExists() throws Exception { + when(rbacService.createRole(any(RoleDto.class))).thenThrow(new BusinessRuleException("Role name already exists")); + + JsonNode json = assertError(call(postJson("/api/v1/roles", token("ROLE:WRITE"), "{\"roleName\":\"ADMIN\"}")), 422); + + assertThat(json.get("message").asText()).isEqualTo("Role name already exists"); + } + + // ---------------------------------------------------------------- PUT /roles/{id} + + @Test + void _09_ShouldReturn200WithRenamedRole_WhenBodyIsValid() throws Exception { + when(rbacService.updateRole(anyLong(), any(RoleDto.class))).thenReturn(role(3L, "Editors")); + + JsonNode json = assertSuccess(call(putJson("/api/v1/roles/3", token("ROLE:WRITE"), "{\"roleName\":\"Editors\"}")), 200); + + assertThat(json.get("data").get("roleName").asText()).isEqualTo("Editors"); + ArgumentCaptor sent = ArgumentCaptor.forClass(RoleDto.class); + verify(rbacService).updateRole(eq(3L), sent.capture()); + assertThat(sent.getValue().getRoleName()).isEqualTo("Editors"); + } + + @Test + void _10_ShouldReturn400NamingTheField_WhenRenameBodyIsInvalid() throws Exception { + JsonNode json = assertError(call(putJson("/api/v1/roles/3", token("ROLE:WRITE"), "{\"roleName\":\"\"}")), 400); + + assertThat(json.get("message").asText()).contains("roleName: "); + verifyNoInteractions(rbacService); + } + + @Test + void _11_ShouldReturn404_WhenRoleToRenameDoesNotExist() throws Exception { + when(rbacService.updateRole(anyLong(), any(RoleDto.class))).thenThrow(new ResourceNotFoundException("Role not found: 99")); + + assertError(call(putJson("/api/v1/roles/99", token("ROLE:WRITE"), "{\"roleName\":\"X\"}")), 404); + } + + @Test + void _12_ShouldReturn422_WhenRenameCollidesWithAnotherRole() throws Exception { + when(rbacService.updateRole(anyLong(), any(RoleDto.class))).thenThrow(new BusinessRuleException("Role name already exists")); + + assertError(call(putJson("/api/v1/roles/3", token("ROLE:WRITE"), "{\"roleName\":\"ADMIN\"}")), 422); + } + + // ---------------------------------------------------------------- DELETE /roles/{id} + + @Test + void _13_ShouldReturn200WithNoData_WhenRoleIsDeleted() throws Exception { + JsonNode json = assertSuccess(call(deleteWith("/api/v1/roles/3", token("ROLE:WRITE"))), 200); + + assertThat(json.get("message").asText()).isEqualTo("Role deleted"); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + verify(rbacService).deleteRole(3L); + } + + @Test + void _14_ShouldReturn422_WhenRoleIsStillAssignedToUsers() throws Exception { + doThrow(new BusinessRuleException("Role is still assigned to users")).when(rbacService).deleteRole(3L); + + JsonNode json = assertError(call(deleteWith("/api/v1/roles/3", token("ROLE:WRITE"))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Role is still assigned to users"); + } + + @Test + void _15_ShouldReturn404_WhenRoleToDeleteDoesNotExist() throws Exception { + doThrow(new ResourceNotFoundException("Role not found: 99")).when(rbacService).deleteRole(99L); + + assertError(call(deleteWith("/api/v1/roles/99", token("ROLE:WRITE"))), 404); + } + + // ---------------------------------------------------------------- POST /roles/{id}/permissions/{permissionId} + + @Test + void _16_ShouldReturn200WithRole_WhenPermissionIsAssigned() throws Exception { + when(rbacService.assignPermissionToRole(3L, 7L)).thenReturn(role(3L, "EDITOR")); + + JsonNode json = assertSuccess(call(postWith("/api/v1/roles/3/permissions/7", token("ROLE:WRITE"))), 200); + + assertThat(json.get("message").asText()).isEqualTo("Permission assigned"); + assertThat(json.get("data").get("permissions")).hasSize(1); + } + + @Test + void _17_ShouldReturn422_WhenPermissionIsAlreadyAssigned() throws Exception { + when(rbacService.assignPermissionToRole(3L, 7L)) + .thenThrow(new BusinessRuleException("Permission is already assigned to this role")); + + assertError(call(postWith("/api/v1/roles/3/permissions/7", token("ROLE:WRITE"))), 422); + } + + @Test + void _18_ShouldReturn404_WhenRoleOrPermissionToAssignDoesNotExist() throws Exception { + when(rbacService.assignPermissionToRole(3L, 99L)).thenThrow(new ResourceNotFoundException("Permission not found: 99")); + + assertError(call(postWith("/api/v1/roles/3/permissions/99", token("ROLE:WRITE"))), 404); + } + + // ---------------------------------------------------------------- DELETE /roles/{id}/permissions/{permissionId} + + @Test + void _19_ShouldReturn200WithRole_WhenPermissionIsRemoved() throws Exception { + when(rbacService.removePermissionFromRole(3L, 7L)).thenReturn(role(3L, "EDITOR")); + + JsonNode json = assertSuccess(call(deleteWith("/api/v1/roles/3/permissions/7", token("ROLE:WRITE"))), 200); + + assertThat(json.get("message").asText()).isEqualTo("Permission removed"); + } + + @Test + void _20_ShouldReturn422_WhenRemovalWouldLeaveNoAdmin() throws Exception { + when(rbacService.removePermissionFromRole(3L, 7L)) + .thenThrow(new BusinessRuleException("Cannot remove the last account holding ROLE:WRITE")); + + JsonNode json = assertError(call(deleteWith("/api/v1/roles/3/permissions/7", token("ROLE:WRITE"))), 422); + + assertThat(json.get("message").asText()).contains("last account"); + } + + @Test + void _21_ShouldReturn404_WhenPermissionIsNotAssignedToTheRole() throws Exception { + when(rbacService.removePermissionFromRole(3L, 7L)) + .thenThrow(new ResourceNotFoundException("Permission 7 is not assigned to role 3")); + + assertError(call(deleteWith("/api/v1/roles/3/permissions/7", token("ROLE:WRITE"))), 404); + } + + @Test + void _22_ShouldReturn405ApiResponse_WhenPatchIsUsedOnRoles() throws Exception { + assertError(call(patchWith("/api/v1/roles/3", token("ROLE:WRITE"))), 405); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/UserControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/UserControllerTest.java new file mode 100644 index 0000000..0791025 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/UserControllerTest.java @@ -0,0 +1,235 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.edgareldy.springjdbctutorial.ws.support.JwtTestSupport; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MvcResult; + +/** + * MockMvc tests of UserController with RbacService mocked (no database) but the real security filter chain, + * method security and advice: status codes, ApiResponse shape, page/size validation and permissions. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class UserControllerTest extends AbstractRbacMvcTest { + + private static UserDto user(long id) { + return new UserDto(id, "Alice", "Martin", "alice@example.com", "must-not-leak", true, false, + List.of("ADMIN"), List.of("ROLE:WRITE")); + } + + // ---------------------------------------------------------------- GET /users + + @Test + void _01_ShouldReturn200WithPageShape_WhenTokenHoldsUserRead() throws Exception { + when(rbacService.listUsers(0, 20)).thenReturn(new PageDto<>(List.of(user(1L)), 0, 20, 1L)); + + JsonNode json = assertSuccess(call(getWith("/api/v1/users", token("USER:READ"))), 200); + + JsonNode data = json.get("data"); + assertThat(data.get("content")).hasSize(1); + assertThat(data.get("content").get(0).get("email").asText()).isEqualTo("alice@example.com"); + assertThat(data.get("content").get(0).get("roles").get(0).asText()).isEqualTo("ADMIN"); + assertThat(data.get("content").get(0).has("password")).isFalse(); + assertThat(data.get("page").asInt()).isZero(); + assertThat(data.get("size").asInt()).isEqualTo(20); + assertThat(data.get("totalElements").asLong()).isEqualTo(1L); + assertThat(data.get("totalPages").asInt()).isEqualTo(1); + } + + @Test + void _02_ShouldForwardRequestedPageAndSize_WhenParametersAreGiven() throws Exception { + when(rbacService.listUsers(2, 5)).thenReturn(new PageDto<>(List.of(), 2, 5, 11L)); + + JsonNode json = assertSuccess(call(getWith("/api/v1/users?page=2&size=5", token("USER:READ"))), 200); + + verify(rbacService).listUsers(2, 5); + assertThat(json.get("data").get("totalPages").asInt()).isEqualTo(3); + } + + @Test + void _03_ShouldKeepEmptyContentArrayInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + when(rbacService.listUsers(99, 10)).thenReturn(new PageDto<>(List.of(), 99, 10, 3L)); + + JsonNode json = assertSuccess(call(getWith("/api/v1/users?page=99&size=10", token("USER:READ"))), 200); + + JsonNode content = json.get("data").get("content"); + assertThat(content).isNotNull(); + assertThat(content.isArray()).isTrue(); + assertThat(content).isEmpty(); + assertThat(json.get("data").get("totalElements").asLong()).isEqualTo(3L); + } + + @Test + void _04_ShouldReturn400NamingThePageParameter_WhenPageIsNegative() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/users?page=-1", token("USER:READ"))), 400); + + assertThat(json.get("message").asText()).contains("page: ").doesNotContain("size: "); + verifyNoInteractions(rbacService); + } + + @Test + void _05_ShouldReturn400NamingTheSizeParameter_WhenSizeIsZero() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/users?size=0", token("USER:READ"))), 400); + + assertThat(json.get("message").asText()).contains("size: ").doesNotContain("page: "); + verifyNoInteractions(rbacService); + } + + @Test + void _06_ShouldReturn400NamingTheSizeParameter_WhenSizeIs101() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/users?size=101", token("USER:READ"))), 400); + + assertThat(json.get("message").asText()).contains("size: "); + verifyNoInteractions(rbacService); + } + + @Test + void _07_ShouldReturn400WithBothParameters_WhenPageAndSizeAreInvalid() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/users?page=-5&size=500", token("USER:READ"))), 400); + + assertThat(json.get("message").asText()).contains("page: ").contains("size: "); + } + + @Test + void _08_ShouldAcceptTheBounds_WhenSizeIsOneAndOneHundred() throws Exception { + when(rbacService.listUsers(0, 1)).thenReturn(new PageDto<>(List.of(), 0, 1, 0L)); + when(rbacService.listUsers(0, 100)).thenReturn(new PageDto<>(List.of(), 0, 100, 0L)); + + assertSuccess(call(getWith("/api/v1/users?size=1", token("USER:READ"))), 200); + assertSuccess(call(getWith("/api/v1/users?size=100", token("USER:READ"))), 200); + } + + @Test + void _09_ShouldReturn400_WhenPageIsNotANumber() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/users?page=abc", token("USER:READ"))), 400); + + assertThat(json.get("message").asText()).isEqualTo("Invalid value for parameter: page"); + } + + // ---------------------------------------------------------------- GET /users/{id} + + @Test + void _10_ShouldReturn200WithRolesAndNoPassword_WhenUserExists() throws Exception { + when(rbacService.getUser(1L)).thenReturn(user(1L)); + + JsonNode json = assertSuccess(call(getWith("/api/v1/users/1", token("USER:READ"))), 200); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(1L); + assertThat(json.get("data").get("roles").get(0).asText()).isEqualTo("ADMIN"); + assertThat(json.get("data").get("permissions").get(0).asText()).isEqualTo("ROLE:WRITE"); + assertThat(json.get("data").has("password")).isFalse(); + } + + @Test + void _11_ShouldReturn404ApiResponse_WhenUserDoesNotExist() throws Exception { + when(rbacService.getUser(99L)).thenThrow(new ResourceNotFoundException("User not found: 99")); + + JsonNode json = assertError(call(getWith("/api/v1/users/99", token("USER:READ"))), 404); + + assertThat(json.get("message").asText()).isEqualTo("User not found: 99"); + } + + @Test + void _12_ShouldReturn400_WhenUserIdIsNotANumber() throws Exception { + assertError(call(getWith("/api/v1/users/abc", token("USER:READ"))), 400); + } + + // ---------------------------------------------------------------- PATCH /users/{id}/roles/{roleId} + + @Test + void _13_ShouldReturn200_WhenTokenHoldsUserWriteAndRoleIsAssigned() throws Exception { + when(rbacService.assignRoleToUser(1L, 3L)).thenReturn(user(1L)); + + JsonNode json = assertSuccess(call(patchWith("/api/v1/users/1/roles/3", token("USER:WRITE"))), 200); + + assertThat(json.get("message").asText()).isEqualTo("Role assigned"); + assertThat(json.get("data").get("id").asLong()).isEqualTo(1L); + verify(rbacService).assignRoleToUser(1L, 3L); + } + + @Test + void _14_ShouldReturn422_WhenRoleIsAlreadyAssigned() throws Exception { + when(rbacService.assignRoleToUser(1L, 3L)) + .thenThrow(new BusinessRuleException("Role is already assigned to this user")); + + JsonNode json = assertError(call(patchWith("/api/v1/users/1/roles/3", token("USER:WRITE"))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Role is already assigned to this user"); + } + + @Test + void _15_ShouldReturn404_WhenUserOrRoleToAssignDoesNotExist() throws Exception { + when(rbacService.assignRoleToUser(99L, 3L)).thenThrow(new ResourceNotFoundException("User not found: 99")); + + assertError(call(patchWith("/api/v1/users/99/roles/3", token("USER:WRITE"))), 404); + } + + // ---------------------------------------------------------------- DELETE /users/{id}/roles/{roleId} + + @Test + void _16_ShouldReturn200_WhenTokenHoldsUserWriteAndRoleIsRemoved() throws Exception { + when(rbacService.removeRoleFromUser(1L, 3L)).thenReturn(user(1L)); + + JsonNode json = assertSuccess(call(deleteWith("/api/v1/users/1/roles/3", token("USER:WRITE"))), 200); + + assertThat(json.get("message").asText()).isEqualTo("Role removed"); + verify(rbacService).removeRoleFromUser(1L, 3L); + } + + @Test + void _17_ShouldReturn422_WhenRemovalWouldLeaveNoAdmin() throws Exception { + when(rbacService.removeRoleFromUser(1L, 3L)) + .thenThrow(new BusinessRuleException("Cannot remove the last account holding ROLE:WRITE")); + + JsonNode json = assertError(call(deleteWith("/api/v1/users/1/roles/3", token("USER:WRITE"))), 422); + + assertThat(json.get("message").asText()).contains("last account"); + } + + @Test + void _18_ShouldReturn404_WhenRoleIsNotAssignedToTheUser() throws Exception { + when(rbacService.removeRoleFromUser(1L, 3L)) + .thenThrow(new ResourceNotFoundException("Role 3 is not assigned to user 1")); + + assertError(call(deleteWith("/api/v1/users/1/roles/3", token("USER:WRITE"))), 404); + } + + // ---------------------------------------------------------------- errors common to the routes + + @Test + void _19_ShouldReturn500WithGenericMessage_WhenServiceThrowsAnUnexpectedException() throws Exception { + when(rbacService.getUser(1L)).thenThrow(new IllegalStateException("secret internal detail")); + + JsonNode json = assertError(call(getWith("/api/v1/users/1", token("USER:READ"))), 500); + + assertThat(json.get("message").asText()).isEqualTo("Internal server error"); + assertThat(json.toString()).doesNotContain("secret internal detail"); + } + + @Test + void _20_ShouldReturn405ApiResponse_WhenAnUnsupportedMethodIsUsedOnTheCollection() throws Exception { + MvcResult result = call(postWith("/api/v1/users", token(allPermissions()))); + + assertError(result, 405); + } + + private static String[] allPermissions() { + return JwtTestSupport.ALL_PERMISSIONS.toArray(new String[0]); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/CustomPermissionEvaluatorTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/CustomPermissionEvaluatorTest.java new file mode 100644 index 0000000..bc5cc92 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/CustomPermissionEvaluatorTest.java @@ -0,0 +1,117 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +/** + * Unit tests of CustomPermissionEvaluator on hand-built Authentication objects (no Spring context, no mocks). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class CustomPermissionEvaluatorTest { + + private final CustomPermissionEvaluator evaluator = new CustomPermissionEvaluator(); + + // The 3-argument constructor of UsernamePasswordAuthenticationToken marks the token as authenticated, + // like JwtAuthFilter does once the JWT is verified + private static Authentication authenticated(String... authorities) { + return new UsernamePasswordAuthenticationToken("user", null, AuthorityUtils.createAuthorityList(authorities)); + } + + @Test + void _01_ShouldAllow_WhenAuthorityMatchesResourceAndAction() { + assertThat(evaluator.hasPermission(authenticated("USER:READ", "ROLE:WRITE"), "ROLE", "WRITE")).isTrue(); + } + + @Test + void _02_ShouldDeny_WhenAuthorityIsMissing() { + assertThat(evaluator.hasPermission(authenticated("USER:READ"), "ROLE", "WRITE")).isFalse(); + } + + @Test + void _03_ShouldDenyWrite_WhenOnlyReadIsGranted() { + assertThat(evaluator.hasPermission(authenticated("USER:READ"), "USER", "WRITE")).isFalse(); + } + + @Test + void _04_ShouldDenyOtherResource_WhenSameActionIsGrantedOnAnotherResource() { + assertThat(evaluator.hasPermission(authenticated("ROLE:WRITE"), "USER", "WRITE")).isFalse(); + } + + @Test + void _05_ShouldAllow_WhenCaseOrSurroundingSpacesDiffer() { + assertThat(evaluator.hasPermission(authenticated("user:read"), "USER", "READ")).isTrue(); + assertThat(evaluator.hasPermission(authenticated("USER:READ"), " user ", " Read ")).isTrue(); + } + + @Test + void _06_ShouldDeny_WhenAuthenticationIsNull() { + assertThat(evaluator.hasPermission(null, "USER", "READ")).isFalse(); + } + + @Test + void _07_ShouldDeny_WhenAuthenticationIsAnonymousEvenWithAMatchingAuthority() { + Authentication anonymous = new AnonymousAuthenticationToken("key", "anonymousUser", + AuthorityUtils.createAuthorityList("USER:READ")); + + assertThat(evaluator.hasPermission(anonymous, "USER", "READ")).isFalse(); + } + + @Test + void _08_ShouldDeny_WhenAuthenticationIsNotAuthenticated() { + // The 2-argument constructor builds an UNauthenticated token, even if authorities were added later + Authentication notAuthenticated = new UsernamePasswordAuthenticationToken("user", "credentials"); + + assertThat(evaluator.hasPermission(notAuthenticated, "USER", "READ")).isFalse(); + } + + @Test + void _09_ShouldDeny_WhenTargetOrPermissionIsNotAString() { + Authentication auth = authenticated("USER:READ"); + + assertThat(evaluator.hasPermission(auth, 42L, "READ")).isFalse(); + assertThat(evaluator.hasPermission(auth, "USER", 42L)).isFalse(); + assertThat(evaluator.hasPermission(auth, new Object(), new Object())).isFalse(); + } + + @Test + void _10_ShouldDeny_WhenTargetOrPermissionIsNull() { + Authentication auth = authenticated("USER:READ"); + + assertThat(evaluator.hasPermission(auth, null, "READ")).isFalse(); + assertThat(evaluator.hasPermission(auth, "USER", null)).isFalse(); + } + + @Test + void _11_ShouldDenyWithoutThrowing_WhenAnAuthorityHasANullValue() { + GrantedAuthority nullAuthority = () -> null; + Authentication auth = new UsernamePasswordAuthenticationToken("user", null, + List.of(nullAuthority, new SimpleGrantedAuthority("ROLE:READ"))); + + assertThat(evaluator.hasPermission(auth, "USER", "READ")).isFalse(); + assertThat(evaluator.hasPermission(auth, "ROLE", "READ")).isTrue(); + } + + @Test + void _12_ShouldDeny_WhenThereIsNoAuthority() { + assertThat(evaluator.hasPermission(authenticated(), "USER", "READ")).isFalse(); + } + + @Test + void _13_ShouldAlwaysDeny_WhenUsingTheIdAndTypeOverload() { + Authentication auth = authenticated("USER:READ"); + + assertThat(evaluator.hasPermission(auth, 1L, "USER", "READ")).isFalse(); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/SecurityContextActorProviderTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/SecurityContextActorProviderTest.java new file mode 100644 index 0000000..49e2de3 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/SecurityContextActorProviderTest.java @@ -0,0 +1,62 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Unit tests of SecurityContextActorProvider: the actor id comes from the AuthenticatedUser principal of the + * SecurityContext, and is null whenever nobody real is authenticated. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class SecurityContextActorProviderTest { + + private final SecurityContextActorProvider provider = new SecurityContextActorProvider(); + + // SecurityContextHolder is thread-local static state: always clear it so no test leaks into another + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void _01_ShouldReturnTheUserId_WhenAnAuthenticatedUserIsInTheContext() { + AuthenticatedUser principal = new AuthenticatedUser(42L, "a@example.com", "jti", "hash", + Instant.now(), Instant.now().plusSeconds(60)); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, AuthorityUtils.NO_AUTHORITIES)); + + assertThat(provider.currentUserId()).isEqualTo(42L); + } + + @Test + void _02_ShouldReturnNull_WhenThereIsNoAuthentication() { + assertThat(provider.currentUserId()).isNull(); + } + + @Test + void _03_ShouldReturnNull_WhenTheAuthenticationIsAnonymous() { + SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken("key", + "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); + + assertThat(provider.currentUserId()).isNull(); + } + + @Test + void _04_ShouldReturnNull_WhenThePrincipalIsNotAnAuthenticatedUser() { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("just-a-name", null, AuthorityUtils.NO_AUTHORITIES)); + + assertThat(provider.currentUserId()).isNull(); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/JwtTestSupport.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/JwtTestSupport.java index 3c12ef6..bd63e17 100644 --- a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/JwtTestSupport.java +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/JwtTestSupport.java @@ -42,6 +42,22 @@ public static JwtService devJwtService(Clock clock) { 3600L, ISSUER, clock); } + /** Every permission of the V2 seed, in "RESOURCE:ACTION" form. */ + public static final List ALL_PERMISSIONS = List.of( + "USER:READ", "USER:WRITE", "ROLE:READ", "ROLE:WRITE", "PERMISSION:READ", "PERMISSION:WRITE", + "CATEGORY:READ", "CATEGORY:WRITE", "PRODUCT:READ", "PRODUCT:WRITE", "CUSTOMER:READ", "CUSTOMER:WRITE", + "ORDER:READ", "ORDER:WRITE"); + + /** + * A genuine token signed with the dev private key, carrying exactly the given permissions in its claim. + * The controller tests use it instead of a login: the mocked service layer has no database to log into. + */ + public static String tokenWithPermissions(long userId, List permissions) { + com.edgareldy.springjdbctutorial.core.auth.dto.UserDto user = new com.edgareldy.springjdbctutorial.core.auth.dto.UserDto( + userId, "Test", "User", "user-" + userId + "@example.com", null, true, false, List.of(), permissions); + return devJwtService(Clock.systemUTC()).issue(user).getToken(); + } + /** A fresh 2048 bit RSA key pair, never related to the dev keys. */ public static KeyPair generateKeyPair() { try { diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/RbacTestSupport.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/RbacTestSupport.java new file mode 100644 index 0000000..3efe81d --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/RbacTestSupport.java @@ -0,0 +1,281 @@ +package com.edgareldy.springjdbctutorial.ws.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * Fixtures of the RBAC integration tests, on top of AuthTestSupport: admin and simple users whose JWT comes from + * a REAL login, custom roles, audit trail queries and ISOLATION of the global last-admin state. All tests of a + * JVM share one database, and the last-admin rule counts every enabled, unlocked ROLE:WRITE holder, so a test + * first calls isolateLastAdminState() (which disables the other holders) and close() restores everything: + * the users and roles it created are deleted and the disabled holders are enabled again. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public final class RbacTestSupport implements AutoCloseable { + + /** Every role created by these tests starts with this prefix, which is how close() finds them. */ + public static final String ROLE_PREFIX = "it-"; + /** Every permission created by these tests has a resource starting with this prefix (no seeded resource does). */ + public static final String PERMISSION_PREFIX = "IT"; + + /** A user created by the fixtures: its database id, email and the JWT of its last login (null if it cannot log in). */ + public static final class TestUser { + private final long id; + private final String email; + private String jwt; + + TestUser(long id, String email, String jwt) { + this.id = id; + this.email = email; + this.jwt = jwt; + } + + public long getId() { + return id; + } + + public String getEmail() { + return email; + } + + public String getJwt() { + return jwt; + } + } + + /** One row of audit_logs, reduced to what the tests assert. */ + public static final class AuditRow { + private final String action; + private final String entityType; + private final Long entityId; + private final Long actorUserId; + private final String details; + + AuditRow(String action, String entityType, Long entityId, Long actorUserId, String details) { + this.action = action; + this.entityType = entityType; + this.entityId = entityId; + this.actorUserId = actorUserId; + this.details = details; + } + + public String getAction() { + return action; + } + + public String getEntityType() { + return entityType; + } + + public Long getEntityId() { + return entityId; + } + + public Long getActorUserId() { + return actorUserId; + } + + public String getDetails() { + return details; + } + + @Override + public String toString() { + return action + "/" + entityType + "/" + entityId + "/actor=" + actorUserId; + } + } + + private final MockMvc mockMvc; + private final JdbcTemplate jdbc; + private final AuthTestSupport auth; + // Cost 4 keeps user creation fast; the login endpoint verifies with whatever cost the hash carries + private final String passwordHash = new BCryptPasswordEncoder(4).encode(AuthTestSupport.PASSWORD); + private final List createdUserIds = new ArrayList<>(); + private final List disabledHolderIds = new ArrayList<>(); + + public RbacTestSupport(MockMvc mockMvc, JdbcTemplate jdbc) { + this.mockMvc = mockMvc; + this.jdbc = jdbc; + this.auth = new AuthTestSupport(mockMvc); + } + + // ------------------------------------------------------------------ isolation + + /** + * Disables every enabled, unlocked ROLE:WRITE holder currently in the database, so the test controls exactly + * who counts for the last-admin rule. close() enables them again. + */ + public void isolateLastAdminState() { + List holders = jdbc.queryForList( + "SELECT DISTINCT u.id FROM users u JOIN role_user ru ON ru.user_id = u.id " + + "JOIN role_permission rp ON rp.role_id = ru.role_id " + + "JOIN permissions p ON p.id = rp.permission_id " + + "WHERE u.enabled = TRUE AND u.account_locked = FALSE " + + "AND p.resource = 'ROLE' AND p.action = 'WRITE'", Long.class); + for (Long id : holders) { + jdbc.update("UPDATE users SET enabled = FALSE WHERE id = ?", id); + disabledHolderIds.add(id); + } + } + + /** Deletes what the test created and re-enables the holders that isolateLastAdminState() disabled. */ + @Override + public void close() { + try { + for (Long id : createdUserIds) { + // role_user, tokens and blacklist rows go with the user by cascade + jdbc.update("DELETE FROM users WHERE id = ?", id); + } + jdbc.update("DELETE FROM roles WHERE role_name LIKE ?", ROLE_PREFIX + "%"); + jdbc.update("DELETE FROM role_permission WHERE permission_id IN " + + "(SELECT id FROM permissions WHERE resource LIKE ?)", PERMISSION_PREFIX + "%"); + jdbc.update("DELETE FROM permissions WHERE resource LIKE ?", PERMISSION_PREFIX + "%"); + } finally { + for (Long id : disabledHolderIds) { + jdbc.update("UPDATE users SET enabled = TRUE WHERE id = ?", id); + } + auth.close(); + } + } + + // ------------------------------------------------------------------ users and roles + + public long adminRoleId() { + return jdbc.queryForObject("SELECT id FROM roles WHERE role_name = 'ADMIN'", Long.class); + } + + public long permissionId(String code) { + return jdbc.queryForObject("SELECT id FROM permissions WHERE resource || ':' || action = ?", Long.class, code); + } + + /** A role named it-... holding the given "RESOURCE:ACTION" permissions, inserted directly in the database. */ + public long createRole(String... permissionCodes) { + long id = jdbc.queryForObject("INSERT INTO roles (role_name) VALUES (?) RETURNING id", Long.class, + uniqueRoleName()); + for (String code : permissionCodes) { + int rows = jdbc.update("INSERT INTO role_permission (role_id, permission_id) " + + "SELECT ?, id FROM permissions WHERE resource || ':' || action = ?", id, code); + assertThat(rows).as("permission " + code + " exists").isEqualTo(1); + } + return id; + } + + public static String uniqueRoleName() { + return ROLE_PREFIX + UUID.randomUUID().toString().substring(0, 8); + } + + /** + * Inserts a user, optionally gives it a role, and logs it in through the REAL login endpoint when the account is + * enabled and unlocked (so the JWT carries the permissions of the role at this very moment). + */ + public TestUser createUser(boolean enabled, boolean locked, Long roleId) throws Exception { + String email = AuthTestSupport.uniqueEmail("rbac"); + long id = jdbc.queryForObject("INSERT INTO users (first_name, last_name, email, password, enabled, " + + "account_locked) VALUES ('Test', 'User', ?, ?, ?, ?) RETURNING id", Long.class, + email, passwordHash, enabled, locked); + createdUserIds.add(id); + if (roleId != null) { + jdbc.update("INSERT INTO role_user (role_id, user_id) VALUES (?, ?)", roleId, id); + } + String jwt = enabled && !locked ? auth.loginToken(email, AuthTestSupport.PASSWORD) : null; + return new TestUser(id, email, jwt); + } + + /** An enabled user holding the seeded ADMIN role (all 14 permissions), logged in. */ + public TestUser createAdmin() throws Exception { + return createUser(true, false, adminRoleId()); + } + + /** An enabled user with no role at all (no permission), logged in. */ + public TestUser createSimpleUser() throws Exception { + return createUser(true, false, null); + } + + /** Logs the user in again and returns a NEW jwt built from its CURRENT roles. */ + public String freshToken(TestUser user) throws Exception { + user.jwt = auth.loginToken(user.email, AuthTestSupport.PASSWORD); + return user.jwt; + } + + public void assignRoleInDb(long userId, long roleId) { + jdbc.update("INSERT INTO role_user (role_id, user_id) VALUES (?, ?)", roleId, userId); + } + + public long countLastAdminCandidates() { + return jdbc.queryForObject("SELECT COUNT(DISTINCT u.id) FROM users u " + + "JOIN role_user ru ON ru.user_id = u.id JOIN role_permission rp ON rp.role_id = ru.role_id " + + "JOIN permissions p ON p.id = rp.permission_id WHERE u.enabled = TRUE AND u.account_locked = FALSE " + + "AND p.resource = 'ROLE' AND p.action = 'WRITE'", Long.class); + } + + // ------------------------------------------------------------------ audit trail + + /** Highest audit_logs id right now: rows created by the test have a greater id. */ + public long auditBaseline() { + return jdbc.queryForObject("SELECT COALESCE(MAX(id), 0) FROM audit_logs", Long.class); + } + + public List auditSince(long baseline) { + return jdbc.query("SELECT action, entity_type, entity_id, actor_user_id, details FROM audit_logs " + + "WHERE id > ? ORDER BY id", + (rs, n) -> new AuditRow(rs.getString("action"), rs.getString("entity_type"), + rs.getObject("entity_id", Long.class), rs.getObject("actor_user_id", Long.class), + rs.getString("details")), + baseline); + } + + public List auditSince(long baseline, String action) { + return auditSince(baseline).stream().filter(r -> r.getAction().equals(action)).toList(); + } + + // ------------------------------------------------------------------ requests + + public MvcResult get(String jwt, String url) throws Exception { + return send(jwt, MockMvcRequestBuilders.get(url), null); + } + + public MvcResult post(String jwt, String url, String jsonBody) throws Exception { + return send(jwt, MockMvcRequestBuilders.post(url), jsonBody); + } + + public MvcResult put(String jwt, String url, String jsonBody) throws Exception { + return send(jwt, MockMvcRequestBuilders.put(url), jsonBody); + } + + public MvcResult patch(String jwt, String url) throws Exception { + return send(jwt, MockMvcRequestBuilders.patch(url), null); + } + + public MvcResult delete(String jwt, String url) throws Exception { + return send(jwt, MockMvcRequestBuilders.delete(url), null); + } + + public MvcResult request(HttpMethod method, String jwt, String url) throws Exception { + return send(jwt, MockMvcRequestBuilders.request(method, url), null); + } + + private MvcResult send(String jwt, MockHttpServletRequestBuilder builder, String jsonBody) throws Exception { + if (jwt != null) { + builder.header("Authorization", "Bearer " + jwt); + } + if (jsonBody != null) { + builder.contentType(MediaType.APPLICATION_JSON).content(jsonBody); + } + return mockMvc.perform(builder).andReturn(); + } +}