From 766944afc7c5b383bd6e2c368c72c9f575af128c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 01/30] feat(common): seed the baseline permissions and the ADMIN role in Flyway V2 A data migration inserts the 14 permissions (USER, ROLE, PERMISSION, CATEGORY, PRODUCT, CUSTOMER and ORDER, each with READ and WRITE), an ADMIN role and the assignment of every permission to it. Without this seed nobody could ever hold ROLE:WRITE, so nobody could create the first assignment. Every insert is idempotent (ON CONFLICT DO NOTHING) and no user is seeded: the first administrator comes from the optional, configuration-driven bootstrap. --- .../V2__seed_permissions_and_admin_role.sql | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 core/common/src/main/resources/db/migration/V2__seed_permissions_and_admin_role.sql 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; From 9d7c4670ea11a425c7b186db7e6454dc0819e092 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 02/30] feat(common): add PageDto for paginated service results Services return dto types only, so a page of results needs a business-side container: PageDto carries the content, the page number, the size and the total number of elements, immutable and without any HTTP concern. The web layer converts it to its own PageResponse payload, keeping the core free of web types. --- .../core/common/dto/PageDto.java | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dto/PageDto.java 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 + + "}"; + } +} From 37aa991e97a9961bdc5aca71c24d7184f374609a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 03/30] feat(auth): add the Role, Permission and AuditLog entities Three more table-shaped classes documented with @Table, @Id and @Column: roles, permissions (a resource and an action, such as ROLE and WRITE) and the audit trail whose actor and entity id are nullable because a system action has no actor and a failed creation has no id yet. As for the other entities, the annotations are documentation only, no JPA provider reads them. --- .../core/auth/entity/AuditLog.java | 146 ++++++++++++++++++ .../core/auth/entity/Permission.java | 88 +++++++++++ .../core/auth/entity/Role.java | 74 +++++++++ 3 files changed, 308 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/AuditLog.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/Permission.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/Role.java 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 + + "}"; + } +} From 2a2889ef0f9958364cb965f0666e857bf6a12e70 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 04/30] feat(auth): add RoleDto, PermissionDto and AuditLogDto The business shapes returned by the RBAC service. RoleDto carries the permissions granted to the role as PermissionDto objects, which lets a role list show what each role can do without the web layer touching a DAO. They are separate classes from their entities, in line with the five-layer split. --- .../core/auth/dto/AuditLogDto.java | 134 ++++++++++++++++++ .../core/auth/dto/PermissionDto.java | 80 +++++++++++ .../core/auth/dto/RoleDto.java | 81 +++++++++++ 3 files changed, 295 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/AuditLogDto.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/PermissionDto.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/RoleDto.java 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 + + "}"; + } +} From 4b7a9c085dcd6c3a59f9e8aca07d53f08d7a87d3 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 05/30] feat(auth): add the RowMappers of roles, permissions and audit logs Each RowMapper converts one ResultSet row into an entity and nothing more. The audit log mapper reads actor_user_id and entity_id as nullable Long values, using getObject so that SQL NULL is not silently turned into 0 by getLong. The DAOs of the RBAC part use them through JdbcTemplate.query. --- .../core/auth/mapper/AuditLogRowMapper.java | 31 +++++++++++++++++++ .../core/auth/mapper/PermissionRowMapper.java | 26 ++++++++++++++++ .../core/auth/mapper/RoleRowMapper.java | 25 +++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogRowMapper.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionRowMapper.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleRowMapper.java 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/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/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")); + } +} From bb9b5aa89ed62c805a8ed52aa46511d04113aa87 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 06/30] feat(auth): add the entity mappers of roles, permissions and audit logs These mappers convert between entity and dto in both directions and are the only place where that happens for the RBAC types. The role mapper leaves the permission list empty: filling it needs a query, so the service does it. They hold no business logic and never query the database. --- .../core/auth/mapper/AuditLogMapper.java | 31 +++++++++++++++++ .../core/auth/mapper/PermissionMapper.java | 29 ++++++++++++++++ .../core/auth/mapper/RoleMapper.java | 34 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogMapper.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionMapper.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleMapper.java 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/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/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()); + } +} From be284fdd64ed4aee24a82ca25a858807a5ca0549 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 07/30] feat(auth): add the role, permission and audit log DAOs The DAOs own the SQL of the RBAC tables: CRUD for roles and permissions, the role_permission join rows (permissions are assigned onto a role, never the reverse), counts used by the referenced-delete rules, and an insert-only audit log DAO. They extend AbstractDao, work in entity types only and have no stereotype annotation. Duplicate keys surface as DuplicateKeyException, translated by JdbcTemplate itself, and the service turns them into business refusals. --- .../core/auth/dao/AuditLogDao.java | 22 +++++ .../core/auth/dao/PermissionDao.java | 34 +++++++ .../core/auth/dao/RoleDao.java | 48 +++++++++ .../core/auth/dao/impl/AuditLogDaoImpl.java | 46 +++++++++ .../core/auth/dao/impl/PermissionDaoImpl.java | 68 +++++++++++++ .../core/auth/dao/impl/RoleDaoImpl.java | 99 +++++++++++++++++++ 6 files changed, 317 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/AuditLogDao.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PermissionDao.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/RoleDao.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/AuditLogDaoImpl.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PermissionDaoImpl.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/RoleDaoImpl.java 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/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; + } +} From 6840f2965860c0e8d0f16cfea0c7c6be0f5c1b4a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 08/30] feat(auth): add user paging, role assignment and last-admin queries UserDao gains a LIMIT/OFFSET page query, the role_user assignment methods (roles are assigned onto a user) and countLastAdminCandidates, which counts distinct enabled and unlocked users holding ROLE:WRITE. acquireLastAdminLock runs pg_advisory_xact_lock: a transaction-scoped lock, released at commit or rollback, that serialises every operation able to remove the last administrator, so two concurrent requests cannot both pass the check. --- .../core/auth/dao/UserDao.java | 24 ++++++++++ .../core/auth/dao/impl/UserDaoImpl.java | 48 +++++++++++++++++++ 2 files changed, 72 insertions(+) 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/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); + } } From 95bde19599e5b17220e53101f78349225ab271dc Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 09/30] feat(auth): add AuditLogger with joined and independent transactions log joins the business transaction, so a success row disappears if the operation rolls back. logRejected uses REQUIRES_NEW: it suspends the running transaction and commits its own, so a refusal row survives the rollback that follows the refusal. Propagation is applied by the Spring proxy, so it only works when the caller goes through the interface bean, which is how RbacServiceImpl uses it. The actor comes from an ActorProvider interface that the web layer implements, so core never reads a SecurityContext. --- .../core/auth/service/ActorProvider.java | 15 +++++ .../core/auth/service/AuditLogger.java | 18 ++++++ .../auth/service/impl/AuditLoggerImpl.java | 58 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/ActorProvider.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/AuditLogger.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuditLoggerImpl.java 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/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())); + } +} From 450a87f333f881b6a431f25607c944dea778072c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 10/30] feat(auth): implement roles, permissions and assignments in RbacService RbacServiceImpl covers the CRUD of roles and permissions and the two assignment directions: permissions onto a role, roles onto a user. A role still held by a user, or a permission still held by a role, cannot be deleted. Every success calls the AuditLogger and every business refusal is audited in an independent transaction before the exception is thrown, while 404 cases are not audited. Operations that can remove the last ROLE:WRITE holder take the advisory lock as their very first statement, count the eligible administrators before and after the change, and refuse when the count drops from at least one to zero, which rolls the change back. The count reads database state, not live tokens. --- .../core/auth/service/RbacService.java | 55 +++ .../auth/service/impl/RbacServiceImpl.java | 389 ++++++++++++++++++ 2 files changed, 444 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/RbacService.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/RbacServiceImpl.java 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/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; + } +} From b731605e2301915b4adf2b5a524672c2b63acf15 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 11/30] feat(auth): wire the RBAC beans and the optional admin bootstrap DaoConfig and ServiceConfig declare the new DAOs, the AuditLogger and the RbacService as explicit beans typed by interface, so transactions apply through their proxies. Any context importing ServiceConfig must provide an ActorProvider. AdminBootstrap is driven only by app.bootstrap.admin-email and app.bootstrap.admin-password: when absent it does nothing, otherwise it creates that enabled user with the ADMIN role if the email is unknown. Every failure is caught and logged, so it can never stop the application from starting. It is meant for development only. --- .../core/auth/config/DaoConfig.java | 21 +++++ .../core/auth/config/ServiceConfig.java | 44 +++++++++- .../auth/service/impl/AdminBootstrap.java | 88 +++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AdminBootstrap.java 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/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; + } +} From b9a11694bc904576673ccca77e6866e08bc2434b Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 12/30] feat(ws): resolve permissions in CustomPermissionEvaluator A PermissionEvaluator answers hasPermission(target, permission) calls made from @PreAuthorize expressions. This one is the single place permissions are resolved: it checks whether the authenticated user holds the authority RESOURCE:ACTION, an authority list rebuilt from the JWT claim, so there is no database call per request. It never throws and is false for anonymous or null authentications. SecurityContextActorProvider gives the audit logger the id of the caller. --- .../security/CustomPermissionEvaluator.java | 56 +++++++++++++++++++ .../SecurityContextActorProvider.java | 27 +++++++++ 2 files changed, 83 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/CustomPermissionEvaluator.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/SecurityContextActorProvider.java 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; + } +} From b18a707d7c0a49b1dc759fa4a326ef48a7597532 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 13/30] feat(ws): enable method security backed by the permission evaluator @EnableMethodSecurity turns on @PreAuthorize through AOP proxies on the controller beans. The MethodSecurityExpressionHandler is a static @Bean method, so it is available early enough when Spring builds the method security advisors, and it registers CustomPermissionEvaluator so that hasPermission('ROLE','WRITE') is understood. The config also exposes the ActorProvider consumed by the core audit logger. --- .../ws/config/SecurityConfig.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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(); + } } From f25f7a9fe351505451b1641d647b49df961c366f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 14/30] feat(ws): add the RBAC payloads and converters RoleRequest and PermissionRequest carry Bean Validation constraints (not blank, bounded length, letters and underscores for a permission), and the responses expose the roles with their permissions. Converters map between the core dto classes and these payloads with static methods, and UserConverter learns to turn a PageDto into a PageResponse. Payload classes stay separate from core dto classes on purpose. --- .../ws/converter/PermissionConverter.java | 43 ++++++++++ .../ws/converter/RoleConverter.java | 40 ++++++++++ .../ws/converter/UserConverter.java | 12 +++ .../ws/payload/rbac/PermissionRequest.java | 72 +++++++++++++++++ .../ws/payload/rbac/PermissionResponse.java | 79 ++++++++++++++++++ .../ws/payload/rbac/RoleRequest.java | 56 +++++++++++++ .../ws/payload/rbac/RoleResponse.java | 80 +++++++++++++++++++ 7 files changed, 382 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/PermissionConverter.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/RoleConverter.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/PermissionRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/PermissionResponse.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/RoleRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/RoleResponse.java 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/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 + + "}"; + } +} From fea7e3b8cf283fca7dccc658b5c4b4421c5cccfc Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 15/30] feat(ws): expose the RBAC endpoints with declarative permissions The three controllers follow the README table exactly. Each method is guarded by @PreAuthorize("hasPermission('RESOURCE','ACTION')"), never a manual if, and only calls RbacService and the converters. The user list takes page and size parameters constrained with @Min and @Max, which Spring 6.1 method validation checks without any class-level annotation. Every answer is an ApiResponse, creations return 201 and assignments return the updated user or role. --- .../ws/controller/PermissionController.java | 67 ++++++++++++++++ .../ws/controller/RoleController.java | 79 +++++++++++++++++++ .../ws/controller/UserController.java | 65 +++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/PermissionController.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/RoleController.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/UserController.java 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"); + } +} From 3d0fcd0fd5e894618d40a217d4852bba15c0ffa6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 16/30] fix(ws): refine 401 versus 403 and name the invalid request parameters An AccessDeniedException raised by @PreAuthorize reaches the advice before the security filters, so the handler must decide between 401 and 403 itself: an anonymous or missing authentication now gets 401, an authenticated caller without the right gets 403. Constraint violations on request parameters now read "size: must be less than or equal to 100", naming the parameter like the "field: message" entries of body validation errors. --- .../ws/exception/GlobalExceptionHandler.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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..65a334a 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; @@ -73,8 +76,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 -> result.getMethodParameter().getParameterName() + ": " + + error.getDefaultMessage())) .sorted() .collect(Collectors.joining("; ")); return build(HttpStatus.BAD_REQUEST, message); @@ -142,9 +148,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"); } From 0945886e68a016985e4d10b101d567fc5e076009 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:06:17 +0400 Subject: [PATCH 17/30] test(common): assert the V2 seed exactly The migration test now checks that the seed created exactly 14 permissions (seven resources times READ and WRITE), one ADMIN role holding all of them, 14 role_permission rows in total, and no user and no role assignment. Pinning the exact count means a later branch that adds a seeded permission must update this test on purpose. --- .../common/config/FlywayMigrationTest.java | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) 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(); + } } From c8b8cf1df6882dfd5f8d5a0688ed838c41bee5ed Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:06:17 +0400 Subject: [PATCH 18/30] test(auth): cover the RBAC DAOs against PostgreSQL Role, permission, audit log and the new user DAO methods each run against a real database with their own dedicated fixture file, so the existing user fixture stays private to its own test class. They check duplicates, foreign-key blocked deletes, NULL actor and entity ids, page offsets and role assignments. countLastAdminCandidates is tested to count only enabled and unlocked holders of ROLE:WRITE, once per user, and the advisory lock is shown to block a second transaction until the first one commits. --- .../auth/dao/impl/AuditLogDaoImplTest.java | 98 +++++++++ .../auth/dao/impl/PermissionDaoImplTest.java | 123 +++++++++++ .../core/auth/dao/impl/RoleDaoImplTest.java | 173 +++++++++++++++ .../core/auth/dao/impl/UserDaoRbacTest.java | 201 ++++++++++++++++++ .../test/resources/audit-log-dao-dataset.sql | 12 ++ .../test/resources/permission-dao-dataset.sql | 15 ++ .../src/test/resources/role-dao-dataset.sql | 22 ++ .../test/resources/user-rbac-dao-dataset.sql | 32 +++ 8 files changed, 676 insertions(+) create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/AuditLogDaoImplTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PermissionDaoImplTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/RoleDaoImplTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoRbacTest.java create mode 100644 core/auth/src/test/resources/audit-log-dao-dataset.sql create mode 100644 core/auth/src/test/resources/permission-dao-dataset.sql create mode 100644 core/auth/src/test/resources/role-dao-dataset.sql create mode 100644 core/auth/src/test/resources/user-rbac-dao-dataset.sql 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/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; From 165033f4e9163dfc8cfa4bf3bdb3e2a7664f2e23 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:06:17 +0400 Subject: [PATCH 19/30] test(auth): test the RBAC RowMappers and entity mappers RowMapper tests use a mocked ResultSet, including the NULL actor, NULL entity id and NULL timestamp of audit rows, and the entity mappers are checked with hand-built objects in both directions. Neither needs a database, which keeps the mapping contract tests instant. --- .../core/auth/mapper/AuditLogMapperTest.java | 67 +++++++++++++++++ .../auth/mapper/AuditLogRowMapperTest.java | 74 +++++++++++++++++++ .../auth/mapper/PermissionMapperTest.java | 48 ++++++++++++ .../auth/mapper/PermissionRowMapperTest.java | 37 ++++++++++ .../core/auth/mapper/RoleMapperTest.java | 51 +++++++++++++ .../core/auth/mapper/RoleRowMapperTest.java | 37 ++++++++++ 6 files changed, 314 insertions(+) create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/AuditLogRowMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PermissionRowMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RoleRowMapperTest.java 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"); + } +} From aa721812e9c21796a865f99fdc4540507b084533 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:06:17 +0400 Subject: [PATCH 20/30] test(auth): unit test every RbacService operation with mocked DAOs Each mutation is checked for its audit call, each business refusal for a logRejected call made before the exception, and 404 cases for the absence of any audit call. An InOrder verification proves the advisory lock is taken before any lookup and that the eligible administrators are counted before and after the change. The last-admin refusal is covered from removePermissionFromRole, removeRoleFromUser and updatePermission, and only when the count really drops from at least one to zero. Duplicate-key and foreign-key races are shown to become business refusals. --- .../service/impl/RbacServiceImplTest.java | 929 ++++++++++++++++++ 1 file changed, 929 insertions(+) create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/RbacServiceImplTest.java 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()); + } +} From f5cf45dbdcd8a772fe5b4353a9d3d2ca6f4f9bfb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:06:17 +0400 Subject: [PATCH 21/30] test(auth): test AuditLoggerImpl and AdminBootstrap The audit logger tests cover the actor from the provider (including none), the REJECTED_ prefix, the clock and the propagation of each method. The bootstrap tests show that it does nothing without configuration, creates the admin with the ADMIN role, skips an existing email, and never throws even when the DAO, the encoder or the transaction manager fails. The last point is the contract that matters: a misconfigured bootstrap must never prevent the application from starting. --- .../auth/service/impl/AdminBootstrapTest.java | 201 ++++++++++++++++++ .../service/impl/AuditLoggerImplTest.java | 125 +++++++++++ 2 files changed, 326 insertions(+) create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AdminBootstrapTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuditLoggerImplTest.java 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); + } +} From 98aa7ca7dd075d7bb2ff767d62f20fdd06d739e2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:06:17 +0400 Subject: [PATCH 22/30] test(ws): add RBAC fixtures for token-based and database-backed tests JwtTestSupport can now sign a token that holds an arbitrary set of permissions, and RbacTestSupport creates users and roles, obtains JWTs through a real login and queries audit rows. Because all tests share one database, it also isolates the global last-admin state by disabling other ROLE:WRITE holders and restores everything it touched in close. The seeded ADMIN role is never modified by a test. --- .../ws/support/JwtTestSupport.java | 16 + .../ws/support/RbacTestSupport.java | 281 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/RbacTestSupport.java 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(); + } +} From c2ab81cd8bf6de89bf97891f5655f0c14403ace8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:06:17 +0400 Subject: [PATCH 23/30] test(ws): test CustomPermissionEvaluator and the actor provider The evaluator is checked for an allowed and a denied permission, case insensitivity, anonymous and null authentications, unknown target types and the id-based overload, which always answers false. The actor provider returns the user id of an AuthenticatedUser and null for anything else. Both are the security decision points of the project, so they are tested in isolation from the web layer. --- .../CustomPermissionEvaluatorTest.java | 117 ++++++++++++++++++ .../SecurityContextActorProviderTest.java | 62 ++++++++++ 2 files changed, 179 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/CustomPermissionEvaluatorTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/SecurityContextActorProviderTest.java 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(); + } +} From af363b3351f0fdb899c06f1228c86246c6ff5d20 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:06:17 +0400 Subject: [PATCH 24/30] test(ws): test the RBAC controllers with MockMvc The real SecurityConfig and a real signed JWT are used with a mocked RbacService. Each success test uses a token holding only the required permission, and the tests cover status codes and JSON shape, 404, 422, per-field 400 messages, 405, 415 and 500. The user list is checked for page and size violations reported as "size: ..." and for a page beyond the last one that keeps content as an empty array. --- .../ws/mvc/AbstractRbacMvcTest.java | 98 +++++++ .../ws/mvc/PermissionControllerTest.java | 166 ++++++++++++ .../ws/mvc/RbacMvcTestConfig.java | 94 +++++++ .../ws/mvc/RoleControllerTest.java | 242 ++++++++++++++++++ .../ws/mvc/UserControllerTest.java | 235 +++++++++++++++++ 5 files changed, 835 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractRbacMvcTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/PermissionControllerTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/RbacMvcTestConfig.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/RoleControllerTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/UserControllerTest.java 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/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]); + } +} From 8107daa61cc0e5b1d095c89c262bcc4abf7b45f6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:06:17 +0400 Subject: [PATCH 25/30] test(ws): check the permission matrix of the 14 RBAC endpoints A parameterized test walks every endpoint of the README table: no token gives 401, a token holding all other permissions gives 403 with the ApiResponse body, an empty token gives 403 and a blacklisted token gives 401, and the service is never reached in any of these cases. It proves that @PreAuthorize with the custom evaluator protects each route independently of the others. --- .../ws/mvc/RbacEndpointSecurityTest.java | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/RbacEndpointSecurityTest.java 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); + } +} From 65b1ddfd9032429951e8591966809aa04217b222 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:06:17 +0400 Subject: [PATCH 26/30] test(ws): run the RBAC flows on the real stack Real services, a real database and real logins prove that role and permission CRUD and assignments work, that every mutation writes a matching audit row with the actor, and that a role change only reaches the JWT at the next login. A deleteRole on a referenced role returns 422 and its REJECTED_ audit row still exists after the rollback, which proves REQUIRES_NEW works through the proxy without any stereotype. 404 and 400 cases write no audit row. --- .../integration/RbacFlowIntegrationTest.java | 455 ++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/RbacFlowIntegrationTest.java 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); + } +} From e2fcf7fb54c10ab9ad9239e9dae0050bd7ef0739 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:06:17 +0400 Subject: [PATCH 27/30] test(ws): prove the last-admin rule, including under concurrency The rule is checked in both directions (removing ROLE:WRITE from the only admin's role, and removing the role from the only admin user), for renaming the permission away, and for the cases where it must allow the change: a second enabled and unlocked holder, possibly through another role. A disabled or locked second admin does not count. A concurrency test releases two threads at once on two requests that would each remove one of the only two admins: in every round exactly one succeeds and the other gets 422, which is the advisory lock at work. --- .../LastAdminRuleIntegrationTest.java | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/LastAdminRuleIntegrationTest.java 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(); + }; + } +} From a2e1da5cea58c352395ba3fb7cf385365c642a46 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:06:17 +0400 Subject: [PATCH 28/30] test(ws): test the admin bootstrap on the real context With the bootstrap properties set, the admin exists with the ADMIN role and a bcrypt hash, can log in over HTTP with all 14 permissions and leaves a single BOOTSTRAP_ADMIN audit row with no actor. A second context with an invalid password still starts and creates no account. That last test is the guarantee that a bad bootstrap setting can never make the application fail to start. --- .../AdminBootstrapFailureIntegrationTest.java | 52 ++++++++ .../AdminBootstrapIntegrationTest.java | 112 ++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AdminBootstrapFailureIntegrationTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AdminBootstrapIntegrationTest.java 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); + } +} From 0aa9a435b2fcde90d3540c7bc97c7a54698520e8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:10:10 +0400 Subject: [PATCH 29/30] fix(ws): never print a null parameter name in validation messages The parameter name comes from compiled parameter metadata, which is empty when a class is built without the -parameters compiler flag, giving a message such as "null: must be greater than or equal to 1". The handler now falls back to the word "parameter" instead. The build sets -parameters, so the normal output is unchanged. --- .../ws/exception/GlobalExceptionHandler.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 65a334a..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 @@ -31,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; /** @@ -79,8 +80,8 @@ public ResponseEntity> handleMethodValidation(HandlerMethodVal // 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 -> result.getMethodParameter().getParameterName() + ": " - + error.getDefaultMessage())) + .map(error -> Objects.requireNonNullElse(result.getMethodParameter().getParameterName(), + "parameter") + ": " + error.getDefaultMessage())) .sorted() .collect(Collectors.joining("; ")); return build(HttpStatus.BAD_REQUEST, message); From 848397c80515b7ae41ed6a912f39dda352fe6632 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:10:10 +0400 Subject: [PATCH 30/30] docs(readme): tick the feature/rbac tasks and document the dev admin bootstrap All eight RBAC tasks are checked: the entities, dto classes, RowMappers and mappers, the DAOs, RbacService, AuditLogger, CustomPermissionEvaluator, the method security configuration, the V2 seed, the controllers and the tests at all layers, including the last-admin rule under concurrency. The development key README now explains the optional admin bootstrap variables, which are for local development only and never set for tests or production. --- README.md | 16 ++++++++-------- dev-keys/README.md | 13 +++++++++++++ 2 files changed, 21 insertions(+), 8 deletions(-) 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/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.