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();
+ }
+}