From 403886eb6c1d4800605b8fd80636f295a5b6ee6c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 001/144] build(pom): share test dependencies and expose the common test-jar Every module now inherits JUnit 5, Mockito, AssertJ and Spring Test with test scope from the parent, so a module pom only lists what is specific to it. The parent also manages the common test-jar, letting the DAO tests of each business module reuse the shared PostgreSQL Testcontainers helper instead of copying it. Testcontainers and the PostgreSQL driver stay out of the shared list on purpose: only the modules that really run DAO tests declare them. --- pom.xml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/pom.xml b/pom.xml index 70d26de..5d64409 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ 5.14.4 5.23.0 1.21.4 + 3.27.7 10.1.60 @@ -173,6 +174,18 @@ logback-classic ${logback.version} + + com.edgareldy.springjdbctutorial + common + ${project.version} + test-jar + test + + + org.assertj + assertj-core + ${assertj.version} + org.mockito mockito-core @@ -186,6 +199,31 @@ + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.springframework + spring-test + test + + + From b61861d66373469e219ad5ac50a6a52b4af2ebfe Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 002/144] build(core): declare the auth, catalog, customer and order modules The core aggregator now lists every business module of the README. Each one starts as an empty jar that depends only on common; order additionally depends on catalog and customer, which is the single cross-module edge of the design (through their service interfaces once they exist). Declaring them now lets ws depend on the whole core from the start and keeps each later branch limited to adding classes, not restructuring the reactor. --- core/auth/pom.xml | 23 +++++++++++++++++++++++ core/catalog/pom.xml | 23 +++++++++++++++++++++++ core/customer/pom.xml | 23 +++++++++++++++++++++++ core/order/pom.xml | 27 +++++++++++++++++++++++++++ core/pom.xml | 4 ++++ 5 files changed, 100 insertions(+) create mode 100644 core/auth/pom.xml create mode 100644 core/catalog/pom.xml create mode 100644 core/customer/pom.xml create mode 100644 core/order/pom.xml diff --git a/core/auth/pom.xml b/core/auth/pom.xml new file mode 100644 index 0000000..def61c8 --- /dev/null +++ b/core/auth/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + com.edgareldy.springjdbctutorial + core + 0.1.0-SNAPSHOT + + + auth + auth + Identity and RBAC domain: users, roles, permissions, tokens, audit logs + + + + com.edgareldy.springjdbctutorial + common + + + diff --git a/core/catalog/pom.xml b/core/catalog/pom.xml new file mode 100644 index 0000000..5c4dca8 --- /dev/null +++ b/core/catalog/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + com.edgareldy.springjdbctutorial + core + 0.1.0-SNAPSHOT + + + catalog + catalog + E-commerce catalog: categories and products + + + + com.edgareldy.springjdbctutorial + common + + + diff --git a/core/customer/pom.xml b/core/customer/pom.xml new file mode 100644 index 0000000..227da5e --- /dev/null +++ b/core/customer/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + com.edgareldy.springjdbctutorial + core + 0.1.0-SNAPSHOT + + + customer + customer + E-commerce customers + + + + com.edgareldy.springjdbctutorial + common + + + diff --git a/core/order/pom.xml b/core/order/pom.xml new file mode 100644 index 0000000..a3ac330 --- /dev/null +++ b/core/order/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + + com.edgareldy.springjdbctutorial + core + 0.1.0-SNAPSHOT + + + order + order + E-commerce orders: depends on the catalog and customer services + + + + com.edgareldy.springjdbctutorial + catalog + + + com.edgareldy.springjdbctutorial + customer + + + diff --git a/core/pom.xml b/core/pom.xml index b1cc02e..ff32bc9 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -17,5 +17,9 @@ common + auth + catalog + customer + order From a01445ceb2c2f0e886539448773f099f498add76 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 003/144] build(common): add the JDBC, Flyway and test-jar dependencies common carries what every business module needs: spring-jdbc for JdbcTemplate and transactions, spring-context for @Configuration, HikariCP for pooling, the PostgreSQL driver, Flyway with its PostgreSQL module, and the jakarta.persistence annotations used only as documentation on entities. It also publishes its test classes as a test-jar (maven-jar-plugin test-jar goal), so the Testcontainers PostgreSQL holder written once here can be imported by the DAO tests of the other modules. --- core/common/pom.xml | 57 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/core/common/pom.xml b/core/common/pom.xml index cb055cb..c68fb4d 100644 --- a/core/common/pom.xml +++ b/core/common/pom.xml @@ -15,9 +15,66 @@ Shared exceptions, AbstractDao and DataSourceConfig + org.springframework spring-jdbc + + + org.springframework + spring-context + + + com.zaxxer + HikariCP + + + org.postgresql + postgresql + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + + jakarta.persistence + jakarta.persistence-api + + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + + + From c9178aa57e21ebba39de054fe6648c7e9e58b8e6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 004/144] build(ws): add Spring MVC, Jackson, validation and logging dependencies ws is the web layer: it depends on every core module, on spring-webmvc and on Jackson with the JSR-310 module for Instant. The Servlet API is provided scope because Tomcat 10.1 supplies Servlet 6.0 at runtime, and Bean Validation runs standalone on Hibernate Validator with its Expressly EL. The WAR plugin is told there is no web.xml, since a WebApplicationInitializer replaces it, and the artifact is named ws so the package is ws/target/ws.war as the README states. --- ws/pom.xml | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/ws/pom.xml b/ws/pom.xml index e2cda26..2136e9f 100644 --- a/ws/pom.xml +++ b/ws/pom.xml @@ -20,6 +20,75 @@ com.edgareldy.springjdbctutorial common + + com.edgareldy.springjdbctutorial + auth + + + com.edgareldy.springjdbctutorial + catalog + + + com.edgareldy.springjdbctutorial + customer + + + com.edgareldy.springjdbctutorial + order + + + + org.springframework + spring-webmvc + + + + jakarta.servlet + jakarta.servlet-api + provided + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + jakarta.validation + jakarta.validation-api + + + org.hibernate.validator + hibernate-validator + + + org.glassfish.expressly + expressly + + + ch.qos.logback + logback-classic + + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + From 91c82657aef5f197444c3e77e9d025c14181f2d4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 005/144] feat(common): add ResourceNotFoundException and BusinessRuleException These two unchecked exceptions are the shared vocabulary between services and the web layer. A service throws ResourceNotFoundException when an id matches nothing and BusinessRuleException when a domain rule refuses an operation; neither knows anything about HTTP. Keeping them in common lets every core module raise them without depending on ws, while GlobalExceptionHandler translates them to 404 and 422 later. --- .../exception/BusinessRuleException.java | 22 ++++++++++++++++++ .../exception/ResourceNotFoundException.java | 23 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/BusinessRuleException.java create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/ResourceNotFoundException.java diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/BusinessRuleException.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/BusinessRuleException.java new file mode 100644 index 0000000..73baa6b --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/BusinessRuleException.java @@ -0,0 +1,22 @@ +package com.edgareldy.springjdbctutorial.core.common.exception; + +/** + * Thrown by a service when a business rule refuses an operation (mapped to HTTP 422 by the web layer). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class BusinessRuleException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public BusinessRuleException(String message) { + super(message); + } + + public BusinessRuleException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/ResourceNotFoundException.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..d835123 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/ResourceNotFoundException.java @@ -0,0 +1,23 @@ +package com.edgareldy.springjdbctutorial.core.common.exception; + +/** + * Thrown by a service when a requested resource does not exist (mapped to HTTP 404 by the web layer). + * It lives in core so no module depends on anything web-related. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class ResourceNotFoundException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public ResourceNotFoundException(String message) { + super(message); + } + + public ResourceNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} From b7f207a763b16c7649d7e2a211a06b4c0a0ed854 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 006/144] feat(common): add the Flyway V1 schema for both domains The migration creates every table of the README data model: the identity and RBAC domain (users, roles, permissions, join tables, activation, blacklist and reset tokens, audit logs) and the e-commerce domain (categories, products, customers, orders). Flyway is the only thing that creates or alters tables, no framework generates the schema. Constraints carry the invariants the database can enforce: a unique index on the lower-cased email, tokens stored as 64 character digests, restrictive foreign keys where a service must refuse the delete first, and no foreign key between the two domains. --- .../db/migration/V1__init_schema.sql | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 core/common/src/main/resources/db/migration/V1__init_schema.sql diff --git a/core/common/src/main/resources/db/migration/V1__init_schema.sql b/core/common/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..e6ac40a --- /dev/null +++ b/core/common/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,160 @@ +-- Initial schema of both independent domains (identity/RBAC and e-commerce). +-- Flyway is the single source of truth of the schema: nothing else creates or alters tables. +-- The two domains share one database but have no foreign key between them. + +-- --------------------------------------------------------------------------- +-- Identity / RBAC domain +-- --------------------------------------------------------------------------- + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + -- bcrypt hash, never the clear password + password VARCHAR(100) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + account_locked BOOLEAN NOT NULL DEFAULT FALSE +); + +-- Emails are compared case-insensitively: the unique index is on the lower-cased value +CREATE UNIQUE INDEX uq_users_email_lower ON users (LOWER(email)); + +CREATE TABLE roles ( + id BIGSERIAL PRIMARY KEY, + role_name VARCHAR(100) NOT NULL, + CONSTRAINT uq_roles_role_name UNIQUE (role_name) +); + +CREATE TABLE permissions ( + id BIGSERIAL PRIMARY KEY, + resource VARCHAR(100) NOT NULL, + action VARCHAR(100) NOT NULL, + CONSTRAINT uq_permissions_resource_action UNIQUE (resource, action) +); + +-- Roles are assigned onto a user, never the reverse +CREATE TABLE role_user ( + role_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + PRIMARY KEY (role_id, user_id), + -- No cascade on the role: deleting a role still assigned to a user is refused by the service + CONSTRAINT fk_role_user_role FOREIGN KEY (role_id) REFERENCES roles (id), + CONSTRAINT fk_role_user_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_role_user_user ON role_user (user_id); + +-- Permissions are assigned onto a role, never the reverse +CREATE TABLE role_permission ( + role_id BIGINT NOT NULL, + permission_id BIGINT NOT NULL, + PRIMARY KEY (role_id, permission_id), + CONSTRAINT fk_role_permission_role FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, + -- No cascade on the permission: deleting a permission still held by a role is refused by the service + CONSTRAINT fk_role_permission_permission FOREIGN KEY (permission_id) REFERENCES permissions (id) +); + +CREATE INDEX idx_role_permission_permission ON role_permission (permission_id); + +-- Activation and password reset tokens are stored as SHA-256 hex digests only (64 characters) +CREATE TABLE activation_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + validated_at TIMESTAMPTZ, + CONSTRAINT uq_activation_tokens_token UNIQUE (token), + CONSTRAINT fk_activation_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_activation_tokens_user ON activation_tokens (user_id); + +-- A revoked JWT: token is the SHA-256 hex digest of the JWT (never the JWT itself), jti its unique id +CREATE TABLE blacklisted_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token VARCHAR(64) NOT NULL, + jti VARCHAR(100) NOT NULL, + blacklisted_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + validated_at TIMESTAMPTZ, + CONSTRAINT uq_blacklisted_tokens_token UNIQUE (token), + CONSTRAINT uq_blacklisted_tokens_jti UNIQUE (jti), + CONSTRAINT fk_blacklisted_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +-- The scheduled cleanup deletes rows by expiry +CREATE INDEX idx_blacklisted_tokens_expires_at ON blacklisted_tokens (expires_at); + +CREATE TABLE password_reset_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token VARCHAR(64) NOT NULL, + type VARCHAR(30) NOT NULL, + expiry_date TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_password_reset_tokens_token UNIQUE (token), + CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens (user_id); + +-- Audit trail of every RBAC mutation. No foreign key on purpose: the trail must outlive +-- whatever it references, and a refusal row is written in an independent transaction. +CREATE TABLE audit_logs ( + id BIGSERIAL PRIMARY KEY, + actor_user_id BIGINT, + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(100) NOT NULL, + entity_id BIGINT, + details TEXT, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_audit_logs_created_at ON audit_logs (created_at); + +-- --------------------------------------------------------------------------- +-- E-commerce domain +-- --------------------------------------------------------------------------- + +CREATE TABLE categories ( + id BIGSERIAL PRIMARY KEY, + category_name VARCHAR(100) NOT NULL, + CONSTRAINT uq_categories_category_name UNIQUE (category_name) +); + +CREATE TABLE products ( + id BIGSERIAL PRIMARY KEY, + category_id BIGINT NOT NULL, + product_name VARCHAR(150) NOT NULL, + unit_price NUMERIC(12, 2) NOT NULL, + CONSTRAINT ck_products_unit_price CHECK (unit_price >= 0), + CONSTRAINT fk_products_category FOREIGN KEY (category_id) REFERENCES categories (id) +); + +CREATE INDEX idx_products_category ON products (category_id); + +CREATE TABLE customers ( + id BIGSERIAL PRIMARY KEY, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + telephone VARCHAR(30), + email VARCHAR(255), + address VARCHAR(255) +); + +CREATE TABLE orders ( + id BIGSERIAL PRIMARY KEY, + customer_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + quantity INTEGER NOT NULL, + total NUMERIC(14, 2) NOT NULL, + CONSTRAINT ck_orders_quantity CHECK (quantity > 0), + CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id), + CONSTRAINT fk_orders_product FOREIGN KEY (product_id) REFERENCES products (id) +); + +CREATE INDEX idx_orders_customer ON orders (customer_id); +CREATE INDEX idx_orders_product ON orders (product_id); From 5fb25ac42305732ef5e95148a3e92834045aa565 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 007/144] feat(common): add AbstractDao with shared JdbcTemplate helpers JdbcTemplate is Spring JDBC's core class: it borrows a connection, runs the SQL, binds the parameters, and translates SQLException into Spring's unchecked DataAccessException hierarchy. A RowMapper turns one ResultSet row into one object, which together replace what an ORM would hide. AbstractDao holds the shared template and three helpers every DAO reuses: findOne (first row or empty, never EmptyResultDataAccessException), insertReturningId for PostgreSQL's INSERT ... RETURNING id, and count. SQL itself stays in the concrete DAO classes. --- .../core/common/dao/support/AbstractDao.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDao.java diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDao.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDao.java new file mode 100644 index 0000000..faaa3f0 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDao.java @@ -0,0 +1,63 @@ +package com.edgareldy.springjdbctutorial.core.common.dao.support; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Base class of every DAO implementation: holds the shared {@link JdbcTemplate} and a few query helpers. + * SQL itself always stays in the concrete DAO classes under {@code dao/impl}. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public abstract class AbstractDao { + + // JdbcTemplate is Spring JDBC's central class: it opens/releases the connection, runs the SQL, + // binds the "?" parameters and translates SQLException into Spring's unchecked DataAccessException + // hierarchy. A RowMapper is the callback that turns ONE ResultSet row into one object. Together + // they replace an ORM: we write the SQL and the mapping ourselves. It is thread-safe, so a single + // instance (declared once in DataSourceConfig) is shared by all DAOs. + private final JdbcTemplate jdbcTemplate; + + protected AbstractDao(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate"); + } + + protected JdbcTemplate getJdbcTemplate() { + return jdbcTemplate; + } + + /** + * Runs a query and returns its first row, or empty when there is none. + * Never throws {@code EmptyResultDataAccessException}, unlike {@code queryForObject}. + */ + protected Optional findOne(String sql, RowMapper mapper, Object... args) { + List rows = jdbcTemplate.query(sql, mapper, args); + return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0)); + } + + /** + * Runs an INSERT whose SQL ends with {@code RETURNING id} (PostgreSQL) and returns the generated id. + */ + protected long insertReturningId(String sql, Object... args) { + Long id = jdbcTemplate.queryForObject(sql, Long.class, args); + if (id == null) { + throw new IllegalStateException("INSERT ... RETURNING id returned no id"); + } + return id; + } + + /** + * Runs a {@code SELECT COUNT(*)} style query and returns the number (0 when the result is null). + */ + protected long count(String sql, Object... args) { + Long total = jdbcTemplate.queryForObject(sql, Long.class, args); + return total == null ? 0L : total; + } +} From bdcf0e45780e4f48430c6cee468328bf5f7375d5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 008/144] feat(common): declare DataSource, Flyway, JdbcTemplate and transactions DataSourceConfig is the only place these infrastructure beans exist. A @Configuration class declares beans explicitly with @Bean instead of relying on scanning or auto-configuration. The HikariCP pool reads its settings with getRequiredProperty, so a missing url, user or password fails the startup with a clear message and no default secret exists. Flyway migrates as the bean is created and JdbcTemplate and the DataSourceTransactionManager depend on it, so migrations always run before any DAO. @EnableTransactionManagement makes @Transactional service beans proxied. The PostgreSQL driver class is named explicitly because inside a WAR DriverManager cannot see a driver held in WEB-INF/lib. --- .../core/common/config/DataSourceConfig.java | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfig.java diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfig.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfig.java new file mode 100644 index 0000000..e150de5 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfig.java @@ -0,0 +1,94 @@ +package com.edgareldy.springjdbctutorial.core.common.config; + +import com.zaxxer.hikari.HikariDataSource; +import org.flywaydb.core.Flyway; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.core.env.Environment; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import javax.sql.DataSource; + +/** + * The only place where DataSource, Flyway, JdbcTemplate and the transaction manager are declared. + * Settings come from the Spring Environment (system properties, environment variables) with no default + * for anything sensitive, so startup fails clearly when one is missing. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @Configuration marks a class whose @Bean methods declare Spring beans explicitly. This is how the +// whole project is wired (no component scanning of core classes, no auto-configuration): a bean's +// existence and wiring are visible by reading the config class of its module. Spring calls each +// @Bean method once and registers the returned object under the method name. +// +// @EnableTransactionManagement turns on annotation-driven transactions: for every bean that has +// @Transactional methods (the service implementations declared by @Bean in the ServiceConfig +// classes), Spring wraps the bean in a proxy that opens/commits/rolls back a transaction around the +// call. It looks up the PlatformTransactionManager bean declared below. +@Configuration +@EnableTransactionManagement +public class DataSourceConfig { + + private static final int DEFAULT_MAX_POOL_SIZE = 10; + + private final Environment environment; + + public DataSourceConfig(Environment environment) { + this.environment = environment; + } + + // HikariCP is a fast JDBC connection pool: connections are opened once and reused instead of + // being created per request. getRequiredProperty throws IllegalStateException when a key is + // missing, so a wrong deployment fails at startup instead of using a hidden default (never put a + // default password here). The pool size is not a secret, so it has a default. + // destroyMethod "close" shuts the pool down when the context closes. + @Bean(destroyMethod = "close") + public DataSource dataSource() { + HikariDataSource dataSource = new HikariDataSource(); + // The driver class is named explicitly: inside a WAR the PostgreSQL driver lives in WEB-INF/lib, + // in the webapp classloader, and java.sql.DriverManager (which only auto-registers drivers seen + // by the system classloader) fails with "No suitable driver". Hikari loads the named class + // through the classloader of the application instead. + dataSource.setDriverClassName("org.postgresql.Driver"); + dataSource.setJdbcUrl(environment.getRequiredProperty("app.datasource.url")); + dataSource.setUsername(environment.getRequiredProperty("app.datasource.username")); + dataSource.setPassword(environment.getRequiredProperty("app.datasource.password")); + dataSource.setMaximumPoolSize( + environment.getProperty("app.datasource.max-pool-size", Integer.class, DEFAULT_MAX_POOL_SIZE)); + return dataSource; + } + + // Flyway applies the versioned SQL scripts of db/migration (the schema source of truth) to the + // database. initMethod "migrate" runs them as soon as the bean is created. The beans that use the + // database (JdbcTemplate below, hence every DAO) are declared @DependsOn("flyway"), so Spring + // always creates and runs Flyway BEFORE any DAO can touch a table. + @Bean(initMethod = "migrate") + public Flyway flyway(DataSource dataSource) { + return Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration") + .load(); + } + + @Bean + @DependsOn("flyway") + public JdbcTemplate jdbcTemplate(DataSource dataSource) { + return new JdbcTemplate(dataSource); + } + + // DataSourceTransactionManager implements local JDBC transactions: it binds one Connection to the + // current thread for the duration of a @Transactional method, so every JdbcTemplate call made in + // it (through any DAO) shares the same transaction and commits or rolls back together. + @Bean + @DependsOn("flyway") + public PlatformTransactionManager transactionManager(DataSource dataSource) { + return new DataSourceTransactionManager(dataSource); + } +} From fbb1d84dec8b76eeaaf37af7aa38d1e3eafe84f2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:17 +0400 Subject: [PATCH 009/144] feat(common): add a health check through the five-layer split A tiny vertical slice gives the packaged WAR a public route to probe: a DAO runs SELECT 1, a service turns the outcome into a HealthDto (DOWN when a DataAccessException is raised) and CommonConfig registers both as explicit @Bean methods typed by their interfaces, importing DataSourceConfig. It shows the layering without an entity: the DAO owns SQL, the service owns the decision, and only a dto leaves the module. No stereotype annotation is used on either implementation. --- .../core/common/config/CommonConfig.java | 36 +++++++++++ .../core/common/dao/HealthDao.java | 19 ++++++ .../core/common/dao/impl/HealthDaoImpl.java | 25 ++++++++ .../core/common/dto/HealthDto.java | 63 +++++++++++++++++++ .../core/common/service/HealthService.java | 19 ++++++ .../service/impl/HealthServiceImpl.java | 35 +++++++++++ 6 files changed, 197 insertions(+) create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/HealthDao.java create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImpl.java create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dto/HealthDto.java create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/HealthService.java create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java new file mode 100644 index 0000000..1034a89 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java @@ -0,0 +1,36 @@ +package com.edgareldy.springjdbctutorial.core.common.config; + +import com.edgareldy.springjdbctutorial.core.common.dao.HealthDao; +import com.edgareldy.springjdbctutorial.core.common.dao.impl.HealthDaoImpl; +import com.edgareldy.springjdbctutorial.core.common.service.HealthService; +import com.edgareldy.springjdbctutorial.core.common.service.impl.HealthServiceImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Wiring of the common module: imports the infrastructure and declares the health DAO and service. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @Import pulls DataSourceConfig into whichever context imports this class, so a module config only +// has to import what it directly needs. Beans are typed by their interface: callers never see the +// implementation class. +@Configuration +@Import(DataSourceConfig.class) +public class CommonConfig { + + @Bean + public HealthDao healthDao(JdbcTemplate jdbcTemplate) { + return new HealthDaoImpl(jdbcTemplate); + } + + @Bean + public HealthService healthService(HealthDao healthDao) { + return new HealthServiceImpl(healthDao); + } +} diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/HealthDao.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/HealthDao.java new file mode 100644 index 0000000..4229b54 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/HealthDao.java @@ -0,0 +1,19 @@ +package com.edgareldy.springjdbctutorial.core.common.dao; + +/** + * Data access contract for the health check (no table involved, only connectivity). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface HealthDao { + + /** + * Runs a trivial query to check the database answers. + * + * @return true when the query succeeded + */ + boolean isDatabaseReachable(); +} diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImpl.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImpl.java new file mode 100644 index 0000000..b4b0ffd --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImpl.java @@ -0,0 +1,25 @@ +package com.edgareldy.springjdbctutorial.core.common.dao.impl; + +import com.edgareldy.springjdbctutorial.core.common.dao.HealthDao; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * JdbcTemplate implementation of {@link HealthDao}. Declared by @Bean in CommonConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class HealthDaoImpl extends AbstractDao implements HealthDao { + + public HealthDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public boolean isDatabaseReachable() { + return count("SELECT 1") == 1L; + } +} diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dto/HealthDto.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dto/HealthDto.java new file mode 100644 index 0000000..357b4b9 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dto/HealthDto.java @@ -0,0 +1,63 @@ +package com.edgareldy.springjdbctutorial.core.common.dto; + +import java.util.Objects; + +/** + * Business shape of the health check result. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class HealthDto { + + private String status; + private String database; + + public HealthDto() { + } + + public HealthDto(String status, String database) { + this.status = status; + this.database = database; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getDatabase() { + return database; + } + + public void setDatabase(String database) { + this.database = database; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + HealthDto that = (HealthDto) o; + return Objects.equals(status, that.status) && Objects.equals(database, that.database); + } + + @Override + public int hashCode() { + return Objects.hash(status, database); + } + + @Override + public String toString() { + return "HealthDto{status='" + status + "', database='" + database + "'}"; + } +} diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/HealthService.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/HealthService.java new file mode 100644 index 0000000..729c972 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/HealthService.java @@ -0,0 +1,19 @@ +package com.edgareldy.springjdbctutorial.core.common.service; + +import com.edgareldy.springjdbctutorial.core.common.dto.HealthDto; + +/** + * Health check use case, exposed to the web layer as a public route. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface HealthService { + + /** + * Reports the application and database status; never throws when the database is down. + */ + HealthDto check(); +} diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java new file mode 100644 index 0000000..e184677 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java @@ -0,0 +1,35 @@ +package com.edgareldy.springjdbctutorial.core.common.service.impl; + +import com.edgareldy.springjdbctutorial.core.common.dao.HealthDao; +import com.edgareldy.springjdbctutorial.core.common.dto.HealthDto; +import com.edgareldy.springjdbctutorial.core.common.service.HealthService; +import org.springframework.dao.DataAccessException; + +/** + * Implementation of {@link HealthService}: DOWN when the database cannot be queried. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class HealthServiceImpl implements HealthService { + + private final HealthDao healthDao; + + public HealthServiceImpl(HealthDao healthDao) { + this.healthDao = healthDao; + } + + @Override + public HealthDto check() { + try { + if (healthDao.isDatabaseReachable()) { + return new HealthDto("UP", "UP"); + } + } catch (DataAccessException e) { + // JdbcTemplate translates SQLException into DataAccessException: database unreachable + } + return new HealthDto("DOWN", "DOWN"); + } +} From 8fb2304f9931c2129d8955a0e1d997c13104d3de Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 010/144] feat(ws): add ApiResponse, PageResponse and HealthResponse payloads ApiResponse wraps every answer, success or error, in the same envelope (success, message, data, timestamp) built through two static factories. PageResponse carries a page of results with its totals and refuses a size below 1, so a bad size can never produce a fake total. They are named payload, not dto, on purpose: core dto classes are the business shape, payload classes are the HTTP shape. They are plain classes with explicit accessors, no Lombok and no record. --- .../ws/payload/common/ApiResponse.java | 68 +++++++++++++++++++ .../ws/payload/common/HealthResponse.java | 39 +++++++++++ .../ws/payload/common/PageResponse.java | 56 +++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponse.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/HealthResponse.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponse.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponse.java new file mode 100644 index 0000000..2b0ed53 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponse.java @@ -0,0 +1,68 @@ +package com.edgareldy.springjdbctutorial.ws.payload.common; + +import java.time.Instant; + +/** + * Envelope of every HTTP response, success or error. data is null on errors. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class ApiResponse { + + private boolean success; + private String message; + private T data; + private Instant timestamp; + + public static ApiResponse success(T data, String message) { + ApiResponse response = new ApiResponse<>(); + response.success = true; + response.message = message; + response.data = data; + response.timestamp = Instant.now(); + return response; + } + + public static ApiResponse error(String message) { + ApiResponse response = new ApiResponse<>(); + response.success = false; + response.message = message; + response.timestamp = Instant.now(); + return response; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } + + public Instant getTimestamp() { + return timestamp; + } + + public void setTimestamp(Instant timestamp) { + this.timestamp = timestamp; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/HealthResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/HealthResponse.java new file mode 100644 index 0000000..417ed83 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/HealthResponse.java @@ -0,0 +1,39 @@ +package com.edgareldy.springjdbctutorial.ws.payload.common; + +/** + * HTTP shape of the health check. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class HealthResponse { + + private String status; + private String database; + + public HealthResponse() { + } + + public HealthResponse(String status, String database) { + this.status = status; + this.database = database; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getDatabase() { + return database; + } + + public void setDatabase(String database) { + this.database = database; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponse.java new file mode 100644 index 0000000..194ac69 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponse.java @@ -0,0 +1,56 @@ +package com.edgareldy.springjdbctutorial.ws.payload.common; + +import java.util.List; + +/** + * Paged list payload. size must be at least 1 so totalPages is never a fake value. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PageResponse { + + private List content; + private int page; + private int size; + private long totalElements; + private int totalPages; + + public PageResponse() { + } + + public static PageResponse of(List content, int page, int size, long totalElements) { + if (size < 1) { + throw new IllegalArgumentException("size must be at least 1"); + } + PageResponse response = new PageResponse<>(); + response.content = content; + response.page = page; + response.size = size; + response.totalElements = totalElements; + response.totalPages = (int) ((totalElements + size - 1) / size); + return response; + } + + public List getContent() { + return content; + } + + public int getPage() { + return page; + } + + public int getSize() { + return size; + } + + public long getTotalElements() { + return totalElements; + } + + public int getTotalPages() { + return totalPages; + } +} From bb048a77eb56f90bf85a6de2eb47466e00457e1c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 011/144] feat(ws): map every exception to an ApiResponse in GlobalExceptionHandler @RestControllerAdvice makes these @ExceptionHandler methods apply to every controller, so exceptions become HTTP in exactly one place. Domain exceptions give 404 and 422, Bean Validation failures give 400 with one "field: message" entry per error, malformed JSON, bad parameters, unknown URLs, wrong methods and media types get their own statuses. The catch-all returns a generic 500 and only logs the real error, so no internal detail leaks. A marked spot is left for the 401 and 403 handlers that the auth branch adds with Spring Security. --- .../ws/exception/GlobalExceptionHandler.java | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java 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 new file mode 100644 index 0000000..4b4b21f --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java @@ -0,0 +1,143 @@ +package com.edgareldy.springjdbctutorial.ws.exception; + +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import jakarta.validation.ConstraintViolationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.ErrorResponse; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import java.util.stream.Collectors; + +/** + * Turns every exception into the ApiResponse error format with the right HTTP status. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @RestControllerAdvice makes these @ExceptionHandler methods apply to every controller and writes +// their return value as the response body. It is the one place where exceptions become HTTP, so +// controllers and services never build error responses. It is one of the two stereotypes allowed in ws. +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger LOG = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + private static ResponseEntity> build(HttpStatusCode status, String message) { + return ResponseEntity.status(status).body(ApiResponse.error(message)); + } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleNotFound(ResourceNotFoundException e) { + return build(HttpStatus.NOT_FOUND, e.getMessage()); + } + + @ExceptionHandler(BusinessRuleException.class) + public ResponseEntity> handleBusinessRule(BusinessRuleException e) { + return build(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleInvalidBody(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getAllErrors().stream() + .map(error -> error instanceof FieldError fieldError + ? fieldError.getField() + ": " + fieldError.getDefaultMessage() + : error.getObjectName() + ": " + error.getDefaultMessage()) + .sorted() + .collect(Collectors.joining("; ")); + return build(HttpStatus.BAD_REQUEST, message); + } + + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity> handleMethodValidation(HandlerMethodValidationException e) { + String message = e.getAllErrors().stream() + .map(error -> error.getDefaultMessage()) + .sorted() + .collect(Collectors.joining("; ")); + return build(HttpStatus.BAD_REQUEST, message); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolation(ConstraintViolationException e) { + String message = e.getConstraintViolations().stream() + .map(v -> v.getPropertyPath() + ": " + v.getMessage()) + .sorted() + .collect(Collectors.joining("; ")); + return build(HttpStatus.BAD_REQUEST, message); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleUnreadable(HttpMessageNotReadableException e) { + return build(HttpStatus.BAD_REQUEST, "Malformed JSON request"); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParameter(MissingServletRequestParameterException e) { + return build(HttpStatus.BAD_REQUEST, "Missing parameter: " + e.getParameterName()); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException e) { + return build(HttpStatus.BAD_REQUEST, "Invalid value for parameter: " + e.getName()); + } + + @ExceptionHandler({NoHandlerFoundException.class, NoResourceFoundException.class}) + public ResponseEntity> handleNoResource(Exception e) { + return build(HttpStatus.NOT_FOUND, "Resource not found"); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) { + ResponseEntity.BodyBuilder builder = ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED); + HttpHeaders headers = e.getHeaders(); + if (!headers.isEmpty()) { + builder.headers(headers); + } + return builder.body(ApiResponse.error("Method not allowed")); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity> handleMediaTypeNotSupported(HttpMediaTypeNotSupportedException e) { + return build(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported media type"); + } + + @ExceptionHandler(HttpMediaTypeNotAcceptableException.class) + public ResponseEntity> handleNotAcceptable(HttpMediaTypeNotAcceptableException e) { + return build(HttpStatus.NOT_ACCEPTABLE, "Not acceptable"); + } + + // AUTH BRANCH: add handlers here for AccessDeniedException (403) and AuthenticationException (401). + // They are intentionally absent now: no Spring Security dependency exists yet. + + // Catch-all. Other Spring MVC exceptions implementing ErrorResponse keep the status they carry; + // anything else is a 500 with a generic message, the real error is only logged. + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception e) { + if (e instanceof ErrorResponse errorResponse) { + return build(errorResponse.getStatusCode(), errorResponse.getBody().getDetail() != null + ? errorResponse.getBody().getDetail() : "Request failed"); + } + LOG.error("Unexpected error", e); + return build(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"); + } +} From 8739721d39177ca977bc3925bf0a2d6a1c337e44 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 012/144] feat(ws): add the public health route HealthController exposes GET /api/v1/health. It sees only the HealthService interface and a payload type: a converter maps the core HealthDto to the HealthResponse, keeping core dto and web payload distinct. The route lets a packaged WAR prove that it starts, reaches PostgreSQL and answers in the ApiResponse envelope. --- .../ws/controller/HealthController.java | 33 +++++++++++++++++++ .../ws/converter/HealthConverter.java | 22 +++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/HealthController.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/HealthConverter.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/HealthController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/HealthController.java new file mode 100644 index 0000000..be17307 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/HealthController.java @@ -0,0 +1,33 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.common.service.HealthService; +import com.edgareldy.springjdbctutorial.ws.converter.HealthConverter; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.HealthResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Public health route, used to check that a deployed WAR is alive and reaches its database. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@RestController +@RequestMapping("/api/v1/health") +public class HealthController { + + private final HealthService healthService; + + public HealthController(HealthService healthService) { + this.healthService = healthService; + } + + @GetMapping + public ApiResponse health() { + return ApiResponse.success(HealthConverter.toResponse(healthService.check()), "Health check"); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/HealthConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/HealthConverter.java new file mode 100644 index 0000000..3764633 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/HealthConverter.java @@ -0,0 +1,22 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.common.dto.HealthDto; +import com.edgareldy.springjdbctutorial.ws.payload.common.HealthResponse; + +/** + * Converts the core HealthDto to the HTTP HealthResponse payload. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public final class HealthConverter { + + private HealthConverter() { + } + + public static HealthResponse toResponse(HealthDto dto) { + return new HealthResponse(dto.getStatus(), dto.getDatabase()); + } +} From 9aace489dce44a046d7ff171b7e749f987adf440 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 013/144] feat(ws): configure Spring MVC explicitly with WebMvcConfig @EnableWebMvc turns on Spring MVC in a plain Spring application, without Boot, and WebMvcConfigurer customises it: one Jackson converter that writes Instants as ISO-8601 text and a Hibernate Validator backed validator. The class imports the core configuration, and every later branch adds its module config to the same import. The component scan is limited to the controller and exception packages: those are the only stereotypes of the project, DAOs and services are never scanned. --- .../ws/config/WebMvcConfig.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java new file mode 100644 index 0000000..e1723cc --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -0,0 +1,61 @@ +package com.edgareldy.springjdbctutorial.ws.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.edgareldy.springjdbctutorial.core.common.config.CommonConfig; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.validation.Validator; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +/** + * Spring MVC configuration and single entry point of the wiring (imports the core module configs). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @EnableWebMvc activates Spring MVC (handler mappings, argument resolvers, message converters) in +// plain Spring, without Boot. The WebMvcConfigurer callbacks below customise it. +// +// Core module configs are imported here in the SAME context (one shared context). CommonConfig comes +// first; each later branch adds its module's DaoConfig and ServiceConfig to the @Import list. +// +// The component scan is limited to the web layer (controllers and the advice): those are the only +// stereotypes of the project. DAO and service implementations are never scanned, they are explicit +// @Bean methods, so nothing gets wired by accident. +@Configuration +@EnableWebMvc +@Import(CommonConfig.class) +@ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"}) +public class WebMvcConfig implements WebMvcConfigurer { + + // Replaces the default converters with a single Jackson one. The builder registers the JSR-310 + // module (jackson-datatype-jsr310) found on the classpath; disabling WRITE_DATES_AS_TIMESTAMPS + // makes Instant serialise as ISO-8601 text instead of a number. Null fields stay in the output. + @Override + public void configureMessageConverters(List> converters) { + ObjectMapper mapper = Jackson2ObjectMapperBuilder.json() + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); + converters.add(new MappingJackson2HttpMessageConverter(mapper)); + } + + // LocalValidatorFactoryBean bootstraps Bean Validation (Hibernate Validator on the classpath). + // Returning it from getValidator() makes @Valid on request bodies use it. + @Override + public Validator getValidator() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + return validator; + } +} From a7c5f6bcc4f6aa9844703e14b4e7b2448e68abc2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 014/144] feat(ws): replace web.xml with a WebApplicationInitializer Tomcat discovers WebApplicationInitializer implementations through Spring's servlet container initializer, so servlets and filters are registered in Java. The initializer builds one annotation-based context, exposes it as the root context through ContextLoaderListener and hands the same context to the DispatcherServlet mapped on "/". Sharing one context keeps every bean defined once and lets the security filter added later, which lives outside the DispatcherServlet, find it in the root context. A UTF-8 character encoding filter is registered as well. --- .../ws/config/WebAppInitializer.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java new file mode 100644 index 0000000..9bec963 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java @@ -0,0 +1,49 @@ +package com.edgareldy.springjdbctutorial.ws.config; + +import jakarta.servlet.FilterRegistration; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; +import org.springframework.web.WebApplicationInitializer; +import org.springframework.web.context.ContextLoaderListener; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.filter.CharacterEncodingFilter; +import org.springframework.web.servlet.DispatcherServlet; + +import java.util.EnumSet; + +/** + * Programmatic replacement of web.xml: builds the Spring context and registers servlet and filters. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// Tomcat finds every WebApplicationInitializer through Spring's SpringServletContainerInitializer +// (Servlet 3+ mechanism) and calls onStartup, so no web.xml is needed. +// +// ONE AnnotationConfigWebApplicationContext is shared as the root context (ContextLoaderListener) AND +// as the DispatcherServlet's context. Everything (DAOs, services, controllers, security) then lives in +// one place, so a bean is defined once and the security filter, which is created outside the +// DispatcherServlet, can find it through the root context. +public class WebAppInitializer implements WebApplicationInitializer { + + @Override + public void onStartup(ServletContext servletContext) { + AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); + context.register(WebMvcConfig.class); + + servletContext.addListener(new ContextLoaderListener(context)); + + ServletRegistration.Dynamic dispatcher = + servletContext.addServlet("dispatcher", new DispatcherServlet(context)); + dispatcher.setLoadOnStartup(1); + dispatcher.setAsyncSupported(true); + dispatcher.addMapping("/"); + + FilterRegistration.Dynamic encoding = + servletContext.addFilter("characterEncodingFilter", new CharacterEncodingFilter("UTF-8", true)); + encoding.setAsyncSupported(true); + encoding.addMappingForUrlPatterns(EnumSet.of(jakarta.servlet.DispatcherType.REQUEST), false, "/*"); + } +} From b7526cc385cb89c9d352a64b1d0046f1e758bb8b Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 015/144] test(common): verify the Flyway schema against a real PostgreSQL A singleton Testcontainers PostgreSQL is started once per JVM and its connection settings are injected into the Spring test context with @DynamicPropertySource. The holder is published in the common test-jar so other modules reuse it. FlywayMigrationTest checks that all thirteen tables exist after startup, that the unique email index is case-insensitive and that the restrictive foreign key blocks deleting a referenced category. --- .../common/config/FlywayMigrationTest.java | 74 +++++++++++++++++++ .../common/support/PostgresTestContainer.java | 50 +++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/FlywayMigrationTest.java create mode 100644 core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/support/PostgresTestContainer.java 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 new file mode 100644 index 0000000..c5111e9 --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/FlywayMigrationTest.java @@ -0,0 +1,74 @@ +package com.edgareldy.springjdbctutorial.core.common.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +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.SpringJUnitConfig; + +/** + * Checks that Flyway builds the whole V1 schema and that its key constraints hold on a real PostgreSQL. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @SpringJUnitConfig starts a real Spring context from DataSourceConfig (Flyway migrates at startup) +// and injects beans into the test. @DynamicPropertySource feeds the datasource settings into the +// context Environment once the Testcontainers database is running. +@SpringJUnitConfig(DataSourceConfig.class) +class FlywayMigrationTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void _01_ShouldCreateEveryTableOfTheDataModel_WhenApplicationStarts() { + List tables = jdbcTemplate.queryForList( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'", String.class); + + assertThat(tables).contains("users", "roles", "permissions", "role_user", "role_permission", + "activation_tokens", "blacklisted_tokens", "password_reset_tokens", "audit_logs", + "categories", "products", "customers", "orders"); + } + + @Test + void _02_ShouldRejectDuplicateEmail_WhenOnlyTheCaseDiffers() { + String insert = "INSERT INTO users (first_name, last_name, email, password) VALUES ('A', 'B', ?, 'x')"; + jdbcTemplate.update(insert, "Case.Test@Example.com"); + try { + assertThatThrownBy(() -> jdbcTemplate.update(insert, "case.test@example.COM")) + .isInstanceOf(DataIntegrityViolationException.class); + } finally { + jdbcTemplate.update("DELETE FROM users WHERE LOWER(email) = 'case.test@example.com'"); + } + } + + @Test + void _03_ShouldRefuseCategoryDeletion_WhenAProductStillReferencesIt() { + Long categoryId = jdbcTemplate.queryForObject( + "INSERT INTO categories (category_name) VALUES ('fk-restrict-test') RETURNING id", Long.class); + jdbcTemplate.update("INSERT INTO products (category_id, product_name, unit_price) VALUES (?, 'p', 1.00)", + categoryId); + try { + assertThatThrownBy(() -> jdbcTemplate.update("DELETE FROM categories WHERE id = ?", categoryId)) + .isInstanceOf(DataIntegrityViolationException.class); + } finally { + jdbcTemplate.update("DELETE FROM products WHERE category_id = ?", categoryId); + jdbcTemplate.update("DELETE FROM categories WHERE id = ?", categoryId); + } + } +} diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/support/PostgresTestContainer.java b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/support/PostgresTestContainer.java new file mode 100644 index 0000000..c150a19 --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/support/PostgresTestContainer.java @@ -0,0 +1,50 @@ +package com.edgareldy.springjdbctutorial.core.common.support; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.testcontainers.containers.PostgreSQLContainer; + +/** + * Singleton PostgreSQL 16 Testcontainer shared by every DB test of the JVM (published in the common test-jar). + * The container is started once, on first use, and killed by Testcontainers' Ryuk when the JVM exits. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// Testcontainers starts a throwaway real PostgreSQL in Docker, so DAO tests run real SQL against the +// real Flyway schema instead of an in-memory fake. The container lives in a static holder class +// (initialised lazily, once, by the JVM class loader) so all test classes share one database instead +// of paying the startup cost per class. Tests therefore must isolate the global state they touch. +public final class PostgresTestContainer { + + private PostgresTestContainer() { + } + + /** Lazy holder: the container is created and started the first time this class is loaded. */ + private static final class Holder { + static final PostgreSQLContainer CONTAINER = new PostgreSQLContainer<>("postgres:16-alpine"); + + static { + CONTAINER.start(); + } + } + + /** + * Returns the running container, starting it on first call. + */ + public static PostgreSQLContainer get() { + return Holder.CONTAINER; + } + + /** + * Registers app.datasource.url/username/password. Call it from a {@code @DynamicPropertySource} + * method: Spring evaluates the suppliers lazily, after the container is up, and adds them to the + * test context Environment (the container port is random, so it cannot be written statically). + */ + public static void register(DynamicPropertyRegistry registry) { + registry.add("app.datasource.url", () -> get().getJdbcUrl()); + registry.add("app.datasource.username", () -> get().getUsername()); + registry.add("app.datasource.password", () -> get().getPassword()); + } +} From a360c64ea29b95b23e6f5628096605831c6b3c96 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 016/144] test(common): check that DataSourceConfig fails without its settings The context is built from an empty MockEnvironment, with no database, and must fail with a clear message when the url or the password is missing. The test also asserts that no default password leaks into the message. It documents the rule that a wrong deployment fails at startup instead of silently using a hidden default. --- .../common/config/DataSourceConfigTest.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfigTest.java diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfigTest.java b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfigTest.java new file mode 100644 index 0000000..820245e --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfigTest.java @@ -0,0 +1,67 @@ +package com.edgareldy.springjdbctutorial.core.common.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.mock.env.MockEnvironment; + +/** + * Checks that startup fails with a clear message when a required datasource setting is missing. + * No database is needed: the failure happens while the DataSource bean is being created. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class DataSourceConfigTest { + + // MockEnvironment (from spring-test) is an Environment holding only the properties we give it, + // so the test does not depend on the developer's real environment variables. + private static void startWith(MockEnvironment environment) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.setEnvironment(environment); + context.register(DataSourceConfig.class); + try { + context.refresh(); + } finally { + context.close(); + } + } + + @Test + void _01_ShouldFailStartup_WhenDatasourceUrlIsMissing() { + MockEnvironment environment = new MockEnvironment(); + + assertThatThrownBy(() -> startWith(environment)) + .isInstanceOf(BeanCreationException.class) + .rootCause() + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("app.datasource.url"); + } + + @Test + void _02_ShouldFailStartup_WhenDatasourcePasswordIsMissing() { + MockEnvironment environment = new MockEnvironment() + .withProperty("app.datasource.url", "jdbc:postgresql://localhost:1/x") + .withProperty("app.datasource.username", "u"); + + assertThatThrownBy(() -> startWith(environment)) + .isInstanceOf(BeanCreationException.class) + .rootCause() + .hasMessageContaining("app.datasource.password"); + } + + @Test + void _03_ShouldNotMentionAnyDefaultPassword_WhenStartupFails() { + MockEnvironment environment = new MockEnvironment(); + + Throwable failure = org.assertj.core.api.Assertions.catchThrowable(() -> startWith(environment)); + + assertThat(failure).isNotNull(); + assertThat(failure.getMessage()).doesNotContain("password="); + } +} From fee88859741fa69fc75e1e7641fbba0df881a49a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 017/144] test(common): cover AbstractDao and HealthDao against PostgreSQL Each DAO test class loads its own dedicated SQL fixture through @Sql before every test: abstract-dao-dataset.sql for the AbstractDao helpers (findOne present and empty, increasing generated ids, count) and health-dao-dataset.sql for the health DAO. No dataset is shared between classes. The fixture of the AbstractDao test restarts the sequence explicitly, because rows inserted with explicit ids do not advance it. --- .../common/dao/impl/HealthDaoImplTest.java | 39 +++++++++ .../common/dao/support/AbstractDaoTest.java | 85 +++++++++++++++++++ .../test/resources/abstract-dao-dataset.sql | 6 ++ .../src/test/resources/health-dao-dataset.sql | 2 + 4 files changed, 132 insertions(+) create mode 100644 core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImplTest.java create mode 100644 core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDaoTest.java create mode 100644 core/common/src/test/resources/abstract-dao-dataset.sql create mode 100644 core/common/src/test/resources/health-dao-dataset.sql diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImplTest.java b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImplTest.java new file mode 100644 index 0000000..40696cc --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImplTest.java @@ -0,0 +1,39 @@ +package com.edgareldy.springjdbctutorial.core.common.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +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.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 HealthDaoImpl against the real PostgreSQL, with its own fixture file health-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@SpringJUnitConfig(DataSourceConfig.class) +@Sql("/health-dao-dataset.sql") +class HealthDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void _01_ShouldReturnTrue_WhenDatabaseAnswers() { + assertThat(new HealthDaoImpl(jdbcTemplate).isDatabaseReachable()).isTrue(); + } +} diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDaoTest.java b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDaoTest.java new file mode 100644 index 0000000..c75dd7f --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDaoTest.java @@ -0,0 +1,85 @@ +package com.edgareldy.springjdbctutorial.core.common.dao.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.util.Optional; +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.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests the shared query helpers of AbstractDao (findOne, insertReturningId, count) on the categories + * table, with its own fixture file abstract-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @Sql runs the fixture script before EACH test method, so every test starts from the same known rows. +// The fixture is dedicated to this class and never reused by another DAO test class. +@SpringJUnitConfig(DataSourceConfig.class) +@Sql("/abstract-dao-dataset.sql") +class AbstractDaoTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private JdbcTemplate jdbcTemplate; + + /** Minimal concrete DAO exposing the protected helpers under test. */ + private static final class CategoryProbeDao extends AbstractDao { + + CategoryProbeDao(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + Optional findName(long id) { + return findOne("SELECT category_name FROM categories WHERE id = ?", + (rs, rowNum) -> rs.getString("category_name"), id); + } + + long insert(String name) { + return insertReturningId("INSERT INTO categories (category_name) VALUES (?) RETURNING id", name); + } + + long total() { + return count("SELECT COUNT(*) FROM categories"); + } + } + + @Test + void _01_ShouldReturnRow_WhenFindOneMatchesARow() { + assertThat(new CategoryProbeDao(jdbcTemplate).findName(1L)).contains("Books"); + } + + @Test + void _02_ShouldReturnEmpty_WhenFindOneMatchesNoRow() { + assertThat(new CategoryProbeDao(jdbcTemplate).findName(999L)).isEmpty(); + } + + @Test + void _03_ShouldReturnIncreasingIds_WhenInsertingTwice() { + CategoryProbeDao dao = new CategoryProbeDao(jdbcTemplate); + + long first = dao.insert("Music"); + long second = dao.insert("Games"); + + assertThat(first).isGreaterThanOrEqualTo(100L); + assertThat(second).isGreaterThan(first); + } + + @Test + void _04_ShouldCountFixtureRows_WhenCounting() { + assertThat(new CategoryProbeDao(jdbcTemplate).total()).isEqualTo(2L); + } +} diff --git a/core/common/src/test/resources/abstract-dao-dataset.sql b/core/common/src/test/resources/abstract-dao-dataset.sql new file mode 100644 index 0000000..3283e93 --- /dev/null +++ b/core/common/src/test/resources/abstract-dao-dataset.sql @@ -0,0 +1,6 @@ +-- Fixture of AbstractDaoTest only: two categories on a clean table. +DELETE FROM products; +DELETE FROM categories; +INSERT INTO categories (id, category_name) VALUES (1, 'Books'), (2, 'Tools'); +-- Explicit ids do not advance the sequence: restart it high so generated ids never collide +ALTER SEQUENCE categories_id_seq RESTART WITH 100; diff --git a/core/common/src/test/resources/health-dao-dataset.sql b/core/common/src/test/resources/health-dao-dataset.sql new file mode 100644 index 0000000..6dfb911 --- /dev/null +++ b/core/common/src/test/resources/health-dao-dataset.sql @@ -0,0 +1,2 @@ +-- Fixture of HealthDaoImplTest only: the health check reads no table, nothing to load. +SELECT 1; From ce291f60802e736077236fd14e51170c05b08e42 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 018/144] test(common): unit test HealthServiceImpl with a mocked DAO Mockito replaces the DAO, so no database is involved: the service must report UP when the DAO answers true, and DOWN both when it answers false and when it throws a DataAccessException. This is the service layer of the strategy: business decisions verified in isolation, with assertions on the dto that leaves the module. --- .../service/impl/HealthServiceImplTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImplTest.java diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImplTest.java b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImplTest.java new file mode 100644 index 0000000..0b84250 --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImplTest.java @@ -0,0 +1,54 @@ +package com.edgareldy.springjdbctutorial.core.common.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.common.dao.HealthDao; +import com.edgareldy.springjdbctutorial.core.common.dto.HealthDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataAccessResourceFailureException; + +/** + * Unit tests of HealthServiceImpl with the DAO mocked (no database). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// MockitoExtension creates the @Mock objects and injects them into the @InjectMocks service, so the +// service is tested alone: the DAO answers are scripted with when(...).thenReturn/thenThrow. +@ExtendWith(MockitoExtension.class) +class HealthServiceImplTest { + + @Mock + private HealthDao healthDao; + + @InjectMocks + private HealthServiceImpl service; + + @Test + void _01_ShouldReturnUp_WhenDatabaseIsReachable() { + when(healthDao.isDatabaseReachable()).thenReturn(true); + + assertThat(service.check()).isEqualTo(new HealthDto("UP", "UP")); + } + + @Test + void _02_ShouldReturnDown_WhenDaoThrowsDataAccessException() { + when(healthDao.isDatabaseReachable()).thenThrow(new DataAccessResourceFailureException("db down")); + + assertThat(service.check()).isEqualTo(new HealthDto("DOWN", "DOWN")); + } + + @Test + void _03_ShouldReturnDown_WhenDatabaseIsNotReachable() { + when(healthDao.isDatabaseReachable()).thenReturn(false); + + assertThat(service.check()).isEqualTo(new HealthDto("DOWN", "DOWN")); + } +} From 6a855531b8485fa094d13550cfe64ddd8ac9763a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 019/144] test(ws): cover ApiResponse and PageResponse The tests check the success and error factories, the ceiling computation of totalPages, the refusal of a size of zero or below, and that a page beyond the last one keeps an empty content list. They are plain unit tests without Spring: payload classes hold no logic beyond construction. --- .../ws/payload/common/ApiResponseTest.java | 54 +++++++++++++++++ .../ws/payload/common/PageResponseTest.java | 59 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponseTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponseTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponseTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponseTest.java new file mode 100644 index 0000000..b146d89 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponseTest.java @@ -0,0 +1,54 @@ +package com.edgareldy.springjdbctutorial.ws.payload.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; + +/** + * Unit tests of the ApiResponse envelope (success and error shapes). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class ApiResponseTest { + + private final ObjectMapper mapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + @Test + void _01_ShouldCarryDataAndMessage_WhenBuildingASuccess() { + ApiResponse response = ApiResponse.success("payload", "done"); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEqualTo("payload"); + assertThat(response.getMessage()).isEqualTo("done"); + assertThat(response.getTimestamp()).isNotNull(); + } + + @Test + void _02_ShouldHaveNoData_WhenBuildingAnError() { + ApiResponse response = ApiResponse.error("boom"); + + assertThat(response.isSuccess()).isFalse(); + assertThat(response.getMessage()).isEqualTo("boom"); + assertThat(response.getData()).isNull(); + assertThat(response.getTimestamp()).isNotNull(); + } + + @Test + void _03_ShouldSerializeTheFourFields_WhenWritingAnErrorAsJson() throws Exception { + JsonNode json = mapper.readTree(mapper.writeValueAsString(ApiResponse.error("boom"))); + + assertThat(json.get("success").asBoolean()).isFalse(); + assertThat(json.get("message").asText()).isEqualTo("boom"); + assertThat(json.get("data").isNull()).isTrue(); + assertThat(json.get("timestamp").asText()).isNotBlank(); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponseTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponseTest.java new file mode 100644 index 0000000..49ad6a6 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponseTest.java @@ -0,0 +1,59 @@ +package com.edgareldy.springjdbctutorial.ws.payload.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Unit tests of PageResponse (total pages arithmetic and size validation). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class PageResponseTest { + + @Test + void _01_ShouldRoundTotalPagesUp_WhenLastPageIsPartial() { + PageResponse page = PageResponse.of(List.of("a", "b"), 0, 2, 5); + + assertThat(page.getTotalPages()).isEqualTo(3); + assertThat(page.getTotalElements()).isEqualTo(5); + assertThat(page.getPage()).isZero(); + assertThat(page.getSize()).isEqualTo(2); + } + + @Test + void _02_ShouldHaveZeroTotalPages_WhenThereAreNoElements() { + assertThat(PageResponse.of(List.of(), 0, 10, 0).getTotalPages()).isZero(); + } + + @Test + void _03_ShouldHaveExactTotalPages_WhenTotalIsAMultipleOfSize() { + assertThat(PageResponse.of(List.of("a"), 0, 5, 10).getTotalPages()).isEqualTo(2); + } + + @Test + void _04_ShouldThrow_WhenSizeIsZero() { + assertThatThrownBy(() -> PageResponse.of(List.of(), 0, 0, 10)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void _05_ShouldThrow_WhenSizeIsNegative() { + assertThatThrownBy(() -> PageResponse.of(List.of(), 0, -3, 10)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void _06_ShouldKeepEmptyContent_WhenPageIsOutOfRange() { + PageResponse page = PageResponse.of(List.of(), 99, 10, 5); + + assertThat(page.getContent()).isNotNull().isEmpty(); + assertThat(page.getPage()).isEqualTo(99); + assertThat(page.getTotalPages()).isEqualTo(1); + } +} From 177723ba54f3053d474b47c7b62cee712ab5eb7d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 020/144] test(ws): verify HTTP error mapping and the health controller with MockMvc MockMvc drives a WebApplicationContext built from a small test config, a test-only controller that throws each case and the real GlobalExceptionHandler. Every error is asserted in the ApiResponse shape: 404, 422, 400 with per-field messages, malformed JSON, constrained request parameters, unknown URL, 405 with its Allow header, 415 and a 500 that does not leak the real message. The health controller is tested with the service mocked. These classes live in ws.mvc because the component scan of WebMvcConfig would otherwise pick up test controllers. --- .../ws/mvc/GlobalExceptionHandlerTest.java | 273 ++++++++++++++++++ .../ws/mvc/HealthControllerTest.java | 121 ++++++++ 2 files changed, 394 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/HealthControllerTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..84a24e3 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java @@ -0,0 +1,273 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.exception.GlobalExceptionHandler; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +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.validation.Validator; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Verifies that GlobalExceptionHandler turns every exception into the ApiResponse error format with + * the right HTTP status, through MockMvc and a test-only controller that throws each case. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// These MVC tests live in ws.mvc, NOT in ws.controller/ws.exception: WebMvcConfig component-scans those +// two packages, and it would pick up the test-only configuration and controllers found on the test classpath. +// MockMvc drives the DispatcherServlet in memory (no HTTP port). @SpringJUnitWebConfig builds a +// WebApplicationContext from the nested TestConfig; webAppContextSetup then wires MockMvc on it, so +// the real handler mappings, argument resolvers, validator and @RestControllerAdvice all run. +@SpringJUnitWebConfig(GlobalExceptionHandlerTest.TestConfig.class) +class GlobalExceptionHandlerTest { + + /** + * Test context: MVC enabled, the real advice, the throwing controller, and the converters and + * validator of the real WebMvcConfig so JSON and validation behave as in production. + */ + @Configuration + @EnableWebMvc + static class TestConfig implements WebMvcConfigurer { + + private final WebMvcConfig real = new WebMvcConfig(); + + @Bean + GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Bean + ThrowingController throwingController() { + return new ThrowingController(); + } + + @Override + public void configureMessageConverters(List> converters) { + real.configureMessageConverters(converters); + } + + @Override + public Validator getValidator() { + return real.getValidator(); + } + } + + /** + * Test-only controller whose endpoints throw or validate to trigger each handler. + */ + @RestController + @RequestMapping("/test") + static class ThrowingController { + + @GetMapping("/not-found") + String notFound() { + throw new ResourceNotFoundException("Category 5 not found"); + } + + @GetMapping("/business") + String business() { + throw new BusinessRuleException("Category is still referenced"); + } + + @GetMapping("/boom") + String boom() { + throw new IllegalStateException("secret internal detail"); + } + + @PostMapping(value = "/body", consumes = MediaType.APPLICATION_JSON_VALUE) + String body(@Valid @RequestBody SampleBody body) { + return body.getName(); + } + + @GetMapping("/param") + String param(@RequestParam int count) { + return String.valueOf(count); + } + + // Constraint annotations directly on a parameter trigger Spring 6.1+ method validation + @GetMapping("/range") + String range(@RequestParam @Min(1) @Max(100) int size) { + return String.valueOf(size); + } + } + + /** + * Request body validated by the test endpoint. + */ + public static class SampleBody { + + @NotBlank + private String name; + + @Min(1) + private int age = 1; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + } + + private final ObjectMapper mapper = new ObjectMapper(); + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + /** Asserts the README error shape and returns the parsed body. */ + private JsonNode assertErrorShape(MvcResult result, int status) throws Exception { + assertThat(result.getResponse().getStatus()).isEqualTo(status); + JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); + assertThat(json.get("success").asBoolean()).isFalse(); + assertThat(json.get("message").asText()).isNotBlank(); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + assertThat(json.get("timestamp").asText()).isNotBlank(); + return json; + } + + @Test + void _01_ShouldReturn404_WhenResourceNotFoundExceptionIsThrown() throws Exception { + MvcResult result = mockMvc.perform(get("/test/not-found")).andReturn(); + + assertThat(assertErrorShape(result, 404).get("message").asText()).isEqualTo("Category 5 not found"); + } + + @Test + void _02_ShouldReturn422_WhenBusinessRuleExceptionIsThrown() throws Exception { + MvcResult result = mockMvc.perform(get("/test/business")).andReturn(); + + assertThat(assertErrorShape(result, 422).get("message").asText()).isEqualTo("Category is still referenced"); + } + + @Test + void _03_ShouldReturn400WithPerFieldMessages_WhenBodyValidationFails() throws Exception { + MvcResult result = mockMvc.perform(post("/test/body") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\" \",\"age\":0}")).andReturn(); + + String message = assertErrorShape(result, 400).get("message").asText(); + assertThat(message).contains("name: must not be blank").contains("age: must be greater than or equal to 1"); + } + + @Test + void _04_ShouldReturn400_WhenJsonIsMalformed() throws Exception { + MvcResult result = mockMvc.perform(post("/test/body") + .contentType(MediaType.APPLICATION_JSON) + .content("{not json")).andReturn(); + + assertThat(assertErrorShape(result, 400).get("message").asText()).isEqualTo("Malformed JSON request"); + } + + @Test + void _05_ShouldReturn400_WhenRequiredParameterIsMissing() throws Exception { + MvcResult result = mockMvc.perform(get("/test/param")).andReturn(); + + assertThat(assertErrorShape(result, 400).get("message").asText()).contains("count"); + } + + @Test + void _06_ShouldReturn400_WhenParameterHasTheWrongType() throws Exception { + MvcResult result = mockMvc.perform(get("/test/param").param("count", "abc")).andReturn(); + + assertThat(assertErrorShape(result, 400).get("message").asText()).contains("count"); + } + + @Test + void _07_ShouldReturn400_WhenParameterIsBelowMin() throws Exception { + MvcResult result = mockMvc.perform(get("/test/range").param("size", "0")).andReturn(); + + assertThat(assertErrorShape(result, 400).get("message").asText()) + .contains("must be greater than or equal to 1"); + } + + @Test + void _08_ShouldReturn400_WhenParameterIsAboveMax() throws Exception { + MvcResult result = mockMvc.perform(get("/test/range").param("size", "101")).andReturn(); + + assertThat(assertErrorShape(result, 400).get("message").asText()) + .contains("must be less than or equal to 100"); + } + + @Test + void _09_ShouldReturn404_WhenUrlIsUnknown() throws Exception { + MvcResult result = mockMvc.perform(get("/test/does-not-exist")).andReturn(); + + assertErrorShape(result, 404); + } + + @Test + void _10_ShouldReturn405WithAllowHeader_WhenMethodIsNotSupported() throws Exception { + MvcResult result = mockMvc.perform(post("/test/not-found")).andReturn(); + + assertErrorShape(result, 405); + assertThat(result.getResponse().getHeader("Allow")).contains("GET"); + } + + @Test + void _11_ShouldReturn415_WhenContentTypeIsNotSupported() throws Exception { + MvcResult result = mockMvc.perform(post("/test/body") + .contentType(MediaType.TEXT_PLAIN) + .content("name")).andReturn(); + + assertErrorShape(result, 415); + } + + @Test + void _12_ShouldReturn500WithGenericMessage_WhenUnexpectedExceptionIsThrown() throws Exception { + MvcResult result = mockMvc.perform(get("/test/boom")).andReturn(); + + JsonNode json = assertErrorShape(result, 500); + assertThat(json.get("message").asText()).isEqualTo("Internal server error"); + assertThat(result.getResponse().getContentAsString()).doesNotContain("secret internal detail"); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/HealthControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/HealthControllerTest.java new file mode 100644 index 0000000..290b4a3 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/HealthControllerTest.java @@ -0,0 +1,121 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import com.edgareldy.springjdbctutorial.core.common.dto.HealthDto; +import com.edgareldy.springjdbctutorial.core.common.service.HealthService; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.controller.HealthController; +import com.edgareldy.springjdbctutorial.ws.exception.GlobalExceptionHandler; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +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; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * MockMvc tests of HealthController with the HealthService mocked (no database). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// Kept out of ws.controller on purpose: the component scan of WebMvcConfig would pick up this test config. +@SpringJUnitWebConfig(HealthControllerTest.TestConfig.class) +class HealthControllerTest { + + /** + * Test context: MVC, the real controller and advice, and a Mockito mock in place of the service. + */ + @Configuration + @EnableWebMvc + static class TestConfig implements WebMvcConfigurer { + + @Bean + HealthService healthService() { + return mock(HealthService.class); + } + + @Bean + HealthController healthController(HealthService healthService) { + return new HealthController(healthService); + } + + @Bean + GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Override + public void configureMessageConverters(List> converters) { + new WebMvcConfig().configureMessageConverters(converters); + } + } + + private final ObjectMapper mapper = new ObjectMapper(); + + @Autowired + private WebApplicationContext context; + + @Autowired + private HealthService healthService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + reset(healthService); + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @Test + void _01_ShouldReturn200WithStatusUp_WhenServiceReportsUp() throws Exception { + when(healthService.check()).thenReturn(new HealthDto("UP", "UP")); + + MvcResult result = mockMvc.perform(get("/api/v1/health")).andReturn(); + + assertThat(result.getResponse().getStatus()).isEqualTo(200); + JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); + assertThat(json.get("success").asBoolean()).isTrue(); + assertThat(json.get("data").get("status").asText()).isEqualTo("UP"); + assertThat(json.get("data").get("database").asText()).isEqualTo("UP"); + assertThat(json.get("timestamp").asText()).isNotBlank(); + } + + @Test + void _02_ShouldExposeStatusDown_WhenServiceReportsDown() throws Exception { + when(healthService.check()).thenReturn(new HealthDto("DOWN", "DOWN")); + + MvcResult result = mockMvc.perform(get("/api/v1/health")).andReturn(); + + JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); + assertThat(json.get("data").get("status").asText()).isEqualTo("DOWN"); + } + + @Test + void _03_ShouldReturn405WithErrorShape_WhenMethodIsPost() throws Exception { + MvcResult result = mockMvc.perform(post("/api/v1/health")).andReturn(); + + assertThat(result.getResponse().getStatus()).isEqualTo(405); + JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); + assertThat(json.get("success").asBoolean()).isFalse(); + } +} From c3bf50a38d7e0b7dee766128143b51c8583952f7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 021/144] test(ws): boot the real WebMvcConfig against PostgreSQL The integration test loads the real WebMvcConfig, hence CommonConfig, DataSourceConfig and Flyway, on a Testcontainers database and checks that the health route answers 200 with status UP and that an unknown URL gives a 404 in the ApiResponse envelope. WebAppInitializerTest uses a mocked ServletContext to verify the registrations: the dispatcher servlet on "/", the ContextLoaderListener and the UTF-8 filter. --- .../ws/config/WebAppInitializerTest.java | 81 +++++++++++++++++++ .../config/WebMvcConfigIntegrationTest.java | 73 +++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java new file mode 100644 index 0000000..fa1304d --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java @@ -0,0 +1,81 @@ +package com.edgareldy.springjdbctutorial.ws.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterRegistration; +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.web.context.ContextLoaderListener; +import org.springframework.web.filter.CharacterEncodingFilter; +import org.springframework.web.servlet.DispatcherServlet; + +/** + * Checks what WebAppInitializer registers on the ServletContext. The context is never refreshed + * (the ServletContext is a Mockito mock), so no database is involved. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class WebAppInitializerTest { + + @Test + void _01_ShouldRegisterDispatcherServletOnRoot_WhenApplicationStarts() { + ServletContext servletContext = mock(ServletContext.class); + ServletRegistration.Dynamic servletRegistration = mock(ServletRegistration.Dynamic.class); + FilterRegistration.Dynamic filterRegistration = mock(FilterRegistration.Dynamic.class); + when(servletContext.addServlet(eq("dispatcher"), any(Servlet.class))).thenReturn(servletRegistration); + when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))) + .thenReturn(filterRegistration); + + new WebAppInitializer().onStartup(servletContext); + + ArgumentCaptor servlet = ArgumentCaptor.forClass(Servlet.class); + verify(servletContext).addServlet(eq("dispatcher"), servlet.capture()); + assertThat(servlet.getValue()).isInstanceOf(DispatcherServlet.class); + verify(servletRegistration).addMapping("/"); + verify(servletRegistration).setLoadOnStartup(1); + } + + @Test + void _02_ShouldRegisterContextLoaderListener_WhenApplicationStarts() { + ServletContext servletContext = mock(ServletContext.class); + when(servletContext.addServlet(eq("dispatcher"), any(Servlet.class))) + .thenReturn(mock(ServletRegistration.Dynamic.class)); + when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))) + .thenReturn(mock(FilterRegistration.Dynamic.class)); + + new WebAppInitializer().onStartup(servletContext); + + ArgumentCaptor listener = ArgumentCaptor.forClass(ContextLoaderListener.class); + verify(servletContext).addListener(listener.capture()); + assertThat(listener.getValue()).isNotNull(); + } + + @Test + void _03_ShouldRegisterUtf8EncodingFilterOnEveryUrl_WhenApplicationStarts() { + ServletContext servletContext = mock(ServletContext.class); + FilterRegistration.Dynamic filterRegistration = mock(FilterRegistration.Dynamic.class); + when(servletContext.addServlet(eq("dispatcher"), any(Servlet.class))) + .thenReturn(mock(ServletRegistration.Dynamic.class)); + when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))) + .thenReturn(filterRegistration); + + new WebAppInitializer().onStartup(servletContext); + + ArgumentCaptor filter = ArgumentCaptor.forClass(Filter.class); + verify(servletContext).addFilter(eq("characterEncodingFilter"), filter.capture()); + assertThat(filter.getValue()).isInstanceOf(CharacterEncodingFilter.class); + verify(filterRegistration).addMappingForUrlPatterns(any(), eq(false), eq("/*")); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java new file mode 100644 index 0000000..a9a4333 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java @@ -0,0 +1,73 @@ +package com.edgareldy.springjdbctutorial.ws.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +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.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; + +/** + * Full-stack integration test: the REAL WebMvcConfig (CommonConfig, DataSourceConfig, Flyway, real + * service and DAO) against the Testcontainers PostgreSQL, driven through MockMvc. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// The PostgreSQL container class comes from the common module test-jar. @DynamicPropertySource injects +// its random JDBC url into the Environment before the real configuration is loaded. +@SpringJUnitWebConfig(WebMvcConfig.class) +class WebMvcConfigIntegrationTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + private final ObjectMapper mapper = new ObjectMapper(); + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @Test + void _01_ShouldReturn200WithStatusUp_WhenHealthRouteIsCalledOnARealDatabase() throws Exception { + MvcResult result = mockMvc.perform(get("/api/v1/health")).andReturn(); + + assertThat(result.getResponse().getStatus()).isEqualTo(200); + JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); + assertThat(json.get("success").asBoolean()).isTrue(); + assertThat(json.get("data").get("status").asText()).isEqualTo("UP"); + assertThat(json.get("timestamp").asText()).isNotBlank(); + } + + @Test + void _02_ShouldReturn404ApiResponse_WhenUrlIsUnknown() throws Exception { + MvcResult result = mockMvc.perform(get("/api/v1/nothing-here")).andReturn(); + + assertThat(result.getResponse().getStatus()).isEqualTo(404); + JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); + assertThat(json.get("success").asBoolean()).isFalse(); + assertThat(json.get("message").asText()).isNotBlank(); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + assertThat(json.get("timestamp").asText()).isNotBlank(); + } +} From b8a2051f0b74b4e7b9217ac5146d62833afdc2fe Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:42:00 +0400 Subject: [PATCH 022/144] build(docker): add a multi-stage Dockerfile for the WAR on Tomcat 10.1 The first stage builds the reactor with Maven, copying the poms first so the dependency download is cached until a pom changes. The second stage is a Tomcat 10.1 JRE 17 image that keeps only the WAR, deployed as spring-jdbc-tutorial.war so the context path matches the URLs of the README. The image runs as an unprivileged user and the build context excludes everything but the sources. Tests are skipped in the image build: the DAO tests need a Docker daemon and run in CI instead. --- .dockerignore | 8 ++++++++ Dockerfile | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d04e138 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Keep the build context small and free of local files (keys, env files, git data) +* +!pom.xml +!core/ +!ws/ +!mvnw +!.mvn/ +**/target/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8979d06 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# Multi-stage image for the packaged WAR. +# Stage 1 compiles and packages with Maven, stage 2 is a Tomcat 10.1 runtime holding only the WAR. + +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /build + +# Copy the poms first so Docker caches the dependency download until a pom changes. +COPY pom.xml ./ +COPY core/pom.xml core/pom.xml +COPY core/common/pom.xml core/common/pom.xml +COPY core/auth/pom.xml core/auth/pom.xml +COPY core/catalog/pom.xml core/catalog/pom.xml +COPY core/customer/pom.xml core/customer/pom.xml +COPY core/order/pom.xml core/order/pom.xml +COPY ws/pom.xml ws/pom.xml +RUN mvn -B -q dependency:go-offline -DskipTests || true + +COPY core ./core +COPY ws ./ws +# Tests are skipped here: the DAO tests need Testcontainers, i.e. a Docker daemon that a build +# stage does not have. They run in CI with mvn verify instead. +RUN mvn -B -q package -DskipTests + +FROM tomcat:10.1-jre17-temurin +# The README documents the API under /spring-jdbc-tutorial/api/v1: the WAR name is the context path. +RUN rm -rf /usr/local/tomcat/webapps/* +COPY --from=build /build/ws/target/ws.war /usr/local/tomcat/webapps/spring-jdbc-tutorial.war + +# Run as a non-root user so a compromised process has no rights on the container. +RUN useradd --system --no-create-home spring \ + && chown -R spring /usr/local/tomcat/webapps /usr/local/tomcat/logs /usr/local/tomcat/temp /usr/local/tomcat/work /usr/local/tomcat/conf +USER spring + +EXPOSE 8080 +CMD ["catalina.sh", "run"] From 78173a678054061127cb3b387052b0d47dce1ab8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:42:00 +0400 Subject: [PATCH 023/144] build(docker): add docker-compose for the WAR and PostgreSQL 16 The compose file runs the packaged application beside a PostgreSQL 16 container with a health check. The database password is mandatory and has no default, the published port is configurable through APP_PORT because 8080 is often taken, and the datasource is passed as environment variables that Spring maps to app.datasource.* properties. It is meant for the packaged WAR only: the tests start their own database through Testcontainers. .env.example lists the variables to copy into .env. --- .env.example | 5 +++++ docker-compose.yml | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 .env.example create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..99ea9d3 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy to .env (ignored by git) and adjust. Read by docker-compose.yml only. +DB_NAME=spring_jdbc_tutorial +DB_USER=spring +DB_PASSWORD=change-me +APP_PORT=8080 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d19c854 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +# Runs the PACKAGED WAR (Tomcat 10.1) with a real PostgreSQL 16. It is not used for the tests: +# the DAO tests start their own PostgreSQL through Testcontainers. +# +# Before the first "docker compose up --build": +# 1. cp .env.example .env and set DB_PASSWORD (there is no default password on purpose) +# 2. from the feature/auth branch on, create a production RSA key pair in ./keys (the keys under +# dev-keys/ are for development only): +# mkdir -p keys +# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out keys/privateKey.pem +# openssl pkey -in keys/privateKey.pem -pubout -out keys/publicKey.pem +# chmod 644 keys/*.pem +# The container runs as an unprivileged user whose uid differs from yours, so the keys must +# be readable by others. The keys directory is ignored by git and mounted read-only. +# +# The API is then served at http://localhost:${APP_PORT}/spring-jdbc-tutorial/api/v1/... + +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: ${DB_NAME:-spring_jdbc_tutorial} + POSTGRES_USER: ${DB_USER:-spring} + POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-spring} -d ${DB_NAME:-spring_jdbc_tutorial}"] + interval: 5s + timeout: 3s + retries: 10 + + app: + build: + context: . + depends_on: + db: + condition: service_healthy + environment: + # Spring reads these through Environment: APP_DATASOURCE_URL is the property app.datasource.url + APP_DATASOURCE_URL: jdbc:postgresql://db:5432/${DB_NAME:-spring_jdbc_tutorial} + APP_DATASOURCE_USERNAME: ${DB_USER:-spring} + APP_DATASOURCE_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + ports: + - "${APP_PORT:-8080}:8080" + +volumes: + db-data: From 351b8546f761feeec5c5bce5f75fbe1774922e33 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:42:00 +0400 Subject: [PATCH 024/144] ci(actions): run mvn verify on the whole reactor The workflow checks out the code, sets up Temurin Java 17 with the Maven cache and runs ./mvnw -B verify on pushes and pull requests to master and develop. There is no services block for PostgreSQL on purpose: the DAO tests start their own container through Testcontainers, using the Docker daemon already present on ubuntu-latest, exactly as they do locally. --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e28ed59 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [master, develop] + pull_request: + branches: [master, develop] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: maven + + # No "services: postgres" block on purpose. The DAO tests start their own PostgreSQL + # container through Testcontainers and the Docker daemon already available on + # ubuntu-latest, exactly as they do locally, so nothing is provisioned by the workflow. + - name: Build and test the whole reactor + run: ./mvnw -B verify From 60dd8f7313970dcf8f0a83400760948157ceca51 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:42:00 +0400 Subject: [PATCH 025/144] ci(actions): lint commit messages against Conventional Commits On every pull request the workflow reads the commits of the range between the base and the head and rejects any subject that does not match type(scope): summary. The scope is mandatory and the summary is limited to 100 characters. Merge commits are skipped, so the merge of a feature branch does not need to follow the convention. --- .github/workflows/pr-checks.yml | 43 +++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/pr-checks.yml diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..8188482 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,43 @@ +name: PR checks + +on: + pull_request: + branches: [master, develop] + +jobs: + commit-lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate commit messages (Conventional Commits) + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + + pattern='^(feat|fix|refactor|test|docs|chore|build|ci|style|perf)\([a-z0-9.,/_-]+\): .{1,100}$' + failed=0 + + while IFS= read -r subject; do + [ -z "$subject" ] && continue + if [[ "$subject" =~ $pattern ]]; then + echo "OK: $subject" + else + echo "INVALID: $subject" + failed=1 + fi + done < <(git log "$BASE_SHA..$HEAD_SHA" --no-merges --pretty=format:'%s') + + if [ "$failed" -ne 0 ]; then + echo "" + echo "One or more commit messages do not follow Conventional Commits." + echo "Expected format: (): " + echo "Allowed types: feat, fix, refactor, test, docs, chore, build, ci, style, perf" + exit 1 + fi From 63b93ca1c878c341921256905cc62cbb534291c9 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:42:00 +0400 Subject: [PATCH 026/144] docs(github): add the pull request template The template asks for the branch name, the ticked task checklist copied from the README, a commit summary and a test plan covering the five test layers and the dedicated fixture files. The review checklist encodes the rules of this project: no business logic in controllers, layer boundaries between entity and dto, ApiResponse everywhere, declarative permissions, no stereotype on DAOs and services, no Lombok, no record, no ORM, audit logging and Flyway as the only schema source. --- .github/PULL_REQUEST_TEMPLATE.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..aa26cfb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ +## Branch + + + +## Task checklist + + + +- [ ] + +## Commit summary + + + +## Test plan + +- [ ] Tests exist at every layer the branch's checklist calls for (DAO, RowMapper, EntityMapper, service, controller) +- [ ] Each DAO test class loads its own dedicated SQL fixture file +- [ ] `./mvnw clean verify` is green on the whole reactor + +## Code review checklist + +- [ ] No business logic in controllers; controllers only see services and `payload/` types +- [ ] DAOs only use `entity/` types, services only `dto/` types at their boundary +- [ ] Every response, success or error, is an `ApiResponse` (errors through `GlobalExceptionHandler`, 401/403 through the security handlers) +- [ ] Permission checks are declarative (`@PreAuthorize("hasPermission(...)")`), no manual permission `if` +- [ ] No `@Repository`, `@Service` or `@Component` on a DAO or service; every bean is an explicit `@Bean` in `DaoConfig`/`ServiceConfig` +- [ ] No Lombok, no `record`, no ORM (`JdbcTemplate` only) +- [ ] Every RBAC mutation calls `AuditLogger` +- [ ] The database schema only changes through a Flyway migration +- [ ] Test methods are named `_NN_Should_When` +- [ ] Commits are atomic, follow Conventional Commits with a scope, no em dash, no tool attribution From 8619f3d2951de2dbe91aceb39f3f6d96703c6d32 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:42:09 +0400 Subject: [PATCH 027/144] docs(readme): tick core-architecture tasks and note two deviations All seven core-architecture tasks are checked. The project structure now states two facts learned while implementing them: OpenApiConfig is not delivered because springdoc-openapi needs Spring Boot classes, and WebMvcConfig scans only the controller and exception packages, the sole stereotype-annotated classes of the project. The README stays the specification, so corrections found in practice are written back into it. --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3175301..2268d3e 100644 --- a/README.md +++ b/README.md @@ -235,9 +235,9 @@ spring-jdbc-tutorial/ │ │ in this project, since Spring MVC requires it for this mechanism) │ └── config/ │ ├── WebAppInitializer.java (WebApplicationInitializer, replaces web.xml) - │ ├── WebMvcConfig.java (@EnableWebMvc, imports every core module's DaoConfig/ServiceConfig) + │ ├── WebMvcConfig.java (@EnableWebMvc, imports every core module's DaoConfig/ServiceConfig, scans only ws.controller and ws.exception) │ ├── SecurityConfig.java (SecurityFilterChain, method security enabling @PreAuthorize) - │ └── OpenApiConfig.java + │ └── OpenApiConfig.java (not delivered: springdoc-openapi relies on Spring Boot classes) └── webapp/ └── (empty - no JSPs, API-only) ├── docker-compose.yml @@ -310,13 +310,13 @@ No other naming style (`shouldX()`, `testX()`, `givenX_whenY_thenZ()`) is used a ### Tasks -- [ ] Parent `pom.xml` (packaging `pom`, modules `core`/`ws`, `dependencyManagement` for Spring Framework 6.2.x, Testcontainers, etc.) -- [ ] `core/pom.xml` (packaging `pom`, modules `common`/`auth`/`catalog`/`customer`/`order`) -- [ ] `core/common`: `ResourceNotFoundException`, `BusinessRuleException`, `AbstractDao` (holds a `JdbcTemplate` reference, offers a couple of shared helpers), `DataSourceConfig` (`DataSource` via HikariCP, `JdbcTemplate`, `DataSourceTransactionManager`) -- [ ] Flyway script `V1__init_schema.sql` (all tables from both domains, including `audit_logs`) -- [ ] `ws` skeleton: `WebAppInitializer` (`WebApplicationInitializer`), `WebMvcConfig` (`@EnableWebMvc`, no module config imported yet), `ApiResponse`, `PageResponse`, `GlobalExceptionHandler` -- [ ] `docker-compose.yml` (`ws` deployed on Tomcat + PostgreSQL), `Dockerfile` (multi-stage: Maven build, Tomcat 10.1 runtime) -- [ ] `.github/workflows/ci.yml`: `mvn verify` across the whole reactor +- [x] Parent `pom.xml` (packaging `pom`, modules `core`/`ws`, `dependencyManagement` for Spring Framework 6.2.x, Testcontainers, etc.) +- [x] `core/pom.xml` (packaging `pom`, modules `common`/`auth`/`catalog`/`customer`/`order`) +- [x] `core/common`: `ResourceNotFoundException`, `BusinessRuleException`, `AbstractDao` (holds a `JdbcTemplate` reference, offers a couple of shared helpers), `DataSourceConfig` (`DataSource` via HikariCP, `JdbcTemplate`, `DataSourceTransactionManager`) +- [x] Flyway script `V1__init_schema.sql` (all tables from both domains, including `audit_logs`) +- [x] `ws` skeleton: `WebAppInitializer` (`WebApplicationInitializer`), `WebMvcConfig` (`@EnableWebMvc`, no module config imported yet), `ApiResponse`, `PageResponse`, `GlobalExceptionHandler` +- [x] `docker-compose.yml` (`ws` deployed on Tomcat + PostgreSQL), `Dockerfile` (multi-stage: Maven build, Tomcat 10.1 runtime) +- [x] `.github/workflows/ci.yml`: `mvn verify` across the whole reactor ## feature/auth From 8122095ea0a7d37089ffdd141f52ce502f7d11f2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:45:58 +0400 Subject: [PATCH 028/144] fix(ws): stop the catch-all from leaking ErrorResponse detail text Spring MVC exceptions that implement ErrorResponse and have no dedicated handler used to reach the catch-all, which copied their detail text into the message, so framework wording could reach the client. The handler now keeps only the status they carry and answers with its standard reason phrase, or a generic message plus an error log for a 5xx. A non-regression test throws an ErrorResponseException carrying a secret detail and checks that only the reason phrase is returned. --- .../ws/exception/GlobalExceptionHandler.java | 15 +++++++++++---- .../ws/mvc/GlobalExceptionHandlerTest.java | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 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 4b4b21f..47ddacc 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 @@ -129,13 +129,20 @@ public ResponseEntity> handleNotAcceptable(HttpMediaTypeNotAcc // AUTH BRANCH: add handlers here for AccessDeniedException (403) and AuthenticationException (401). // They are intentionally absent now: no Spring Security dependency exists yet. - // Catch-all. Other Spring MVC exceptions implementing ErrorResponse keep the status they carry; - // anything else is a 500 with a generic message, the real error is only logged. + // Catch-all. Other Spring MVC exceptions implementing ErrorResponse keep the status they carry but + // never their detail text (framework wording): the client gets the standard reason phrase of the + // status. A 5xx is logged like any unexpected error. Anything else is a 500 with a generic message, + // the real error is only logged. @ExceptionHandler(Exception.class) public ResponseEntity> handleUnexpected(Exception e) { if (e instanceof ErrorResponse errorResponse) { - return build(errorResponse.getStatusCode(), errorResponse.getBody().getDetail() != null - ? errorResponse.getBody().getDetail() : "Request failed"); + HttpStatusCode status = errorResponse.getStatusCode(); + if (status.is5xxServerError()) { + LOG.error("Unexpected error", e); + return build(status, "Internal server error"); + } + HttpStatus known = HttpStatus.resolve(status.value()); + return build(status, known != null ? known.getReasonPhrase() : "Request failed"); } LOG.error("Unexpected error", e); return build(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"); diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java index 84a24e3..149923f 100644 --- a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java @@ -107,6 +107,15 @@ String boom() { throw new IllegalStateException("secret internal detail"); } + // A Spring ErrorResponse exception without a dedicated handler, carrying framework wording + @GetMapping("/error-response") + String errorResponse() { + org.springframework.web.ErrorResponseException e = new org.springframework.web.ErrorResponseException( + org.springframework.http.HttpStatus.CONFLICT); + e.getBody().setDetail("secret framework detail"); + throw e; + } + @PostMapping(value = "/body", consumes = MediaType.APPLICATION_JSON_VALUE) String body(@Valid @RequestBody SampleBody body) { return body.getName(); @@ -270,4 +279,14 @@ void _12_ShouldReturn500WithGenericMessage_WhenUnexpectedExceptionIsThrown() thr assertThat(json.get("message").asText()).isEqualTo("Internal server error"); assertThat(result.getResponse().getContentAsString()).doesNotContain("secret internal detail"); } + + // Non-regression: the catch-all used to copy the detail text of any ErrorResponse to the client + @Test + void _13_ShouldReturnReasonPhraseOnly_WhenAnErrorResponseExceptionHasNoDedicatedHandler() throws Exception { + MvcResult result = mockMvc.perform(get("/test/error-response")).andReturn(); + + JsonNode json = assertErrorShape(result, 409); + assertThat(json.get("message").asText()).isEqualTo("Conflict"); + assertThat(result.getResponse().getContentAsString()).doesNotContain("secret framework detail"); + } } From 31f4f3cc32651bd9c5ddf28eacca41307dfb44d7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:45:58 +0400 Subject: [PATCH 029/144] fix(ws): register the validator as a bean and keep default converters The LocalValidatorFactoryBean is now a @Bean, so Spring manages its lifecycle and injects the context, and getValidator() returns that same instance instead of building a new unmanaged one on every call. The Jackson customisation moved from configureMessageConverters to extendMessageConverters: the default converters (String, byte[], Resource) are kept and only the default Jackson converter is replaced by the one that writes Instants as ISO-8601 text. --- .../ws/config/WebMvcConfig.java | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index e1723cc..1c93d58 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.edgareldy.springjdbctutorial.core.common.config.CommonConfig; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -39,23 +40,29 @@ @ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"}) public class WebMvcConfig implements WebMvcConfigurer { - // Replaces the default converters with a single Jackson one. The builder registers the JSR-310 - // module (jackson-datatype-jsr310) found on the classpath; disabling WRITE_DATES_AS_TIMESTAMPS - // makes Instant serialise as ISO-8601 text instead of a number. Null fields stay in the output. + // Swaps the default Jackson converter for one built on our ObjectMapper and keeps every other + // default converter (String, byte[], Resource...). The builder registers the JSR-310 module + // (jackson-datatype-jsr310) found on the classpath; disabling WRITE_DATES_AS_TIMESTAMPS makes + // Instant serialise as ISO-8601 text instead of a number. Null fields stay in the output. @Override - public void configureMessageConverters(List> converters) { + public void extendMessageConverters(List> converters) { ObjectMapper mapper = Jackson2ObjectMapperBuilder.json() .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build(); - converters.add(new MappingJackson2HttpMessageConverter(mapper)); + converters.removeIf(MappingJackson2HttpMessageConverter.class::isInstance); + converters.add(0, new MappingJackson2HttpMessageConverter(mapper)); } // LocalValidatorFactoryBean bootstraps Bean Validation (Hibernate Validator on the classpath). - // Returning it from getValidator() makes @Valid on request bodies use it. + // Declared as a bean so Spring manages its lifecycle and injects the context; getValidator() + // returns that same instance so @Valid on request bodies uses it. + @Bean + public LocalValidatorFactoryBean validator() { + return new LocalValidatorFactoryBean(); + } + @Override public Validator getValidator() { - LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); - validator.afterPropertiesSet(); - return validator; + return validator(); } } From 9473c7c75d9103e3a0f50f767b648414a3baf5ea Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:45:58 +0400 Subject: [PATCH 030/144] fix(common): log the cause when the health check fails HealthServiceImpl swallowed the DataAccessException and reported a bare DOWN, leaving an operator blind to the reason (bad credentials, network, exhausted pool). It now logs a warning with the exception before answering DOWN. The logger is Spring's own commons-logging facade (spring-jcl), so core still needs no logging dependency: the ws module picks the backend. --- .../core/common/service/impl/HealthServiceImpl.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java index e184677..8d4cb34 100644 --- a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java @@ -3,6 +3,8 @@ import com.edgareldy.springjdbctutorial.core.common.dao.HealthDao; import com.edgareldy.springjdbctutorial.core.common.dto.HealthDto; import com.edgareldy.springjdbctutorial.core.common.service.HealthService; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.dao.DataAccessException; /** @@ -15,6 +17,9 @@ */ public class HealthServiceImpl implements HealthService { + // Spring's own logging facade (spring-jcl): core needs no logging dependency, ws chooses the backend + private static final Log LOG = LogFactory.getLog(HealthServiceImpl.class); + private final HealthDao healthDao; public HealthServiceImpl(HealthDao healthDao) { @@ -28,7 +33,9 @@ public HealthDto check() { return new HealthDto("UP", "UP"); } } catch (DataAccessException e) { - // JdbcTemplate translates SQLException into DataAccessException: database unreachable + // JdbcTemplate translates SQLException into DataAccessException: database unreachable. + // Logged so an operator sees the cause (bad credentials, network, exhausted pool). + LOG.warn("Database health check failed", e); } return new HealthDto("DOWN", "DOWN"); } From a5f971e9e8d837aa1aee56522976ec4bfbc56eee Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 031/144] build(auth): add spring-security-crypto for password hashing The auth module needs only the password encoders of Spring Security, not the whole security stack, so it depends on the small spring-security-crypto artifact. Its version comes from the Spring Security BOM already imported by the parent pom. Keeping the web-facing Spring Security modules in ws preserves the rule that core knows nothing about HTTP. --- core/auth/pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/auth/pom.xml b/core/auth/pom.xml index def61c8..d1a4560 100644 --- a/core/auth/pom.xml +++ b/core/auth/pom.xml @@ -19,5 +19,10 @@ com.edgareldy.springjdbctutorial common + + + org.springframework.security + spring-security-crypto + From 67daef90f67d99fc860816b5e4e4f6bd93318335 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 032/144] feat(common): declare a Clock bean shared by every service Services that compare or stamp times take a java.time.Clock instead of calling Instant.now(). The production bean is Clock.systemUTC(), and a test can build the service with a fixed clock to check expiry rules exactly. It is declared once in CommonConfig, next to the other infrastructure beans, so every module gets the same time source. --- .../core/common/config/CommonConfig.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java index 1034a89..2b95c2e 100644 --- a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java @@ -9,6 +9,8 @@ import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate; +import java.time.Clock; + /** * Wiring of the common module: imports the infrastructure and declares the health DAO and service. *

@@ -33,4 +35,12 @@ public HealthDao healthDao(JdbcTemplate jdbcTemplate) { public HealthService healthService(HealthDao healthDao) { return new HealthServiceImpl(healthDao); } + + // A Clock bean is the injectable source of "now": services call clock.instant() instead of + // Instant.now(), so tests can supply Clock.fixed(...) and assert expiry logic deterministically. + // UTC because timestamps are stored as timestamptz and never depend on the server zone. + @Bean + public Clock clock() { + return Clock.systemUTC(); + } } From e2667be7991869dbc9ab076b36ebc84755971cd5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 033/144] feat(auth): add the User and token entities Entities are the raw, table-shaped view of a row: one class per table, annotated with jakarta.persistence @Table, @Id and @Column. Those annotations are documentation only, since no JPA provider exists in this project, they simply declare the table and column mapping next to the class. User, ActivationToken, BlacklistedToken and PasswordResetToken are plain classes with explicit accessors and equals/hashCode. Anything carrying a password hash or a token digest masks it in toString so it never reaches a log. --- .../core/auth/entity/ActivationToken.java | 132 +++++++++++++++ .../core/auth/entity/BlacklistedToken.java | 160 ++++++++++++++++++ .../core/auth/entity/PasswordResetToken.java | 118 +++++++++++++ .../core/auth/entity/User.java | 146 ++++++++++++++++ 4 files changed, 556 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/ActivationToken.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/BlacklistedToken.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/PasswordResetToken.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/User.java diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/ActivationToken.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/ActivationToken.java new file mode 100644 index 0000000..382bb53 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/ActivationToken.java @@ -0,0 +1,132 @@ +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 activation_tokens table. The token column holds the SHA-256 hex digest, never the raw token. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "activation_tokens") +public class ActivationToken { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "token") + private String token; + + @Column(name = "created_at") + private Instant createdAt; + + @Column(name = "expires_at") + private Instant expiresAt; + + @Column(name = "validated_at") + private Instant validatedAt; + + public ActivationToken() { + } + + public ActivationToken(Long id, Long userId, String token, Instant createdAt, Instant expiresAt, Instant validatedAt) { + this.id = id; + this.userId = userId; + this.token = token; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.validatedAt = validatedAt; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public Instant getValidatedAt() { + return validatedAt; + } + + public void setValidatedAt(Instant validatedAt) { + this.validatedAt = validatedAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ActivationToken that = (ActivationToken) o; + return Objects.equals(id, that.id) + && Objects.equals(userId, that.userId) + && Objects.equals(token, that.token) + && Objects.equals(createdAt, that.createdAt) + && Objects.equals(expiresAt, that.expiresAt) + && Objects.equals(validatedAt, that.validatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, userId, token, createdAt, expiresAt, validatedAt); + } + + @Override + public String toString() { + return "ActivationToken{" + + "id=" + id + + ", userId=" + userId + + ", token=" + (token == null ? "null" : "****") + + ", createdAt=" + createdAt + + ", expiresAt=" + expiresAt + + ", validatedAt=" + validatedAt + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/BlacklistedToken.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/BlacklistedToken.java new file mode 100644 index 0000000..233affd --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/BlacklistedToken.java @@ -0,0 +1,160 @@ +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 blacklisted_tokens table (revoked JWT). The token column holds the SHA-256 hex digest of the JWT, never the JWT. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "blacklisted_tokens") +public class BlacklistedToken { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "token") + private String token; + + @Column(name = "jti") + private String jti; + + @Column(name = "blacklisted_at") + private Instant blacklistedAt; + + @Column(name = "created_at") + private Instant createdAt; + + @Column(name = "expires_at") + private Instant expiresAt; + + @Column(name = "validated_at") + private Instant validatedAt; + + public BlacklistedToken() { + } + + public BlacklistedToken(Long id, Long userId, String token, String jti, Instant blacklistedAt, Instant createdAt, Instant expiresAt, Instant validatedAt) { + this.id = id; + this.userId = userId; + this.token = token; + this.jti = jti; + this.blacklistedAt = blacklistedAt; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.validatedAt = validatedAt; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getJti() { + return jti; + } + + public void setJti(String jti) { + this.jti = jti; + } + + public Instant getBlacklistedAt() { + return blacklistedAt; + } + + public void setBlacklistedAt(Instant blacklistedAt) { + this.blacklistedAt = blacklistedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public Instant getValidatedAt() { + return validatedAt; + } + + public void setValidatedAt(Instant validatedAt) { + this.validatedAt = validatedAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BlacklistedToken that = (BlacklistedToken) o; + return Objects.equals(id, that.id) + && Objects.equals(userId, that.userId) + && Objects.equals(token, that.token) + && Objects.equals(jti, that.jti) + && Objects.equals(blacklistedAt, that.blacklistedAt) + && Objects.equals(createdAt, that.createdAt) + && Objects.equals(expiresAt, that.expiresAt) + && Objects.equals(validatedAt, that.validatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, userId, token, jti, blacklistedAt, createdAt, expiresAt, validatedAt); + } + + @Override + public String toString() { + return "BlacklistedToken{" + + "id=" + id + + ", userId=" + userId + + ", token=" + (token == null ? "null" : "****") + + ", jti=" + jti + + ", blacklistedAt=" + blacklistedAt + + ", createdAt=" + createdAt + + ", expiresAt=" + expiresAt + + ", validatedAt=" + validatedAt + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/PasswordResetToken.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/PasswordResetToken.java new file mode 100644 index 0000000..afb40fd --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/PasswordResetToken.java @@ -0,0 +1,118 @@ +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 password_reset_tokens table. The token column holds the SHA-256 hex digest, never the raw token. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "password_reset_tokens") +public class PasswordResetToken { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "token") + private String token; + + @Column(name = "type") + private String type; + + @Column(name = "expiry_date") + private Instant expiryDate; + + public PasswordResetToken() { + } + + public PasswordResetToken(Long id, Long userId, String token, String type, Instant expiryDate) { + this.id = id; + this.userId = userId; + this.token = token; + this.type = type; + this.expiryDate = expiryDate; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Instant getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(Instant expiryDate) { + this.expiryDate = expiryDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PasswordResetToken that = (PasswordResetToken) o; + return Objects.equals(id, that.id) + && Objects.equals(userId, that.userId) + && Objects.equals(token, that.token) + && Objects.equals(type, that.type) + && Objects.equals(expiryDate, that.expiryDate); + } + + @Override + public int hashCode() { + return Objects.hash(id, userId, token, type, expiryDate); + } + + @Override + public String toString() { + return "PasswordResetToken{" + + "id=" + id + + ", userId=" + userId + + ", token=" + (token == null ? "null" : "****") + + ", type=" + type + + ", expiryDate=" + expiryDate + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/User.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/User.java new file mode 100644 index 0000000..1b05f13 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/User.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.util.Objects; + +/** + * Row of the users table. The password field holds the bcrypt hash, never a clear password. + * 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 = "users") +public class User { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "first_name") + private String firstName; + + @Column(name = "last_name") + private String lastName; + + @Column(name = "email") + private String email; + + @Column(name = "password") + private String password; + + @Column(name = "enabled") + private boolean enabled; + + @Column(name = "account_locked") + private boolean accountLocked; + + public User() { + } + + public User(Long id, String firstName, String lastName, String email, String password, boolean enabled, boolean accountLocked) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.password = password; + this.enabled = enabled; + this.accountLocked = accountLocked; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isAccountLocked() { + return accountLocked; + } + + public void setAccountLocked(boolean accountLocked) { + this.accountLocked = accountLocked; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + User that = (User) o; + return Objects.equals(id, that.id) + && Objects.equals(firstName, that.firstName) + && Objects.equals(lastName, that.lastName) + && Objects.equals(email, that.email) + && Objects.equals(password, that.password) + && enabled == that.enabled + && accountLocked == that.accountLocked; + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, email, password, enabled, accountLocked); + } + + @Override + public String toString() { + return "User{" + + "id=" + id + + ", firstName=" + firstName + + ", lastName=" + lastName + + ", email=" + email + + ", password=" + (password == null ? "null" : "****") + + ", enabled=" + enabled + + ", accountLocked=" + accountLocked + + "}"; + } +} From df45c511d725bb3ba72086f6de2d472ec040d2cc Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 034/144] feat(auth): add the auth dto classes returned and accepted by services DTOs are the business shape a service exposes, a genuinely separate class from the entity even when the fields match. UserDto carries the raw password on input only and always null on output, plus the role names and the RESOURCE:ACTION permission codes of the user. BlacklistedTokenDto describes a token to revoke at logout. All of them mask passwords and tokens in toString. --- .../core/auth/dto/ActivationTokenDto.java | 121 +++++++++++++ .../core/auth/dto/BlacklistedTokenDto.java | 108 ++++++++++++ .../core/auth/dto/PasswordResetTokenDto.java | 108 ++++++++++++ .../core/auth/dto/UserDto.java | 161 ++++++++++++++++++ 4 files changed, 498 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/ActivationTokenDto.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/BlacklistedTokenDto.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/PasswordResetTokenDto.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/UserDto.java diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/ActivationTokenDto.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/ActivationTokenDto.java new file mode 100644 index 0000000..f835ce5 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/ActivationTokenDto.java @@ -0,0 +1,121 @@ +package com.edgareldy.springjdbctutorial.core.auth.dto; + +import java.time.Instant; +import java.util.Objects; + +/** + * Business shape of an activation token (the token field is the hash, never the raw value). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class ActivationTokenDto { + + private Long id; + + private Long userId; + + private String token; + + private Instant createdAt; + + private Instant expiresAt; + + private Instant validatedAt; + + public ActivationTokenDto() { + } + + public ActivationTokenDto(Long id, Long userId, String token, Instant createdAt, Instant expiresAt, Instant validatedAt) { + this.id = id; + this.userId = userId; + this.token = token; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.validatedAt = validatedAt; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public Instant getValidatedAt() { + return validatedAt; + } + + public void setValidatedAt(Instant validatedAt) { + this.validatedAt = validatedAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ActivationTokenDto that = (ActivationTokenDto) o; + return Objects.equals(id, that.id) + && Objects.equals(userId, that.userId) + && Objects.equals(token, that.token) + && Objects.equals(createdAt, that.createdAt) + && Objects.equals(expiresAt, that.expiresAt) + && Objects.equals(validatedAt, that.validatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, userId, token, createdAt, expiresAt, validatedAt); + } + + @Override + public String toString() { + return "ActivationTokenDto{" + + "id=" + id + + ", userId=" + userId + + ", token=" + (token == null ? "null" : "****") + + ", createdAt=" + createdAt + + ", expiresAt=" + expiresAt + + ", validatedAt=" + validatedAt + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/BlacklistedTokenDto.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/BlacklistedTokenDto.java new file mode 100644 index 0000000..f6be65e --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/BlacklistedTokenDto.java @@ -0,0 +1,108 @@ +package com.edgareldy.springjdbctutorial.core.auth.dto; + +import java.time.Instant; +import java.util.Objects; + +/** + * Input of a logout: the SHA-256 hash of the presented JWT, its jti and its validity window. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class BlacklistedTokenDto { + + private Long userId; + + private String tokenHash; + + private String jti; + + private Instant issuedAt; + + private Instant expiresAt; + + public BlacklistedTokenDto() { + } + + public BlacklistedTokenDto(Long userId, String tokenHash, String jti, Instant issuedAt, Instant expiresAt) { + this.userId = userId; + this.tokenHash = tokenHash; + this.jti = jti; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getTokenHash() { + return tokenHash; + } + + public void setTokenHash(String tokenHash) { + this.tokenHash = tokenHash; + } + + public String getJti() { + return jti; + } + + public void setJti(String jti) { + this.jti = jti; + } + + public Instant getIssuedAt() { + return issuedAt; + } + + public void setIssuedAt(Instant issuedAt) { + this.issuedAt = issuedAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BlacklistedTokenDto that = (BlacklistedTokenDto) o; + return Objects.equals(userId, that.userId) + && Objects.equals(tokenHash, that.tokenHash) + && Objects.equals(jti, that.jti) + && Objects.equals(issuedAt, that.issuedAt) + && Objects.equals(expiresAt, that.expiresAt); + } + + @Override + public int hashCode() { + return Objects.hash(userId, tokenHash, jti, issuedAt, expiresAt); + } + + @Override + public String toString() { + return "BlacklistedTokenDto{" + + "userId=" + userId + + ", tokenHash=" + (tokenHash == null ? "null" : "****") + + ", jti=" + jti + + ", issuedAt=" + issuedAt + + ", expiresAt=" + expiresAt + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/PasswordResetTokenDto.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/PasswordResetTokenDto.java new file mode 100644 index 0000000..7660e8b --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/PasswordResetTokenDto.java @@ -0,0 +1,108 @@ +package com.edgareldy.springjdbctutorial.core.auth.dto; + +import java.time.Instant; +import java.util.Objects; + +/** + * Business shape of a password reset token (the token field is the hash, never the raw value). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PasswordResetTokenDto { + + private Long id; + + private Long userId; + + private String token; + + private String type; + + private Instant expiryDate; + + public PasswordResetTokenDto() { + } + + public PasswordResetTokenDto(Long id, Long userId, String token, String type, Instant expiryDate) { + this.id = id; + this.userId = userId; + this.token = token; + this.type = type; + this.expiryDate = expiryDate; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Instant getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(Instant expiryDate) { + this.expiryDate = expiryDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PasswordResetTokenDto that = (PasswordResetTokenDto) o; + return Objects.equals(id, that.id) + && Objects.equals(userId, that.userId) + && Objects.equals(token, that.token) + && Objects.equals(type, that.type) + && Objects.equals(expiryDate, that.expiryDate); + } + + @Override + public int hashCode() { + return Objects.hash(id, userId, token, type, expiryDate); + } + + @Override + public String toString() { + return "PasswordResetTokenDto{" + + "id=" + id + + ", userId=" + userId + + ", token=" + (token == null ? "null" : "****") + + ", type=" + type + + ", expiryDate=" + expiryDate + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/UserDto.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/UserDto.java new file mode 100644 index 0000000..680a588 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/UserDto.java @@ -0,0 +1,161 @@ +package com.edgareldy.springjdbctutorial.core.auth.dto; + +import java.util.List; +import java.util.Objects; + +/** + * Business shape of a user. On input the password carries the RAW password, on output it is always null. + * Roles are role names, permissions are "RESOURCE:ACTION" codes; both 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 UserDto { + + private Long id; + + private String firstName; + + private String lastName; + + private String email; + + private String password; + + private boolean enabled; + + private boolean accountLocked; + + private List roles; + + private List permissions; + + public UserDto() { + } + + public UserDto(Long id, String firstName, String lastName, String email, String password, boolean enabled, boolean accountLocked, List roles, List permissions) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.password = password; + this.enabled = enabled; + this.accountLocked = accountLocked; + this.roles = roles; + this.permissions = permissions; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isAccountLocked() { + return accountLocked; + } + + public void setAccountLocked(boolean accountLocked) { + this.accountLocked = accountLocked; + } + + public List getRoles() { + return roles; + } + + public void setRoles(List roles) { + this.roles = roles; + } + + 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; + } + UserDto that = (UserDto) o; + return Objects.equals(id, that.id) + && Objects.equals(firstName, that.firstName) + && Objects.equals(lastName, that.lastName) + && Objects.equals(email, that.email) + && Objects.equals(password, that.password) + && enabled == that.enabled + && accountLocked == that.accountLocked + && Objects.equals(roles, that.roles) + && Objects.equals(permissions, that.permissions); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, email, password, enabled, accountLocked, roles, permissions); + } + + @Override + public String toString() { + return "UserDto{" + + "id=" + id + + ", firstName=" + firstName + + ", lastName=" + lastName + + ", email=" + email + + ", password=" + (password == null ? "null" : "****") + + ", enabled=" + enabled + + ", accountLocked=" + accountLocked + + ", roles=" + roles + + ", permissions=" + permissions + + "}"; + } +} From 3a798ec86a39f7c56ec98d386d8a4f4b58f7dcca Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 035/144] feat(auth): add the RowMappers turning ResultSet rows into entities A RowMapper is the JdbcTemplate callback that converts one ResultSet row into one object. Each mapper reads its columns by name and does nothing else: no query, no business rule. timestamptz columns are read with getTimestamp and converted to Instant through a small null-safe helper, because validated_at is NULL until an activation token is consumed. --- .../auth/mapper/ActivationTokenRowMapper.java | 29 +++++++++++++++++ .../mapper/BlacklistedTokenRowMapper.java | 31 +++++++++++++++++++ .../mapper/PasswordResetTokenRowMapper.java | 28 +++++++++++++++++ .../core/auth/mapper/RowMapperSupport.java | 28 +++++++++++++++++ .../core/auth/mapper/UserRowMapper.java | 30 ++++++++++++++++++ 5 files changed, 146 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapper.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapper.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapper.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RowMapperSupport.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapper.java diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapper.java new file mode 100644 index 0000000..a430aa6 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapper.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the activation_tokens table into an ActivationToken entity (validated_at may be NULL). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class ActivationTokenRowMapper implements RowMapper { + + @Override + public ActivationToken mapRow(ResultSet rs, int rowNum) throws SQLException { + return new ActivationToken( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getString("token"), + RowMapperSupport.instant(rs, "created_at"), + RowMapperSupport.instant(rs, "expires_at"), + RowMapperSupport.instant(rs, "validated_at")); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapper.java new file mode 100644 index 0000000..4f6a71d --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapper.java @@ -0,0 +1,31 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the blacklisted_tokens table into a BlacklistedToken entity (validated_at may be NULL). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class BlacklistedTokenRowMapper implements RowMapper { + + @Override + public BlacklistedToken mapRow(ResultSet rs, int rowNum) throws SQLException { + return new BlacklistedToken( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getString("token"), + rs.getString("jti"), + RowMapperSupport.instant(rs, "blacklisted_at"), + RowMapperSupport.instant(rs, "created_at"), + RowMapperSupport.instant(rs, "expires_at"), + RowMapperSupport.instant(rs, "validated_at")); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapper.java new file mode 100644 index 0000000..efd8651 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapper.java @@ -0,0 +1,28 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the password_reset_tokens table into a PasswordResetToken entity. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PasswordResetTokenRowMapper implements RowMapper { + + @Override + public PasswordResetToken mapRow(ResultSet rs, int rowNum) throws SQLException { + return new PasswordResetToken( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getString("token"), + rs.getString("type"), + RowMapperSupport.instant(rs, "expiry_date")); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RowMapperSupport.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RowMapperSupport.java new file mode 100644 index 0000000..f9decfd --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RowMapperSupport.java @@ -0,0 +1,28 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; + +/** + * Shared null-safe column readers for the row mappers of this module. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public final class RowMapperSupport { + + private RowMapperSupport() { + } + + /** + * Reads a timestamptz column as an Instant, or null when the column is SQL NULL. + */ + public static Instant instant(ResultSet rs, String column) throws SQLException { + Timestamp value = rs.getTimestamp(column); + return value == null ? null : value.toInstant(); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapper.java new file mode 100644 index 0000000..c4376c7 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapper.java @@ -0,0 +1,30 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the users table into a User entity. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class UserRowMapper implements RowMapper { + + @Override + public User mapRow(ResultSet rs, int rowNum) throws SQLException { + return new User( + rs.getLong("id"), + rs.getString("first_name"), + rs.getString("last_name"), + rs.getString("email"), + rs.getString("password"), + rs.getBoolean("enabled"), + rs.getBoolean("account_locked")); + } +} From 29736cda5e26703f089c1b8145c6a30f76c13106 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 036/144] feat(auth): add UserMapper between the User entity and UserDto The entity mapper is the only place where the two shapes are converted, in both directions. toDto never copies the password hash, and toEntity takes the already hashed password as an explicit argument, so a raw password can never be written into an entity by accident. Roles and permissions are not columns of users: the service fills them in from separate queries. --- .../core/auth/mapper/UserMapper.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapper.java diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapper.java new file mode 100644 index 0000000..c8d218c --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapper.java @@ -0,0 +1,40 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; + +import java.util.ArrayList; + +/** + * Converts between the User entity and the UserDto. The password hash is never copied to a dto; + * roles and 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 UserMapper { + + /** + * Entity to dto: password is always null, roles and permissions start empty. + */ + public UserDto toDto(User user) { + if (user == null) { + return null; + } + return new UserDto(user.getId(), user.getFirstName(), user.getLastName(), user.getEmail(), null, + user.isEnabled(), user.isAccountLocked(), new ArrayList<>(), new ArrayList<>()); + } + + /** + * Dto to entity: the caller supplies the bcrypt hash, the dto's raw password is never read here. + */ + public User toEntity(UserDto dto, String passwordHash) { + if (dto == null) { + return null; + } + return new User(dto.getId(), dto.getFirstName(), dto.getLastName(), dto.getEmail(), passwordHash, + dto.isEnabled(), dto.isAccountLocked()); + } +} From a465d4396b730bc88387b61fa37a77997333a135 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 037/144] feat(auth): add UserDao and its JdbcTemplate implementation The DAO owns the SQL and works only in entity types. Lookups by email compare LOWER(email) to match the case-insensitive unique index, insert uses INSERT ... RETURNING id to get the generated key in one round trip, and the permission query joins role_user, role_permission and permissions to return distinct RESOURCE:ACTION codes. The implementation extends AbstractDao and has no stereotype annotation: it is declared by an explicit @Bean in DaoConfig. --- .../core/auth/dao/UserDao.java | 37 +++++++++ .../core/auth/dao/impl/UserDaoImpl.java | 83 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImpl.java 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 new file mode 100644 index 0000000..044f1a0 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java @@ -0,0 +1,37 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao; + +import com.edgareldy.springjdbctutorial.core.auth.entity.User; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for users and for the read-only role/permission lookups of a user. Entity types only. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface UserDao { + + Optional findById(Long id); + + /** Case-insensitive lookup. */ + Optional findByEmail(String email); + + boolean existsByEmail(String email); + + /** Inserts the user and returns it with its generated id. */ + User insert(User user); + + void updatePassword(Long userId, String passwordHash); + + void updateEnabled(Long userId, boolean enabled); + + /** Names of the roles assigned to the user, sorted. */ + List findRoleNamesByUserId(Long userId); + + /** Distinct "RESOURCE:ACTION" codes granted through the user's roles, sorted. */ + List findPermissionCodesByUserId(Long userId); +} 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 new file mode 100644 index 0000000..16e2bd8 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImpl.java @@ -0,0 +1,83 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import com.edgareldy.springjdbctutorial.core.auth.mapper.UserRowMapper; +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 UserDao. 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 UserDaoImpl extends AbstractDao implements UserDao { + + private static final String COLUMNS = "id, first_name, last_name, email, password, enabled, account_locked"; + + private final UserRowMapper rowMapper = new UserRowMapper(); + + public UserDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public Optional findById(Long id) { + return findOne("SELECT " + COLUMNS + " FROM users WHERE id = ?", rowMapper, id); + } + + @Override + public Optional findByEmail(String email) { + return findOne("SELECT " + COLUMNS + " FROM users WHERE LOWER(email) = LOWER(?)", rowMapper, email); + } + + @Override + public boolean existsByEmail(String email) { + return count("SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER(?)", email) > 0; + } + + @Override + public User insert(User user) { + long id = insertReturningId( + "INSERT INTO users (first_name, last_name, email, password, enabled, account_locked) " + + "VALUES (?, ?, ?, ?, ?, ?) RETURNING id", + user.getFirstName(), user.getLastName(), user.getEmail(), user.getPassword(), + user.isEnabled(), user.isAccountLocked()); + user.setId(id); + return user; + } + + @Override + public void updatePassword(Long userId, String passwordHash) { + getJdbcTemplate().update("UPDATE users SET password = ? WHERE id = ?", passwordHash, userId); + } + + @Override + public void updateEnabled(Long userId, boolean enabled) { + getJdbcTemplate().update("UPDATE users SET enabled = ? WHERE id = ?", enabled, userId); + } + + @Override + public List findRoleNamesByUserId(Long userId) { + return getJdbcTemplate().queryForList( + "SELECT r.role_name FROM roles r JOIN role_user ru ON ru.role_id = r.id " + + "WHERE ru.user_id = ? ORDER BY r.role_name", + String.class, userId); + } + + @Override + public List findPermissionCodesByUserId(Long userId) { + return getJdbcTemplate().queryForList( + "SELECT DISTINCT p.resource || ':' || p.action AS code FROM permissions p " + + "JOIN role_permission rp ON rp.permission_id = p.id " + + "JOIN role_user ru ON ru.role_id = rp.role_id " + + "WHERE ru.user_id = ? ORDER BY code", + String.class, userId); + } +} From d3879d4c1862368c003c74a0a08a37831f49a1ff Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 038/144] feat(auth): add the token DAOs Activation and password reset tokens are looked up by their SHA-256 digest, which is the only form ever stored. The blacklist DAO only probes, inserts and purges: existsByTokenHash answers the per-request revocation check and deleteExpiredBefore backs the scheduled cleanup. All three run plain JdbcTemplate SQL through AbstractDao, bind Instants with java.sql.Timestamp and work only in entity types. --- .../core/auth/dao/ActivationTokenDao.java | 25 ++++++++ .../core/auth/dao/BlacklistedTokenDao.java | 23 +++++++ .../core/auth/dao/PasswordResetTokenDao.java | 22 +++++++ .../auth/dao/impl/ActivationTokenDaoImpl.java | 60 +++++++++++++++++++ .../dao/impl/BlacklistedTokenDaoImpl.java | 52 ++++++++++++++++ .../dao/impl/PasswordResetTokenDaoImpl.java | 48 +++++++++++++++ 6 files changed, 230 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/ActivationTokenDao.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/BlacklistedTokenDao.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PasswordResetTokenDao.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImpl.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImpl.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImpl.java diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/ActivationTokenDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/ActivationTokenDao.java new file mode 100644 index 0000000..fe269b2 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/ActivationTokenDao.java @@ -0,0 +1,25 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao; + +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; + +import java.time.Instant; +import java.util.Optional; + +/** + * Data access for activation tokens (stored as SHA-256 hash). Entity types only. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface ActivationTokenDao { + + ActivationToken insert(ActivationToken token); + + Optional findByTokenHash(String tokenHash); + + void markValidated(Long id, Instant validatedAt); + + void deleteByUserId(Long userId); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/BlacklistedTokenDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/BlacklistedTokenDao.java new file mode 100644 index 0000000..56b7d3d --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/BlacklistedTokenDao.java @@ -0,0 +1,23 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao; + +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; + +import java.time.Instant; + +/** + * Data access for revoked JWTs (stored as SHA-256 hash). Entity types only. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface BlacklistedTokenDao { + + BlacklistedToken insert(BlacklistedToken token); + + boolean existsByTokenHash(String tokenHash); + + /** Deletes the rows whose expires_at is strictly before the given instant and returns how many. */ + int deleteExpiredBefore(Instant instant); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PasswordResetTokenDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PasswordResetTokenDao.java new file mode 100644 index 0000000..68661f8 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PasswordResetTokenDao.java @@ -0,0 +1,22 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao; + +import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken; + +import java.util.Optional; + +/** + * Data access for password reset tokens (stored as SHA-256 hash). Entity types only. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface PasswordResetTokenDao { + + PasswordResetToken insert(PasswordResetToken token); + + Optional findByTokenHash(String tokenHash); + + void deleteByUserId(Long userId); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImpl.java new file mode 100644 index 0000000..141933c --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImpl.java @@ -0,0 +1,60 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; +import com.edgareldy.springjdbctutorial.core.auth.mapper.ActivationTokenRowMapper; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Optional; + +/** + * JdbcTemplate implementation of ActivationTokenDao. 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 ActivationTokenDaoImpl extends AbstractDao implements ActivationTokenDao { + + private final ActivationTokenRowMapper rowMapper = new ActivationTokenRowMapper(); + + public ActivationTokenDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public ActivationToken insert(ActivationToken token) { + long id = insertReturningId( + "INSERT INTO activation_tokens (user_id, token, created_at, expires_at, validated_at) " + + "VALUES (?, ?, ?, ?, ?) RETURNING id", + token.getUserId(), token.getToken(), toTimestamp(token.getCreatedAt()), + toTimestamp(token.getExpiresAt()), toTimestamp(token.getValidatedAt())); + token.setId(id); + return token; + } + + @Override + public Optional findByTokenHash(String tokenHash) { + return findOne("SELECT id, user_id, token, created_at, expires_at, validated_at " + + "FROM activation_tokens WHERE token = ?", rowMapper, tokenHash); + } + + @Override + public void markValidated(Long id, Instant validatedAt) { + getJdbcTemplate().update("UPDATE activation_tokens SET validated_at = ? WHERE id = ?", + toTimestamp(validatedAt), id); + } + + @Override + public void deleteByUserId(Long userId) { + getJdbcTemplate().update("DELETE FROM activation_tokens WHERE user_id = ?", userId); + } + + private static Timestamp toTimestamp(Instant instant) { + return instant == null ? null : Timestamp.from(instant); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImpl.java new file mode 100644 index 0000000..24d90a1 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImpl.java @@ -0,0 +1,52 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Timestamp; +import java.time.Instant; + +/** + * JdbcTemplate implementation of BlacklistedTokenDao. Declared by @Bean in DaoConfig, no stereotype. + * Uses no RowMapper: the blacklist is only ever probed for existence, inserted or purged. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class BlacklistedTokenDaoImpl extends AbstractDao implements BlacklistedTokenDao { + + public BlacklistedTokenDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public BlacklistedToken insert(BlacklistedToken token) { + long id = insertReturningId( + "INSERT INTO blacklisted_tokens (user_id, token, jti, blacklisted_at, created_at, expires_at, " + + "validated_at) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id", + token.getUserId(), token.getToken(), token.getJti(), toTimestamp(token.getBlacklistedAt()), + toTimestamp(token.getCreatedAt()), toTimestamp(token.getExpiresAt()), + toTimestamp(token.getValidatedAt())); + token.setId(id); + return token; + } + + @Override + public boolean existsByTokenHash(String tokenHash) { + return count("SELECT COUNT(*) FROM blacklisted_tokens WHERE token = ?", tokenHash) > 0; + } + + @Override + public int deleteExpiredBefore(Instant instant) { + return getJdbcTemplate().update("DELETE FROM blacklisted_tokens WHERE expires_at < ?", + Timestamp.from(instant)); + } + + private static Timestamp toTimestamp(Instant instant) { + return instant == null ? null : Timestamp.from(instant); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImpl.java new file mode 100644 index 0000000..ff75246 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImpl.java @@ -0,0 +1,48 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken; +import com.edgareldy.springjdbctutorial.core.auth.mapper.PasswordResetTokenRowMapper; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Timestamp; +import java.util.Optional; + +/** + * JdbcTemplate implementation of PasswordResetTokenDao. 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 PasswordResetTokenDaoImpl extends AbstractDao implements PasswordResetTokenDao { + + private final PasswordResetTokenRowMapper rowMapper = new PasswordResetTokenRowMapper(); + + public PasswordResetTokenDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public PasswordResetToken insert(PasswordResetToken token) { + long id = insertReturningId( + "INSERT INTO password_reset_tokens (user_id, token, type, expiry_date) " + + "VALUES (?, ?, ?, ?) RETURNING id", + token.getUserId(), token.getToken(), token.getType(), Timestamp.from(token.getExpiryDate())); + token.setId(id); + return token; + } + + @Override + public Optional findByTokenHash(String tokenHash) { + return findOne("SELECT id, user_id, token, type, expiry_date FROM password_reset_tokens WHERE token = ?", + rowMapper, tokenHash); + } + + @Override + public void deleteByUserId(Long userId) { + getJdbcTemplate().update("DELETE FROM password_reset_tokens WHERE user_id = ?", userId); + } +} From f66c12c15ff44b5b638e5dc911eaa45a81249124 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 039/144] feat(auth): add the authentication exceptions AuthenticationFailedException is the base of every 401 case, so the web layer maps one type. InvalidCredentialsException carries a single message for an unknown email and a wrong password, and AccountNotActiveException is raised only after the password was verified, so it never reveals whether an email exists. They live in core and know nothing about HTTP. --- .../exception/AccountNotActiveException.java | 19 +++++++++++++++++++ .../AuthenticationFailedException.java | 18 ++++++++++++++++++ .../InvalidCredentialsException.java | 18 ++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AccountNotActiveException.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AuthenticationFailedException.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/InvalidCredentialsException.java diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AccountNotActiveException.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AccountNotActiveException.java new file mode 100644 index 0000000..505d6a3 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AccountNotActiveException.java @@ -0,0 +1,19 @@ +package com.edgareldy.springjdbctutorial.core.auth.exception; + +/** + * The password was correct but the account is not activated or is locked. Only thrown after the + * password check, so it never reveals whether an email exists. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AccountNotActiveException extends AuthenticationFailedException { + + private static final long serialVersionUID = 1L; + + public AccountNotActiveException() { + super("Account is not activated or is locked"); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AuthenticationFailedException.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AuthenticationFailedException.java new file mode 100644 index 0000000..45dcb82 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AuthenticationFailedException.java @@ -0,0 +1,18 @@ +package com.edgareldy.springjdbctutorial.core.auth.exception; + +/** + * Base of every authentication failure (mapped to HTTP 401 by the web layer). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AuthenticationFailedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public AuthenticationFailedException(String message) { + super(message); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/InvalidCredentialsException.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/InvalidCredentialsException.java new file mode 100644 index 0000000..a3f77cd --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/InvalidCredentialsException.java @@ -0,0 +1,18 @@ +package com.edgareldy.springjdbctutorial.core.auth.exception; + +/** + * Unknown email or wrong password: one message for both so an email's existence is never revealed. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class InvalidCredentialsException extends AuthenticationFailedException { + + private static final long serialVersionUID = 1L; + + public InvalidCredentialsException() { + super("Invalid email or password"); + } +} From 77fad269f24c7b692f84c14ef8802b67f0ea7b10 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 040/144] feat(auth): implement registration, activation, login, logout and reset AuthServiceImpl holds the business rules and the transaction boundaries. @Transactional makes a Spring proxy open, commit or roll back a transaction around each write, so a user row and its activation token succeed or fail together. Tokens are 256 bits of SecureRandom output stored only as SHA-256 digests, with the raw value visible only in an INFO log line because no mailer exists. Login gives one error for an unknown email and a wrong password and runs a fake bcrypt check for the unknown one to equalise timing. Passwords are limited to 72 bytes, the bcrypt limit, and forgotPassword behaves the same whether or not the account exists. Logout is idempotent, so a repeated revocation of the same token is accepted. --- .../core/auth/service/AuthService.java | 39 +++ .../auth/service/impl/AuthServiceImpl.java | 238 ++++++++++++++++++ .../core/auth/service/impl/TokenHasher.java | 51 ++++ 3 files changed, 328 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/AuthService.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/TokenHasher.java diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/AuthService.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/AuthService.java new file mode 100644 index 0000000..bea1e15 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/AuthService.java @@ -0,0 +1,39 @@ +package com.edgareldy.springjdbctutorial.core.auth.service; + +import com.edgareldy.springjdbctutorial.core.auth.dto.BlacklistedTokenDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; + +/** + * Registration, activation, login, logout, profile and password reset. Dto types at the boundary. + * Authentication failures throw AuthenticationFailedException subclasses, business refusals BusinessRuleException. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface AuthService { + + /** Creates a disabled account and its activation token (raw token logged at INFO, no mailer). */ + UserDto register(UserDto user); + + void activateAccount(String rawToken); + + /** Returns the user with roles and permissions filled and a null password. */ + UserDto login(String email, String rawPassword); + + /** Revokes a JWT by storing its hash. Idempotent. */ + void logout(BlacklistedTokenDto token); + + boolean isTokenBlacklisted(String tokenHash); + + UserDto getProfile(Long userId); + + /** Never reveals whether the email exists. */ + void forgotPassword(String email); + + void resetPassword(String rawToken, String newPassword); + + /** Deletes the blacklisted rows that already expired and returns how many were removed. */ + int purgeExpiredTokens(); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..99d343e --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java @@ -0,0 +1,238 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.dto.BlacklistedTokenDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; +import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import com.edgareldy.springjdbctutorial.core.auth.exception.AccountNotActiveException; +import com.edgareldy.springjdbctutorial.core.auth.exception.InvalidCredentialsException; +import com.edgareldy.springjdbctutorial.core.auth.mapper.UserMapper; +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; + +/** + * Implementation of AuthService. 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 AuthServiceImpl implements AuthService { + + private static final Log LOG = LogFactory.getLog(AuthServiceImpl.class); + + private static final int MAX_PASSWORD_BYTES = 72; + private static final Duration ACTIVATION_VALIDITY = Duration.ofHours(24); + private static final Duration RESET_VALIDITY = Duration.ofHours(1); + private static final String RESET_TYPE = "PASSWORD_RESET"; + + private final UserDao userDao; + private final ActivationTokenDao activationTokenDao; + private final PasswordResetTokenDao passwordResetTokenDao; + private final BlacklistedTokenDao blacklistedTokenDao; + private final PasswordEncoder passwordEncoder; + private final Clock clock; + private final UserMapper userMapper = new UserMapper(); + private final TokenHasher tokenHasher = new TokenHasher(); + // Verified against when the email is unknown, so a failed login costs one bcrypt check either way + private final String dummyHash; + + public AuthServiceImpl(UserDao userDao, ActivationTokenDao activationTokenDao, + PasswordResetTokenDao passwordResetTokenDao, BlacklistedTokenDao blacklistedTokenDao, + PasswordEncoder passwordEncoder, Clock clock) { + this.userDao = userDao; + this.activationTokenDao = activationTokenDao; + this.passwordResetTokenDao = passwordResetTokenDao; + this.blacklistedTokenDao = blacklistedTokenDao; + this.passwordEncoder = passwordEncoder; + this.clock = clock; + this.dummyHash = passwordEncoder.encode("timing-equalisation-dummy-password"); + } + + // @Transactional (enabled by @EnableTransactionManagement in DataSourceConfig) makes the Spring proxy + // open a transaction before the method and commit it on return, or roll back on a RuntimeException. + // Every write below therefore succeeds or fails as one unit (e.g. user row + activation token). + @Override + @Transactional + public UserDto register(UserDto input) { + if (input == null) { + throw new BusinessRuleException("User is required"); + } + String email = normalizeEmail(input.getEmail()); + requirePasswordWithinLimit(input.getPassword()); + if (userDao.existsByEmail(email)) { + throw new BusinessRuleException("Email is already registered"); + } + UserDto toCreate = new UserDto(null, input.getFirstName(), input.getLastName(), email, null, + false, false, null, null); + User created; + try { + created = userDao.insert(userMapper.toEntity(toCreate, passwordEncoder.encode(input.getPassword()))); + } catch (DuplicateKeyException e) { + // Lost a race against a concurrent registration of the same email: the unique index decided + throw new BusinessRuleException("Email is already registered", e); + } + String rawToken = tokenHasher.generateRawToken(); + Instant now = clock.instant(); + activationTokenDao.insert(new ActivationToken(null, created.getId(), tokenHasher.sha256Hex(rawToken), + now, now.plus(ACTIVATION_VALIDITY), null)); + // No mailer exists in this tutorial: the raw token is only ever visible in this log line + LOG.info("Activation token for " + email + ": " + rawToken); + return userMapper.toDto(created); + } + + @Override + @Transactional + public void activateAccount(String rawToken) { + Instant now = clock.instant(); + ActivationToken token = rawToken == null ? null + : activationTokenDao.findByTokenHash(tokenHasher.sha256Hex(rawToken)).orElse(null); + if (token == null || token.getValidatedAt() != null || !token.getExpiresAt().isAfter(now)) { + throw new BusinessRuleException("Invalid or expired activation token"); + } + userDao.updateEnabled(token.getUserId(), true); + activationTokenDao.markValidated(token.getId(), now); + } + + @Override + @Transactional(readOnly = true) + public UserDto login(String email, String rawPassword) { + User user = email == null ? null : userDao.findByEmail(normalizeEmail(email)).orElse(null); + boolean passwordOk; + if (user == null) { + // Fake verification: same bcrypt cost as a real one, result ignored + passwordEncoder.matches("x", dummyHash); + passwordOk = false; + } else if (rawPassword == null || exceedsLimit(rawPassword)) { + // Over 72 bytes can never match a bcrypt hash: plain invalid credentials, never a 500 + passwordEncoder.matches("x", dummyHash); + passwordOk = false; + } else { + passwordOk = passwordEncoder.matches(rawPassword, user.getPassword()); + } + if (!passwordOk) { + throw new InvalidCredentialsException(); + } + if (!user.isEnabled() || user.isAccountLocked()) { + throw new AccountNotActiveException(); + } + return withRolesAndPermissions(user); + } + + @Override + @Transactional + public void logout(BlacklistedTokenDto token) { + if (blacklistedTokenDao.existsByTokenHash(token.getTokenHash())) { + return; + } + Instant now = clock.instant(); + try { + blacklistedTokenDao.insert(new BlacklistedToken(null, token.getUserId(), token.getTokenHash(), + token.getJti(), now, token.getIssuedAt() == null ? now : token.getIssuedAt(), + token.getExpiresAt(), null)); + } catch (DuplicateKeyException e) { + // Concurrent or repeated logout of the same token (same hash or jti): already revoked, accept + LOG.debug("Token already blacklisted, logout accepted"); + } + } + + @Override + @Transactional(readOnly = true) + public boolean isTokenBlacklisted(String tokenHash) { + return blacklistedTokenDao.existsByTokenHash(tokenHash); + } + + @Override + @Transactional(readOnly = true) + public UserDto getProfile(Long userId) { + User user = userDao.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId)); + return withRolesAndPermissions(user); + } + + @Override + @Transactional + public void forgotPassword(String email) { + // Token and hash are generated whether or not the account exists, so the work is alike + String rawToken = tokenHasher.generateRawToken(); + String tokenHash = tokenHasher.sha256Hex(rawToken); + String normalized = email == null ? "" : normalizeEmail(email); + User user = userDao.findByEmail(normalized).orElse(null); + // The delete runs for an unknown account too (on an id that matches nothing), so the only work + // skipped when the email is unknown is the final insert and the log line + passwordResetTokenDao.deleteByUserId(user == null ? -1L : user.getId()); + if (user == null) { + return; + } + passwordResetTokenDao.insert(new PasswordResetToken(null, user.getId(), tokenHash, RESET_TYPE, + clock.instant().plus(RESET_VALIDITY))); + LOG.info("Password reset token for " + normalized + ": " + rawToken); + } + + @Override + @Transactional + public void resetPassword(String rawToken, String newPassword) { + PasswordResetToken token = rawToken == null ? null + : passwordResetTokenDao.findByTokenHash(tokenHasher.sha256Hex(rawToken)).orElse(null); + if (token == null || !token.getExpiryDate().isAfter(clock.instant())) { + throw new BusinessRuleException("Invalid or expired reset token"); + } + requirePasswordWithinLimit(newPassword); + userDao.updatePassword(token.getUserId(), passwordEncoder.encode(newPassword)); + passwordResetTokenDao.deleteByUserId(token.getUserId()); + } + + @Override + @Transactional + public int purgeExpiredTokens() { + return blacklistedTokenDao.deleteExpiredBefore(clock.instant()); + } + + private UserDto withRolesAndPermissions(User user) { + UserDto dto = userMapper.toDto(user); + dto.setRoles(userDao.findRoleNamesByUserId(user.getId())); + dto.setPermissions(userDao.findPermissionCodesByUserId(user.getId())); + return dto; + } + + private static String normalizeEmail(String email) { + if (email == null || email.isBlank()) { + throw new BusinessRuleException("Email is required"); + } + return email.trim().toLowerCase(Locale.ROOT); + } + + private static boolean exceedsLimit(String password) { + return password.getBytes(StandardCharsets.UTF_8).length > MAX_PASSWORD_BYTES; + } + + private static void requirePasswordWithinLimit(String password) { + if (password == null || password.isEmpty()) { + throw new BusinessRuleException("Password is required"); + } + // bcrypt only reads the first 72 bytes: refuse longer input instead of silently truncating it + if (exceedsLimit(password)) { + throw new BusinessRuleException("Password must not exceed 72 bytes"); + } + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/TokenHasher.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/TokenHasher.java new file mode 100644 index 0000000..348d3e1 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/TokenHasher.java @@ -0,0 +1,51 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; + +/** + * Generates random raw tokens and computes the SHA-256 hex digest that is the only form stored. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +final class TokenHasher { + + private static final int TOKEN_BYTES = 32; + + // Instance field on purpose (never static): one generator per service instance, seeded by the OS. + private final SecureRandom secureRandom = new SecureRandom(); + + /** + * 32 random bytes, URL-safe base64 without padding (43 characters). + */ + String generateRawToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + // Why SHA-256 and not bcrypt for tokens: a token is 256 bits of randomness, so it cannot be + // brute-forced or found in a dictionary and needs no slow, salted hash. It must also be looked up + // BY VALUE (WHERE token = ?), which a per-hash random salt (bcrypt) makes impossible. A database + // leak still yields only digests, which cannot be replayed as a token. Passwords are the opposite + // case (low entropy, chosen by humans), hence bcrypt for those. + /** + * SHA-256 of the UTF-8 bytes of the value, as 64 lowercase hex characters. + */ + String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandatory in every Java platform implementation + throw new IllegalStateException("SHA-256 is not available", e); + } + } +} From caf3984606e9c89f1954068eb9aae5e40a9c6640 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 041/144] feat(auth): wire the DAOs, the AuthService and the cleanup job DaoConfig and ServiceConfig declare every bean explicitly and return them typed by interface, which is what lets @Transactional apply through a JDK proxy. The BCryptPasswordEncoder is exposed as the PasswordEncoder abstraction. @EnableScheduling registers the post-processor that runs @Scheduled methods: ExpiredTokenCleanupJob calls purgeExpiredTokens every day at 3 am to delete blacklist rows whose token has expired anyway. The method is public, so a test can call it directly. --- .../core/auth/config/DaoConfig.java | 45 +++++++++++++++ .../core/auth/config/ServiceConfig.java | 57 +++++++++++++++++++ .../service/impl/ExpiredTokenCleanupJob.java | 35 ++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/DaoConfig.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/ServiceConfig.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJob.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 new file mode 100644 index 0000000..ce44e78 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/DaoConfig.java @@ -0,0 +1,45 @@ +package com.edgareldy.springjdbctutorial.core.auth.config; + +import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao; +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.PasswordResetTokenDaoImpl; +import com.edgareldy.springjdbctutorial.core.auth.dao.impl.UserDaoImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Declares the DAO beans of the auth module, typed by interface (no stereotype on the implementations). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +public class DaoConfig { + + @Bean + public UserDao userDao(JdbcTemplate jdbcTemplate) { + return new UserDaoImpl(jdbcTemplate); + } + + @Bean + public ActivationTokenDao activationTokenDao(JdbcTemplate jdbcTemplate) { + return new ActivationTokenDaoImpl(jdbcTemplate); + } + + @Bean + public PasswordResetTokenDao passwordResetTokenDao(JdbcTemplate jdbcTemplate) { + return new PasswordResetTokenDaoImpl(jdbcTemplate); + } + + @Bean + public BlacklistedTokenDao blacklistedTokenDao(JdbcTemplate jdbcTemplate) { + return new BlacklistedTokenDaoImpl(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 new file mode 100644 index 0000000..2fee538 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/ServiceConfig.java @@ -0,0 +1,57 @@ +package com.edgareldy.springjdbctutorial.core.auth.config; + +import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.auth.service.impl.AuthServiceImpl; +import com.edgareldy.springjdbctutorial.core.auth.service.impl.ExpiredTokenCleanupJob; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +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. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +// @EnableScheduling registers the post-processor that finds @Scheduled methods on beans and runs them +@EnableScheduling +@Import(DaoConfig.class) +public class ServiceConfig { + + // PasswordEncoder is Spring Security's abstraction for one-way password hashing. BCrypt is adaptive + // (tunable cost) and salted per hash, so equal passwords give different hashes and brute force is + // slow. Only the spring-security-crypto artifact is needed, not the whole security stack. + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + // Returned as the AuthService interface: Spring wraps the bean in a JDK dynamic proxy that applies + // the @Transactional advice, which only works when callers go through the interface. + @Bean + public AuthService authService(UserDao userDao, ActivationTokenDao activationTokenDao, + PasswordResetTokenDao passwordResetTokenDao, + BlacklistedTokenDao blacklistedTokenDao, PasswordEncoder passwordEncoder, + Clock clock) { + return new AuthServiceImpl(userDao, activationTokenDao, passwordResetTokenDao, blacklistedTokenDao, + passwordEncoder, clock); + } + + @Bean + public ExpiredTokenCleanupJob expiredTokenCleanupJob(AuthService authService) { + return new ExpiredTokenCleanupJob(authService); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJob.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJob.java new file mode 100644 index 0000000..6ea28e2 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJob.java @@ -0,0 +1,35 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.scheduling.annotation.Scheduled; + +/** + * Daily job purging expired blacklisted tokens. run() is public so tests can call it directly. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class ExpiredTokenCleanupJob { + + private static final Log LOG = LogFactory.getLog(ExpiredTokenCleanupJob.class); + + private final AuthService authService; + + public ExpiredTokenCleanupJob(AuthService authService) { + this.authService = authService; + } + + // @Scheduled runs this method by itself at 03:00 every day (Spring cron: second minute hour day + // month weekday). It only fires because @EnableScheduling is on ServiceConfig and this class is a + // bean. Expired rows are useless (an expired JWT is refused anyway), so deleting them keeps the + // blacklist small and its lookups fast. + @Scheduled(cron = "0 0 3 * * *") + public void run() { + int removed = authService.purgeExpiredTokens(); + LOG.info("Expired blacklisted tokens purged: " + removed); + } +} From ca7f22d203dc9b7b7cbe519cb5693c370799c4a4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:07 +0400 Subject: [PATCH 042/144] build(ws): add Spring Security, jjwt and the test key locations ws now depends on spring-security-web and spring-security-config, the web-facing half of Spring Security, and on jjwt: the API at compile time, the implementation and Jackson binding at runtime only. Surefire receives the two JWT key locations as system properties pointing at the development key pair at the repository root, so the tests never read keys from the classpath. --- ws/pom.xml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/ws/pom.xml b/ws/pom.xml index 2136e9f..0e6e161 100644 --- a/ws/pom.xml +++ b/ws/pom.xml @@ -68,6 +68,30 @@ org.glassfish.expressly expressly + + + org.springframework.security + spring-security-web + + + org.springframework.security + spring-security-config + + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + runtime + + + io.jsonwebtoken + jjwt-jackson + runtime + ch.qos.logback logback-classic @@ -79,6 +103,11 @@ test-jar test + + org.springframework.security + spring-security-test + test + org.testcontainers junit-jupiter @@ -95,6 +124,18 @@ ws + + org.apache.maven.plugins + maven-surefire-plugin + + + + file:${project.basedir}/../dev-keys/privateKey.pem + file:${project.basedir}/../dev-keys/publicKey.pem + + + org.apache.maven.plugins maven-war-plugin From 8c4dce571d933b966501b427c05a2705446be415 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:07 +0400 Subject: [PATCH 043/144] chore(keys): add the development RS256 key pair A 2048 bit RSA pair (PKCS#8 private key, X.509 public key) for local development and the automated tests. It sits in dev-keys/ at the repository root, never under src/main/resources, so it can never be packaged inside the WAR. The README of the directory states they are public and dev only. Production keys are generated separately and mounted from a git-ignored directory, and the application has no default key at all. --- dev-keys/README.md | 13 +++++++++++++ dev-keys/privateKey.pem | 28 ++++++++++++++++++++++++++++ dev-keys/publicKey.pem | 9 +++++++++ 3 files changed, 50 insertions(+) create mode 100644 dev-keys/README.md create mode 100644 dev-keys/privateKey.pem create mode 100644 dev-keys/publicKey.pem diff --git a/dev-keys/README.md b/dev-keys/README.md new file mode 100644 index 0000000..eaedd25 --- /dev/null +++ b/dev-keys/README.md @@ -0,0 +1,13 @@ +# Development JWT keys + +RSA 2048 key pair used ONLY for local development and for the automated tests +(RS256, PKCS#8 private key, X.509 public key, PEM). They are public on purpose: +never use them anywhere real. + +They live here, at the repository root, and never under `src/main/resources`, +so they are never packaged inside the WAR. + +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`. diff --git a/dev-keys/privateKey.pem b/dev-keys/privateKey.pem new file mode 100644 index 0000000..088bc9b --- /dev/null +++ b/dev-keys/privateKey.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3Oz4mE8oQnmm7 +Zwm+1ZEAmnfJH5C4vRNHQ1Julh9NYNg1Di/QKs+g9skr7DKnQ7uCjAc5t+wE68fU +CAkhvx9lPYQu6Ewu8OTbJEOUZ4nT9bZxqXxXxXyoAvjnASZPfBwISldkOT/boOtZ +Gomp24GHjxJrBM1OWWGFx1TxkXH03m+Z/IwEEEETo4HCcpRUvXwLPIiptYA0U+wx +fx4MF8QafdMOJGNHuigQHu3KGUhJP+pxpRGLVEJLz83igeAvP/BGu1OO3EGPoKkT +rOSQEOnBZWBCFGqNm14oRFai6j24EFuXQP60xRzyd9H49RhuiX0q2u5IaqYyT2hU +0Q29YdarAgMBAAECggEAHIhzsZ9ESJk6ks3FVTvRpDvpql6pfTMoVOEVbZYgEr8h +fAWVCYAZLBIyGo5iBu0Dvp0KpQDQlqWWzRxhO6mU/IF7W4ieEHM/2CZKBcqnymOJ +gSKo88wTkOBBnUtvCVKk1N+qwzctDAj/jTUWOeauQYtUjWjGhcxiZOA5CJMjIUPb +6o7ZMQ5ia7K1gnwNucbG2S7Vvwc6AB9gR712wkN0ImxlNSa2QCq5KmG7QVwY7Qc3 +v7b01KL/vRjc4wmQjfpZHhxzmruhE22sx+mv62fSiouzsc2kG9NoxJoQV/Thp33O +GBFElNQFCgipUjmUAkUNrEdOODcutgt3MNWglrb+JQKBgQC5OzgwCbmmM5xqYU23 +zzXA4y2xitTSmBV1sjYNdZ6K8bqOP+ZlQRU7OzklR09DXBG4Yzy5WCFyiZIWUT8l +5FGqB2A/zNhPWLdBkogsyCxkLuKtvaKUKorusyxl7VPmnUWlruSRE3SEly+5d4fb +dDM74iKmslmToJiW7TCUMylw1QKBgQD9PGtw42JR7WHHd8f7EPZgMEGJs2DvP2v+ +Y7/Aj/8TqTUPto1GPBmY7xA107C9S2TvfwgrW+1uUMgCJWxtq8WfX7Ae7atNYfm9 +2w9GW99n5pAJSNkWZ0pdnz1eI9cxh/+PvqlYnpP87nwswGqcamzuz3Y7bqYZ+dka +RIz8bB3pfwKBgQCfx6mMJpdx+osuaIj1yVQA8vZzc+JpVq8MO5HMwmzkReg61HwV +U7LQySFeOsjwvfa7Kz8yUgdoGatefO8P7IdVN8+auUTODSTm91Nn7bP9J0R5wl7/ +mEMH7J6bvcDl6Twu01Juom2cAh40yhry/qVDPqnNSYNjiOCXMDr7W7afkQKBgQDo +h1xY0lJrzD+mu1REM/UnqefkG3R8+42cj9d5aD/eu9bfUM80mOcgSRRneLSnfN14 +FPtZZRYF3+NoJRkFM19J0sGUBMTxT2/VZ5sFDkN70nicLeo46iudACWHlf15m9mN +EpPJrgNRk2MubJj4e7j/KDz2ocmfvI4f0qZq+pQU3QKBgE8Gg5Om86xWMogd/J4v +ZpDGT7/6Xhx3/TfR7ivG18j2YYs9CWb5MRiiQnaIomSEE1hzk9d3CFtoMt8qiLij +9pp6wzo+WxAyE4OaVf5ZctKWWRNvVhcXwB0YrmVW+ibAr9vjfY3vpMHpi3isB34c +1vwLf8kaiglQg9iNITXB7n1b +-----END PRIVATE KEY----- diff --git a/dev-keys/publicKey.pem b/dev-keys/publicKey.pem new file mode 100644 index 0000000..9445c3d --- /dev/null +++ b/dev-keys/publicKey.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtzs+JhPKEJ5pu2cJvtWR +AJp3yR+QuL0TR0NSbpYfTWDYNQ4v0CrPoPbJK+wyp0O7gowHObfsBOvH1AgJIb8f +ZT2ELuhMLvDk2yRDlGeJ0/W2cal8V8V8qAL45wEmT3wcCEpXZDk/26DrWRqJqduB +h48SawTNTllhhcdU8ZFx9N5vmfyMBBBBE6OBwnKUVL18CzyIqbWANFPsMX8eDBfE +Gn3TDiRjR7ooEB7tyhlIST/qcaURi1RCS8/N4oHgLz/wRrtTjtxBj6CpE6zkkBDp +wWVgQhRqjZteKERWouo9uBBbl0D+tMUc8nfR+PUYbol9KtruSGqmMk9oVNENvWHW +qwIDAQAB +-----END PUBLIC KEY----- From a4bd52a7a9f35ff66bd2570c0cd05696e9a06a2d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:07 +0400 Subject: [PATCH 044/144] feat(ws): issue and verify RS256 JWTs with JwtService A JWT is a signed, self-contained bearer credential, so the API keeps no session. RS256 is asymmetric: only the private key holder can issue tokens and verification needs just the public key. JwtKeyLoader reads both PEM files from configurable locations and fails startup with a clear message when one is missing or of the wrong type. Tokens carry the user id, a random jti, the email and the permission codes. parse never throws: a bad signature, an expired or foreign token, or any algorithm other than RS256 gives an empty result. The raw JWT is only ever handled through its SHA-256 digest. --- .../ws/security/IssuedToken.java | 60 +++++++++ .../ws/security/JwtKeyLoader.java | 73 +++++++++++ .../ws/security/JwtService.java | 119 ++++++++++++++++++ .../ws/security/ParsedToken.java | 82 ++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/IssuedToken.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoader.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtService.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ParsedToken.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/IssuedToken.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/IssuedToken.java new file mode 100644 index 0000000..86c3425 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/IssuedToken.java @@ -0,0 +1,60 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import java.time.Instant; +import java.util.Objects; + +/** + * A freshly issued JWT with its issue and expiry instants. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class IssuedToken { + + private final String token; + private final Instant issuedAt; + private final Instant expiresAt; + + public IssuedToken(String token, Instant issuedAt, Instant expiresAt) { + this.token = token; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public String getToken() { + return token; + } + + public Instant getIssuedAt() { + return issuedAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + IssuedToken that = (IssuedToken) o; + return Objects.equals(token, that.token) && Objects.equals(issuedAt, that.issuedAt) + && Objects.equals(expiresAt, that.expiresAt); + } + + @Override + public int hashCode() { + return Objects.hash(token, issuedAt, expiresAt); + } + + @Override + public String toString() { + return "IssuedToken{token=****, issuedAt=" + issuedAt + ", expiresAt=" + expiresAt + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoader.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoader.java new file mode 100644 index 0000000..7f36e76 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoader.java @@ -0,0 +1,73 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +/** + * Loads the RSA key pair of the JWT signer from PEM resources (private PKCS#8, public X.509). Any problem raises an IllegalStateException naming the key, so startup fails clearly. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// Only java.security is used (KeyFactory + the standard key specs), no extra library. The locations are +// Spring resource URLs (file:, classpath:...) resolved by the ResourceLoader. +public final class JwtKeyLoader { + + private JwtKeyLoader() { + } + + public static PrivateKey loadPrivateKey(ResourceLoader loader, String property, String location) { + byte[] der = readPem(loader, property, location, "PRIVATE KEY"); + try { + return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der)); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Invalid RSA private key for " + property + " (PKCS#8 expected)", e); + } + } + + public static PublicKey loadPublicKey(ResourceLoader loader, String property, String location) { + byte[] der = readPem(loader, property, location, "PUBLIC KEY"); + try { + return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(der)); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Invalid RSA public key for " + property + " (X.509 expected)", e); + } + } + + private static byte[] readPem(ResourceLoader loader, String property, String location, String type) { + if (location == null || location.isBlank()) { + throw new IllegalStateException("Missing required property " + property); + } + Resource resource = loader.getResource(location); + String pem; + try (InputStream in = resource.getInputStream()) { + pem = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("Cannot read the key of " + property + " at " + location, e); + } + String begin = "-----BEGIN " + type + "-----"; + String end = "-----END " + type + "-----"; + if (!pem.contains(begin) || !pem.contains(end)) { + throw new IllegalStateException("The key of " + property + " must be a PEM '" + type + "' block"); + } + String body = pem.substring(pem.indexOf(begin) + begin.length(), pem.indexOf(end)).replaceAll("\\s", ""); + try { + return Base64.getDecoder().decode(body); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("The key of " + property + " is not valid Base64 PEM", e); + } + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtService.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtService.java new file mode 100644 index 0000000..f754a31 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtService.java @@ -0,0 +1,119 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import org.springframework.core.io.ResourceLoader; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Issues and verifies the RS256 JWTs of the API. parse never throws: any invalid token gives an empty Optional. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// A JWT (JSON Web Token) is a signed, self-contained bearer credential: header.claims.signature. The +// server keeps no session, it just verifies the signature and reads the claims (here the user id, email +// and permissions), which is what makes the API stateless. +// +// RS256 (RSA + SHA-256) is a deliberate choice over HS256: it is asymmetric, so only the holder of the +// PRIVATE key can issue tokens while verification needs only the PUBLIC key. A service that merely +// verifies tokens never has to hold a signing secret. Keys are read once, at construction. +public class JwtService { + + private static final String PERMISSIONS_CLAIM = "permissions"; + private static final String EMAIL_CLAIM = "email"; + private static final String ALGORITHM = "RS256"; + + private final PrivateKey privateKey; + private final PublicKey publicKey; + private final Duration validity; + private final String issuer; + private final Clock clock; + + public JwtService(ResourceLoader loader, String privateKeyProperty, String privateKeyLocation, + String publicKeyProperty, String publicKeyLocation, long expirationSeconds, + String issuer, Clock clock) { + this.privateKey = JwtKeyLoader.loadPrivateKey(loader, privateKeyProperty, privateKeyLocation); + this.publicKey = JwtKeyLoader.loadPublicKey(loader, publicKeyProperty, publicKeyLocation); + this.validity = Duration.ofSeconds(expirationSeconds); + this.issuer = issuer; + this.clock = clock; + } + + public IssuedToken issue(com.edgareldy.springjdbctutorial.core.auth.dto.UserDto user) { + // JWT times have second precision, so truncate to keep the returned instants equal to the claims + Instant issuedAt = clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS); + Instant expiresAt = issuedAt.plus(validity); + List permissions = user.getPermissions() == null ? List.of() : user.getPermissions(); + String token = Jwts.builder() + .subject(String.valueOf(user.getId())) + .id(UUID.randomUUID().toString()) + .issuer(issuer) + .issuedAt(Date.from(issuedAt)) + .expiration(Date.from(expiresAt)) + .claim(EMAIL_CLAIM, user.getEmail()) + .claim(PERMISSIONS_CLAIM, permissions) + .signWith(privateKey, Jwts.SIG.RS256) + .compact(); + return new IssuedToken(token, issuedAt, expiresAt); + } + + public Optional parse(String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + try { + Jws jws = Jwts.parser() + .verifyWith(publicKey) + .requireIssuer(issuer) + .clock(() -> Date.from(clock.instant())) + .build() + .parseSignedClaims(token); + // verifyWith(RSA key) would also accept RS384/RS512: only RS256 is issued, so only RS256 passes + if (!ALGORITHM.equals(jws.getHeader().getAlgorithm())) { + return Optional.empty(); + } + Claims claims = jws.getPayload(); + Object rawPermissions = claims.get(PERMISSIONS_CLAIM); + List permissions = rawPermissions instanceof List list + ? list.stream().map(String::valueOf).toList() + : List.of(); + if (claims.getSubject() == null || claims.getId() == null + || claims.getIssuedAt() == null || claims.getExpiration() == null) { + return Optional.empty(); + } + return Optional.of(new ParsedToken(Long.valueOf(claims.getSubject()), claims.get(EMAIL_CLAIM, String.class), + claims.getId(), claims.getIssuedAt().toInstant(), claims.getExpiration().toInstant(), + permissions)); + } catch (JwtException | IllegalArgumentException e) { + return Optional.empty(); + } + } + + /** SHA-256 hex of the raw JWT: the only form in which a token is ever stored or looked up. */ + public static String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ParsedToken.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ParsedToken.java new file mode 100644 index 0000000..e7e8c19 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ParsedToken.java @@ -0,0 +1,82 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** + * Verified content of a JWT: user id, email, jti, validity window and permission codes. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class ParsedToken { + + private final Long userId; + private final String email; + private final String jti; + private final Instant issuedAt; + private final Instant expiresAt; + private final List permissions; + + public ParsedToken(Long userId, String email, String jti, Instant issuedAt, Instant expiresAt, + List permissions) { + this.userId = userId; + this.email = email; + this.jti = jti; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + this.permissions = permissions; + } + + public Long getUserId() { + return userId; + } + + public String getEmail() { + return email; + } + + public String getJti() { + return jti; + } + + public Instant getIssuedAt() { + return issuedAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public List getPermissions() { + return permissions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ParsedToken that = (ParsedToken) o; + return Objects.equals(userId, that.userId) && Objects.equals(email, that.email) + && Objects.equals(jti, that.jti) && Objects.equals(issuedAt, that.issuedAt) + && Objects.equals(expiresAt, that.expiresAt) && Objects.equals(permissions, that.permissions); + } + + @Override + public int hashCode() { + return Objects.hash(userId, email, jti, issuedAt, expiresAt, permissions); + } + + @Override + public String toString() { + return "ParsedToken{userId=" + userId + ", email=" + email + ", jti=" + jti + ", issuedAt=" + issuedAt + + ", expiresAt=" + expiresAt + ", permissions=" + permissions + "}"; + } +} From 2c36767e512244ef60ca6bf6b1c38671ea833b21 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:07 +0400 Subject: [PATCH 045/144] feat(ws): authenticate requests with JwtAuthFilter and reject revoked tokens A OncePerRequestFilter reads the Bearer token, verifies it and then asks AuthService whether its SHA-256 digest is blacklisted. A valid, non revoked token fills the SecurityContext with an AuthenticatedUser principal and one authority per permission code taken from the token claims, so no database call is needed to authorise a request. For a missing, invalid or revoked token the context stays empty and the entry point answers 401. The filter is created inside the SecurityFilterChain and is not registered as a servlet filter. --- .../ws/security/AuthenticatedUser.java | 81 +++++++++++++++++++ .../ws/security/JwtAuthFilter.java | 65 +++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/AuthenticatedUser.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtAuthFilter.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/AuthenticatedUser.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/AuthenticatedUser.java new file mode 100644 index 0000000..c1d6669 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/AuthenticatedUser.java @@ -0,0 +1,81 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import java.time.Instant; +import java.util.Objects; + +/** + * Principal placed in the SecurityContext for an authenticated request: who, and which token (hash, jti, validity) so logout can revoke it. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AuthenticatedUser { + + private final Long userId; + private final String email; + private final String jti; + private final String tokenHash; + private final Instant issuedAt; + private final Instant expiresAt; + + public AuthenticatedUser(Long userId, String email, String jti, String tokenHash, Instant issuedAt, + Instant expiresAt) { + this.userId = userId; + this.email = email; + this.jti = jti; + this.tokenHash = tokenHash; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public Long getUserId() { + return userId; + } + + public String getEmail() { + return email; + } + + public String getJti() { + return jti; + } + + public String getTokenHash() { + return tokenHash; + } + + public Instant getIssuedAt() { + return issuedAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AuthenticatedUser that = (AuthenticatedUser) o; + return Objects.equals(userId, that.userId) && Objects.equals(email, that.email) + && Objects.equals(jti, that.jti) && Objects.equals(tokenHash, that.tokenHash) + && Objects.equals(issuedAt, that.issuedAt) && Objects.equals(expiresAt, that.expiresAt); + } + + @Override + public int hashCode() { + return Objects.hash(userId, email, jti, tokenHash, issuedAt, expiresAt); + } + + @Override + public String toString() { + return "AuthenticatedUser{userId=" + userId + ", email=" + email + ", jti=" + jti + + ", tokenHash=****, issuedAt=" + issuedAt + ", expiresAt=" + expiresAt + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtAuthFilter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtAuthFilter.java new file mode 100644 index 0000000..01a7cb9 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtAuthFilter.java @@ -0,0 +1,65 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +/** + * Authenticates a request from its Bearer JWT: verifies it, rejects revoked tokens, and fills the SecurityContext. Never throws for a bad token. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// OncePerRequestFilter guarantees one execution per request even with forwards or error dispatches. +// It is created inside the SecurityFilterChain (not a bean, not registered in the servlet container), +// so it runs only inside the Spring Security chain. When the token is missing, invalid or revoked the +// context is left empty and the AuthenticationEntryPoint answers 401 for protected routes. +public class JwtAuthFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtService jwtService; + private final AuthService authService; + + public JwtAuthFilter(JwtService jwtService, AuthService authService) { + this.jwtService = jwtService; + this.authService = authService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith(BEARER_PREFIX)) { + String jwt = header.substring(BEARER_PREFIX.length()).trim(); + Optional parsed = jwtService.parse(jwt); + if (parsed.isPresent()) { + String tokenHash = JwtService.sha256Hex(jwt); + if (!authService.isTokenBlacklisted(tokenHash)) { + ParsedToken token = parsed.get(); + AuthenticatedUser principal = new AuthenticatedUser(token.getUserId(), token.getEmail(), + token.getJti(), tokenHash, token.getIssuedAt(), token.getExpiresAt()); + List authorities = token.getPermissions().stream() + .map(SimpleGrantedAuthority::new) + .toList(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, authorities)); + } + } + } + filterChain.doFilter(request, response); + } +} From 646dcaf7d904fc5f12050098dfa9740bb52cacf7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:08 +0400 Subject: [PATCH 046/144] feat(ws): answer 401 and 403 in the ApiResponse format The filters of Spring Security run before the DispatcherServlet, so GlobalExceptionHandler never sees their failures. An AuthenticationEntryPoint handles unauthenticated requests and an AccessDeniedHandler handles authenticated requests lacking a right; both write an ApiResponse error as JSON with the shared ObjectMapper. Every error of the API therefore has the same shape, including those raised by the security layer. --- .../ws/security/ApiAccessDeniedHandler.java | 38 +++++++++++++++++++ .../security/ApiAuthenticationEntryPoint.java | 38 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAccessDeniedHandler.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAuthenticationEntryPoint.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAccessDeniedHandler.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAccessDeniedHandler.java new file mode 100644 index 0000000..9ca4f22 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAccessDeniedHandler.java @@ -0,0 +1,38 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * Answers 403 with an ApiResponse body when an authenticated caller lacks the required permission. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// AccessDeniedHandler is the counterpart for "authenticated but not allowed": it writes the JSON 403. +public class ApiAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + public ApiAccessDeniedHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) + throws IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue(response.getOutputStream(), ApiResponse.error("Access denied")); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAuthenticationEntryPoint.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAuthenticationEntryPoint.java new file mode 100644 index 0000000..6fce088 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAuthenticationEntryPoint.java @@ -0,0 +1,38 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * Answers 401 with an ApiResponse body when a protected route is reached without a valid authentication. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// AuthenticationEntryPoint is Spring Security's hook for "the caller is not authenticated": it turnsn// that situation into the HTTP answer (here a JSON 401 in the API error format, not a login redirect). +public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + public ApiAuthenticationEntryPoint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) + throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue(response.getOutputStream(), ApiResponse.error("Authentication required")); + } +} From 9b7698759e5c8ac62211bf59e0987d5766b9e430 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:08 +0400 Subject: [PATCH 047/144] feat(ws): declare the stateless SecurityFilterChain explicitly @EnableWebSecurity registers the infrastructure and the springSecurityFilterChain filter. The chain is a plain @Bean: CSRF is disabled because this is a token API without cookies, sessions are STATELESS, the register, activation, login, forgot and reset routes and the health check are public, everything else needs authentication, and JwtAuthFilter runs before UsernamePasswordAuthenticationFilter. The JwtService bean reads the key locations from the Environment with no default, so startup fails clearly without them. Method security is not enabled yet, the rbac branch adds it with the permission evaluator. --- .../ws/config/SecurityConfig.java | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/SecurityConfig.java 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 new file mode 100644 index 0000000..64a4bcf --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/SecurityConfig.java @@ -0,0 +1,87 @@ +package com.edgareldy.springjdbctutorial.ws.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.JwtService; +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.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.time.Clock; + +/** + * Spring Security wiring: the JWT service, the JSON 401/403 handlers and the stateless SecurityFilterChain. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @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. +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + // Read from the Environment: the two key locations have no default (startup fails clearly when + // missing), expiration and issuer have harmless defaults. + @Bean + public JwtService jwtService(Environment environment, ResourceLoader resourceLoader, Clock clock) { + return new JwtService(resourceLoader, + "app.jwt.private-key-location", environment.getProperty("app.jwt.private-key-location"), + "app.jwt.public-key-location", environment.getProperty("app.jwt.public-key-location"), + environment.getProperty("app.jwt.expiration-seconds", Long.class, 3600L), + environment.getProperty("app.jwt.issuer", "spring-jdbc-tutorial"), + clock); + } + + @Bean + public AuthenticationEntryPoint apiAuthenticationEntryPoint(ObjectMapper objectMapper) { + return new ApiAuthenticationEntryPoint(objectMapper); + } + + @Bean + public AccessDeniedHandler apiAccessDeniedHandler(ObjectMapper objectMapper) { + return new ApiAccessDeniedHandler(objectMapper); + } + + // The SecurityFilterChain describes how requests are protected. This is a token API: CSRF protection + // guards browser sessions and cookies, so it is disabled here, and the chain is STATELESS (no + // HttpSession is created or read, every request must carry its own Bearer JWT). JwtAuthFilter runs + // before UsernamePasswordAuthenticationFilter so the SecurityContext is filled before authorization. + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtService jwtService, + AuthService authService, + AuthenticationEntryPoint apiAuthenticationEntryPoint, + AccessDeniedHandler apiAccessDeniedHandler) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.POST, "/api/v1/auth/register").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/auth/activate-account").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/auth/login").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/auth/forgot-password").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/auth/reset-password").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/health").permitAll() + .anyRequest().authenticated()) + .exceptionHandling(e -> e + .authenticationEntryPoint(apiAuthenticationEntryPoint) + .accessDeniedHandler(apiAccessDeniedHandler)) + .addFilterBefore(new JwtAuthFilter(jwtService, authService), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} From 8dd582fd31d3999bb300127b2b2d28c86c885735 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:08 +0400 Subject: [PATCH 048/144] feat(ws): add the auth payloads and UserConverter Payload classes are the HTTP request and response shapes, named differently from core dto classes on purpose. Requests carry Bean Validation constraints: an email format, and a password between 8 and 72 characters. The byte limit of bcrypt is still enforced by the service for multibyte input. UserConverter maps between the core UserDto and these payloads with static methods, so the controller never touches a dto directly. Passwords and tokens are masked in toString. --- .../ws/converter/UserConverter.java | 33 +++++ .../payload/auth/ForgotPasswordRequest.java | 55 ++++++++ .../ws/payload/auth/LoginRequest.java | 68 ++++++++++ .../ws/payload/auth/LoginResponse.java | 78 +++++++++++ .../ws/payload/auth/RegisterRequest.java | 101 ++++++++++++++ .../ws/payload/auth/ResetPasswordRequest.java | 70 ++++++++++ .../ws/payload/auth/UserResponse.java | 126 ++++++++++++++++++ 7 files changed, 531 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/UserConverter.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ForgotPasswordRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginResponse.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/RegisterRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ResetPasswordRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/UserResponse.java 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 new file mode 100644 index 0000000..9dbf970 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/UserConverter.java @@ -0,0 +1,33 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.ws.payload.auth.RegisterRequest; +import com.edgareldy.springjdbctutorial.ws.payload.auth.UserResponse; + +/** + * Converts between the core UserDto and the auth HTTP payloads. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public final class UserConverter { + + private UserConverter() { + } + + public static UserDto toDto(RegisterRequest request) { + UserDto dto = new UserDto(); + dto.setFirstName(request.getFirstName()); + dto.setLastName(request.getLastName()); + dto.setEmail(request.getEmail()); + dto.setPassword(request.getPassword()); + return dto; + } + + public static UserResponse toResponse(UserDto dto) { + return new UserResponse(dto.getId(), dto.getFirstName(), dto.getLastName(), dto.getEmail(), + dto.isEnabled(), dto.getRoles(), dto.getPermissions()); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ForgotPasswordRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ForgotPasswordRequest.java new file mode 100644 index 0000000..9c9ccf1 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ForgotPasswordRequest.java @@ -0,0 +1,55 @@ +package com.edgareldy.springjdbctutorial.ws.payload.auth; + +import jakarta.validation.constraints.NotBlank; +import java.util.Objects; + +/** + * Body of POST /api/v1/auth/forgot-password. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class ForgotPasswordRequest { + + @NotBlank + private String email; + + public ForgotPasswordRequest() { + } + + public ForgotPasswordRequest(String email) { + this.email = email; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ForgotPasswordRequest that = (ForgotPasswordRequest) o; + return Objects.equals(email, that.email); + } + + @Override + public int hashCode() { + return Objects.hash(email); + } + + @Override + public String toString() { + return "ForgotPasswordRequest{" + "email=" + email + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginRequest.java new file mode 100644 index 0000000..e421172 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginRequest.java @@ -0,0 +1,68 @@ +package com.edgareldy.springjdbctutorial.ws.payload.auth; + +import jakarta.validation.constraints.NotBlank; +import java.util.Objects; + +/** + * Body of POST /api/v1/auth/login. The password is masked in toString. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class LoginRequest { + + @NotBlank + private String email; + + @NotBlank + private String password; + + public LoginRequest() { + } + + public LoginRequest(String email, String password) { + this.email = email; + this.password = password; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + LoginRequest that = (LoginRequest) o; + return Objects.equals(email, that.email) + && Objects.equals(password, that.password); + } + + @Override + public int hashCode() { + return Objects.hash(email, password); + } + + @Override + public String toString() { + return "LoginRequest{" + "email=" + email + ", password=" + (password == null ? "null" : "****") + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginResponse.java new file mode 100644 index 0000000..b8810d2 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginResponse.java @@ -0,0 +1,78 @@ +package com.edgareldy.springjdbctutorial.ws.payload.auth; + +import java.time.Instant; +import java.util.Objects; + +/** + * Result of a successful login: the Bearer JWT (masked in toString), its type and its expiry. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class LoginResponse { + + private String token; + + private String tokenType; + + private Instant expiresAt; + + public LoginResponse() { + } + + public LoginResponse(String token, String tokenType, Instant expiresAt) { + this.token = token; + this.tokenType = tokenType; + this.expiresAt = expiresAt; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + LoginResponse that = (LoginResponse) o; + return Objects.equals(token, that.token) + && Objects.equals(tokenType, that.tokenType) + && Objects.equals(expiresAt, that.expiresAt); + } + + @Override + public int hashCode() { + return Objects.hash(token, tokenType, expiresAt); + } + + @Override + public String toString() { + return "LoginResponse{" + "token=" + (token == null ? "null" : "****") + ", tokenType=" + tokenType + ", expiresAt=" + expiresAt + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/RegisterRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/RegisterRequest.java new file mode 100644 index 0000000..43b6e9e --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/RegisterRequest.java @@ -0,0 +1,101 @@ +package com.edgareldy.springjdbctutorial.ws.payload.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.Objects; + +/** + * Body of POST /api/v1/auth/register. The password is masked in toString. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class RegisterRequest { + + @NotBlank + @Size(max = 100) + private String firstName; + + @NotBlank + @Size(max = 100) + private String lastName; + + @NotBlank + @Email + @Size(max = 255) + private String email; + + @NotBlank + @Size(min = 8, max = 72) + private String password; + + public RegisterRequest() { + } + + public RegisterRequest(String firstName, String lastName, String email, String password) { + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.password = password; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RegisterRequest that = (RegisterRequest) o; + return Objects.equals(firstName, that.firstName) + && Objects.equals(lastName, that.lastName) + && Objects.equals(email, that.email) + && Objects.equals(password, that.password); + } + + @Override + public int hashCode() { + return Objects.hash(firstName, lastName, email, password); + } + + @Override + public String toString() { + return "RegisterRequest{" + "firstName=" + firstName + ", lastName=" + lastName + ", email=" + email + ", password=" + (password == null ? "null" : "****") + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ResetPasswordRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ResetPasswordRequest.java new file mode 100644 index 0000000..596794a --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ResetPasswordRequest.java @@ -0,0 +1,70 @@ +package com.edgareldy.springjdbctutorial.ws.payload.auth; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.Objects; + +/** + * Body of POST /api/v1/auth/reset-password. Token and new password are masked in toString. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class ResetPasswordRequest { + + @NotBlank + private String token; + + @NotBlank + @Size(min = 8, max = 72) + private String newPassword; + + public ResetPasswordRequest() { + } + + public ResetPasswordRequest(String token, String newPassword) { + this.token = token; + this.newPassword = newPassword; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getNewPassword() { + return newPassword; + } + + public void setNewPassword(String newPassword) { + this.newPassword = newPassword; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ResetPasswordRequest that = (ResetPasswordRequest) o; + return Objects.equals(token, that.token) + && Objects.equals(newPassword, that.newPassword); + } + + @Override + public int hashCode() { + return Objects.hash(token, newPassword); + } + + @Override + public String toString() { + return "ResetPasswordRequest{" + "token=" + (token == null ? "null" : "****") + ", newPassword=" + (newPassword == null ? "null" : "****") + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/UserResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/UserResponse.java new file mode 100644 index 0000000..997a9a1 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/UserResponse.java @@ -0,0 +1,126 @@ +package com.edgareldy.springjdbctutorial.ws.payload.auth; + +import java.util.List; +import java.util.Objects; + +/** + * Public view of a user account (never the password). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class UserResponse { + + private Long id; + + private String firstName; + + private String lastName; + + private String email; + + private boolean enabled; + + private List roles; + + private List permissions; + + public UserResponse() { + } + + public UserResponse(Long id, String firstName, String lastName, String email, boolean enabled, List roles, List permissions) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.enabled = enabled; + this.roles = roles; + this.permissions = permissions; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public List getRoles() { + return roles; + } + + public void setRoles(List roles) { + this.roles = roles; + } + + 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; + } + UserResponse that = (UserResponse) o; + return Objects.equals(id, that.id) + && Objects.equals(firstName, that.firstName) + && Objects.equals(lastName, that.lastName) + && Objects.equals(email, that.email) + && enabled == that.enabled + && Objects.equals(roles, that.roles) + && Objects.equals(permissions, that.permissions); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, email, enabled, roles, permissions); + } + + @Override + public String toString() { + return "UserResponse{" + "id=" + id + ", firstName=" + firstName + ", lastName=" + lastName + ", email=" + email + ", enabled=" + enabled + ", roles=" + roles + ", permissions=" + permissions + "}"; + } +} From 78555502170577ef543505a56769871bc9c4bb26 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:08 +0400 Subject: [PATCH 049/144] feat(ws): expose the auth endpoints in AuthController The controller depends only on AuthService and JwtService and holds no business rule: register, activate, login (authenticate then issue a JWT), logout (revoke the token of the authenticated principal), me, forgot and reset password. Every body is validated and every answer is an ApiResponse. forgot-password always answers with the same generic message, so the endpoint cannot be used to discover which emails are registered. @AuthenticationPrincipal injects the AuthenticatedUser built by the filter. --- .../ws/controller/AuthController.java | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/AuthController.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/AuthController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/AuthController.java new file mode 100644 index 0000000..948b553 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/AuthController.java @@ -0,0 +1,92 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.auth.dto.BlacklistedTokenDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.ws.converter.UserConverter; +import com.edgareldy.springjdbctutorial.ws.payload.auth.ForgotPasswordRequest; +import com.edgareldy.springjdbctutorial.ws.payload.auth.LoginRequest; +import com.edgareldy.springjdbctutorial.ws.payload.auth.LoginResponse; +import com.edgareldy.springjdbctutorial.ws.payload.auth.RegisterRequest; +import com.edgareldy.springjdbctutorial.ws.payload.auth.ResetPasswordRequest; +import com.edgareldy.springjdbctutorial.ws.payload.auth.UserResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import com.edgareldy.springjdbctutorial.ws.security.AuthenticatedUser; +import com.edgareldy.springjdbctutorial.ws.security.IssuedToken; +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points of registration, activation, login, logout, profile and password reset. No business logic. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @AuthenticationPrincipal injects the principal the JwtAuthFilter put in the SecurityContext. +@RestController +@RequestMapping("/api/v1/auth") +public class AuthController { + + private final AuthService authService; + private final JwtService jwtService; + + public AuthController(AuthService authService, JwtService jwtService) { + this.authService = authService; + this.jwtService = jwtService; + } + + @PostMapping("/register") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse register(@Valid @RequestBody RegisterRequest request) { + UserDto created = authService.register(UserConverter.toDto(request)); + return ApiResponse.success(UserConverter.toResponse(created), "Account created, activation required"); + } + + @GetMapping("/activate-account") + public ApiResponse activateAccount(@RequestParam("token") String token) { + authService.activateAccount(token); + return ApiResponse.success(null, "Account activated"); + } + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginRequest request) { + UserDto user = authService.login(request.getEmail(), request.getPassword()); + IssuedToken issued = jwtService.issue(user); + return ApiResponse.success(new LoginResponse(issued.getToken(), "Bearer", issued.getExpiresAt()), "Login successful"); + } + + @PostMapping("/logout") + public ApiResponse logout(@AuthenticationPrincipal AuthenticatedUser principal) { + authService.logout(new BlacklistedTokenDto(principal.getUserId(), principal.getTokenHash(), + principal.getJti(), principal.getIssuedAt(), principal.getExpiresAt())); + return ApiResponse.success(null, "Logged out"); + } + + @GetMapping("/me") + public ApiResponse me(@AuthenticationPrincipal AuthenticatedUser principal) { + return ApiResponse.success(UserConverter.toResponse(authService.getProfile(principal.getUserId())), "Profile"); + } + + @PostMapping("/forgot-password") + public ApiResponse forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) { + authService.forgotPassword(request.getEmail()); + return ApiResponse.success(null, "If the email is registered, a reset link has been generated"); + } + + @PostMapping("/reset-password") + public ApiResponse resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + authService.resetPassword(request.getToken(), request.getNewPassword()); + return ApiResponse.success(null, "Password reset"); + } +} From c92b262f06979cb810841c3c59776b5bb559d1d5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:08 +0400 Subject: [PATCH 050/144] feat(ws): map authentication and access denied exceptions to 401 and 403 Exceptions thrown inside a controller reach the advice before the security filters, so an AccessDeniedException raised by method security would fall into the catch-all and become a 500. The advice now maps AuthenticationFailedException and Spring's AuthenticationException to 401 and AccessDeniedException to 403, all as ApiResponse. This fills the spot the core-architecture branch left open for it. --- .../ws/exception/GlobalExceptionHandler.java | 23 +++++++++++++++++-- 1 file changed, 21 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 47ddacc..393f832 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 @@ -1,5 +1,6 @@ package com.edgareldy.springjdbctutorial.ws.exception; +import com.edgareldy.springjdbctutorial.core.auth.exception.AuthenticationFailedException; import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; @@ -11,6 +12,8 @@ import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; import org.springframework.validation.FieldError; import org.springframework.web.ErrorResponse; import org.springframework.web.HttpMediaTypeNotAcceptableException; @@ -126,8 +129,24 @@ public ResponseEntity> handleNotAcceptable(HttpMediaTypeNotAcc return build(HttpStatus.NOT_ACCEPTABLE, "Not acceptable"); } - // AUTH BRANCH: add handlers here for AccessDeniedException (403) and AuthenticationException (401). - // They are intentionally absent now: no Spring Security dependency exists yet. + // Authentication failures raised by the auth service (bad credentials, inactive account) are 401 + // with the core message. Spring Security's own AuthenticationException gets a generic message. + @ExceptionHandler(AuthenticationFailedException.class) + public ResponseEntity> handleAuthenticationFailed(AuthenticationFailedException e) { + return build(HttpStatus.UNAUTHORIZED, e.getMessage()); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity> handleSpringAuthentication(AuthenticationException e) { + return build(HttpStatus.UNAUTHORIZED, "Authentication required"); + } + + // A denied @PreAuthorize inside a controller surfaces as AccessDeniedException: without this handler + // the catch-all would turn it into a 500. + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException e) { + return build(HttpStatus.FORBIDDEN, "Access denied"); + } // Catch-all. Other Spring MVC exceptions implementing ErrorResponse keep the status they carry but // never their detail text (framework wording): the client gets the standard reason phrase of the From 59a35cda8ec543d1a38792fa54f5ef0426cbd80e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:08 +0400 Subject: [PATCH 051/144] feat(ws): import the auth module and security into the shared context WebMvcConfig now imports the auth ServiceConfig, by fully qualified name since every core module has a ServiceConfig, and SecurityConfig. One ObjectMapper bean is shared by the MVC converter and the security handlers so all JSON has the same format. WebAppInitializer registers a DelegatingFilterProxy that looks up the springSecurityFilterChain bean in the root context, which is why a single shared context matters. It is registered after the character encoding filter. --- .../ws/config/WebAppInitializer.java | 12 +++++++++ .../ws/config/WebMvcConfig.java | 27 ++++++++++++------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java index 9bec963..502823d 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java @@ -7,6 +7,7 @@ import org.springframework.web.context.ContextLoaderListener; import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; import org.springframework.web.filter.CharacterEncodingFilter; +import org.springframework.web.filter.DelegatingFilterProxy; import org.springframework.web.servlet.DispatcherServlet; import java.util.EnumSet; @@ -45,5 +46,16 @@ public void onStartup(ServletContext servletContext) { servletContext.addFilter("characterEncodingFilter", new CharacterEncodingFilter("UTF-8", true)); encoding.setAsyncSupported(true); encoding.addMappingForUrlPatterns(EnumSet.of(jakarta.servlet.DispatcherType.REQUEST), false, "/*"); + + // DelegatingFilterProxy is a thin servlet filter that looks up the Spring bean named + // "springSecurityFilterChain" (created by @EnableWebSecurity) in the ROOT context and delegates + // to it. That is why the one context must be the root context too (ContextLoaderListener): the + // servlet container instantiates the filter, only Spring can build the security chain. It is + // registered after the encoding filter, on every dispatch type incl. ERROR and ASYNC. + FilterRegistration.Dynamic security = + servletContext.addFilter("springSecurityFilterChain", new DelegatingFilterProxy("springSecurityFilterChain")); + security.setAsyncSupported(true); + security.addMappingForUrlPatterns(EnumSet.of(jakarta.servlet.DispatcherType.REQUEST, + jakarta.servlet.DispatcherType.ERROR, jakarta.servlet.DispatcherType.ASYNC), false, "/*"); } } diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index 1c93d58..295e659 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -29,28 +29,35 @@ // plain Spring, without Boot. The WebMvcConfigurer callbacks below customise it. // // Core module configs are imported here in the SAME context (one shared context). CommonConfig comes -// first; each later branch adds its module's DaoConfig and ServiceConfig to the @Import list. +// first; each later branch adds its module's ServiceConfig (which imports its own DaoConfig) to the @Import +// list, fully qualified because every module uses the same simple names. SecurityConfig is imported too. // // The component scan is limited to the web layer (controllers and the advice): those are the only // stereotypes of the project. DAO and service implementations are never scanned, they are explicit // @Bean methods, so nothing gets wired by accident. @Configuration @EnableWebMvc -@Import(CommonConfig.class) +@Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class, SecurityConfig.class}) @ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"}) public class WebMvcConfig implements WebMvcConfigurer { - // Swaps the default Jackson converter for one built on our ObjectMapper and keeps every other - // default converter (String, byte[], Resource...). The builder registers the JSR-310 module - // (jackson-datatype-jsr310) found on the classpath; disabling WRITE_DATES_AS_TIMESTAMPS makes - // Instant serialise as ISO-8601 text instead of a number. Null fields stay in the output. - @Override - public void extendMessageConverters(List> converters) { - ObjectMapper mapper = Jackson2ObjectMapperBuilder.json() + // ONE shared ObjectMapper bean: the MVC converter below and the security handlers (401/403 bodies, + // written outside MVC) must serialise ApiResponse identically. The builder registers the JSR-310 + // module found on the classpath; disabling WRITE_DATES_AS_TIMESTAMPS makes Instant serialise as + // ISO-8601 text instead of a number. Null fields stay in the output. + @Bean + public ObjectMapper objectMapper() { + return Jackson2ObjectMapperBuilder.json() .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build(); + } + + // Swaps the default Jackson converter for one built on the shared ObjectMapper and keeps every + // other default converter (String, byte[], Resource...). + @Override + public void extendMessageConverters(List> converters) { converters.removeIf(MappingJackson2HttpMessageConverter.class::isInstance); - converters.add(0, new MappingJackson2HttpMessageConverter(mapper)); + converters.add(0, new MappingJackson2HttpMessageConverter(objectMapper())); } // LocalValidatorFactoryBean bootstraps Bean Validation (Hibernate Validator on the classpath). From ec6cfc5f10dc10ea05be5b8d42d86130c083b3bb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 052/144] build(auth): add Testcontainers test dependencies to the auth module The DAO tests of the auth module run against a real PostgreSQL, so the module declares the Testcontainers JUnit and PostgreSQL artifacts, and the common test-jar that holds the shared singleton container, all with test scope. They stay in this module rather than the parent pom because only modules with DAO tests need them. --- core/auth/pom.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/core/auth/pom.xml b/core/auth/pom.xml index d1a4560..1469698 100644 --- a/core/auth/pom.xml +++ b/core/auth/pom.xml @@ -24,5 +24,23 @@ org.springframework.security spring-security-crypto + + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + From 2f9cb0d90e304fb0b790629928b0936b7aa99e0a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 053/144] test(auth): cover the user and token DAOs against PostgreSQL Each DAO test class loads its own dedicated SQL fixture through @Sql before every test, which truncates the tables it touches with RESTART IDENTITY and inserts its own rows, so no dataset is shared between classes. UserDao tests cover case-insensitive lookups, generated keys, a duplicate email differing only by case, and the joins returning distinct sorted permission codes. The token DAO tests cover insert, lookup by digest, marking a token validated, deletion by user, and the purge counts of the blacklist. --- .../dao/impl/ActivationTokenDaoImplTest.java | 85 ++++++++++++ .../dao/impl/BlacklistedTokenDaoImplTest.java | 88 ++++++++++++ .../impl/PasswordResetTokenDaoImplTest.java | 75 ++++++++++ .../core/auth/dao/impl/UserDaoImplTest.java | 128 ++++++++++++++++++ .../activation-token-dao-dataset.sql | 14 ++ .../blacklisted-token-dao-dataset.sql | 14 ++ .../password-reset-token-dao-dataset.sql | 14 ++ .../src/test/resources/user-dao-dataset.sql | 22 +++ 8 files changed, 440 insertions(+) create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImplTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImplTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImplTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImplTest.java create mode 100644 core/auth/src/test/resources/activation-token-dao-dataset.sql create mode 100644 core/auth/src/test/resources/blacklisted-token-dao-dataset.sql create mode 100644 core/auth/src/test/resources/password-reset-token-dao-dataset.sql create mode 100644 core/auth/src/test/resources/user-dao-dataset.sql diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImplTest.java new file mode 100644 index 0000000..3afb0f4 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImplTest.java @@ -0,0 +1,85 @@ +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.ActivationTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.time.Instant; +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 ActivationTokenDaoImpl against the real PostgreSQL, with its own fixture file + * activation-token-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("/activation-token-dao-dataset.sql") +class ActivationTokenDaoImplTest { + + private static final String HASH_USER_1 = "a1".repeat(32); + private static final String HASH_USER_2 = "b2".repeat(32); + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private ActivationTokenDao dao; + + @Test + void _01_ShouldInsertAndGenerateId_WhenTokenIsNew() { + Instant created = Instant.parse("2026-09-19T12:00:00Z"); + ActivationToken inserted = dao.insert(new ActivationToken(null, 1L, "9f".repeat(32), created, + created.plusSeconds(3600), null)); + + assertThat(inserted.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + ActivationToken reloaded = dao.findByTokenHash("9f".repeat(32)).orElseThrow(); + assertThat(reloaded.getUserId()).isEqualTo(1L); + assertThat(reloaded.getCreatedAt()).isEqualTo(created); + assertThat(reloaded.getExpiresAt()).isEqualTo(created.plusSeconds(3600)); + assertThat(reloaded.getValidatedAt()).isNull(); + } + + @Test + void _02_ShouldFindToken_WhenHashExists() { + ActivationToken token = dao.findByTokenHash(HASH_USER_2).orElseThrow(); + + assertThat(token.getUserId()).isEqualTo(2L); + assertThat(token.getValidatedAt()).isEqualTo(Instant.parse("2026-09-18T11:00:00Z")); + } + + @Test + void _03_ShouldReturnEmpty_WhenHashIsUnknown() { + assertThat(dao.findByTokenHash("00".repeat(32))).isEmpty(); + } + + @Test + void _04_ShouldStoreValidationDate_WhenTokenIsMarkedValidated() { + Instant validatedAt = Instant.parse("2026-09-19T12:30:00Z"); + + dao.markValidated(1L, validatedAt); + + assertThat(dao.findByTokenHash(HASH_USER_1).orElseThrow().getValidatedAt()).isEqualTo(validatedAt); + } + + @Test + void _05_ShouldDeleteOnlyThatUsersTokens_WhenDeletedByUserId() { + dao.deleteByUserId(1L); + + assertThat(dao.findByTokenHash(HASH_USER_1)).isEmpty(); + assertThat(dao.findByTokenHash(HASH_USER_2)).isPresent(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImplTest.java new file mode 100644 index 0000000..b6cb7d8 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImplTest.java @@ -0,0 +1,88 @@ +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.BlacklistedTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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 BlacklistedTokenDaoImpl against the real PostgreSQL, with its own fixture file + * blacklisted-token-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("/blacklisted-token-dao-dataset.sql") +class BlacklistedTokenDaoImplTest { + + private static final String HASH_EXPIRED_1 = "e5".repeat(32); + private static final String HASH_EXPIRED_2 = "f6".repeat(32); + private static final String HASH_LIVE = "a7".repeat(32); + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private BlacklistedTokenDao dao; + + @Test + void _01_ShouldInsertAndGenerateId_WhenTokenIsNew() { + Instant now = Instant.parse("2026-09-19T12:00:00Z"); + BlacklistedToken inserted = dao.insert(new BlacklistedToken(null, 1L, "1c".repeat(32), "jti-new", now, + now.minusSeconds(60), now.plusSeconds(3600), null)); + + assertThat(inserted.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + assertThat(dao.existsByTokenHash("1c".repeat(32))).isTrue(); + } + + @Test + void _02_ShouldReturnTrue_WhenHashIsBlacklisted() { + assertThat(dao.existsByTokenHash(HASH_LIVE)).isTrue(); + } + + @Test + void _03_ShouldReturnFalse_WhenHashIsNotBlacklisted() { + assertThat(dao.existsByTokenHash("00".repeat(32))).isFalse(); + } + + @Test + void _04_ShouldThrowDuplicateKeyException_WhenSameJtiIsInsertedTwice() { + Instant now = Instant.parse("2026-09-19T12:00:00Z"); + BlacklistedToken sameJti = new BlacklistedToken(null, 1L, "2d".repeat(32), "jti-live", now, + now, now.plusSeconds(3600), null); + + assertThatThrownBy(() -> dao.insert(sameJti)).isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _05_ShouldDeleteOnlyExpiredRowsAndReturnTheirCount_WhenPurgedBeforeAnInstant() { + int deleted = dao.deleteExpiredBefore(Instant.parse("2026-09-19T00:00:00Z")); + + assertThat(deleted).isEqualTo(2); + assertThat(dao.existsByTokenHash(HASH_EXPIRED_1)).isFalse(); + assertThat(dao.existsByTokenHash(HASH_EXPIRED_2)).isFalse(); + assertThat(dao.existsByTokenHash(HASH_LIVE)).isTrue(); + } + + @Test + void _06_ShouldDeleteNothing_WhenNoRowIsExpiredBeforeTheInstant() { + assertThat(dao.deleteExpiredBefore(Instant.parse("2026-09-01T00:00:00Z"))).isZero(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImplTest.java new file mode 100644 index 0000000..aeba82c --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImplTest.java @@ -0,0 +1,75 @@ +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.PasswordResetTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.time.Instant; +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 PasswordResetTokenDaoImpl against the real PostgreSQL, with its own fixture file + * password-reset-token-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("/password-reset-token-dao-dataset.sql") +class PasswordResetTokenDaoImplTest { + + private static final String HASH_USER_1 = "c3".repeat(32); + private static final String HASH_USER_2 = "d4".repeat(32); + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private PasswordResetTokenDao dao; + + @Test + void _01_ShouldInsertAndGenerateId_WhenTokenIsNew() { + Instant expiry = Instant.parse("2026-09-19T13:00:00Z"); + PasswordResetToken inserted = dao.insert(new PasswordResetToken(null, 2L, "8e".repeat(32), + "PASSWORD_RESET", expiry)); + + assertThat(inserted.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + PasswordResetToken reloaded = dao.findByTokenHash("8e".repeat(32)).orElseThrow(); + assertThat(reloaded.getUserId()).isEqualTo(2L); + assertThat(reloaded.getType()).isEqualTo("PASSWORD_RESET"); + assertThat(reloaded.getExpiryDate()).isEqualTo(expiry); + } + + @Test + void _02_ShouldFindToken_WhenHashExists() { + PasswordResetToken token = dao.findByTokenHash(HASH_USER_1).orElseThrow(); + + assertThat(token.getUserId()).isEqualTo(1L); + assertThat(token.getExpiryDate()).isEqualTo(Instant.parse("2026-09-19T11:00:00Z")); + } + + @Test + void _03_ShouldReturnEmpty_WhenHashIsUnknown() { + assertThat(dao.findByTokenHash("00".repeat(32))).isEmpty(); + } + + @Test + void _04_ShouldDeleteOnlyThatUsersTokens_WhenDeletedByUserId() { + dao.deleteByUserId(1L); + + assertThat(dao.findByTokenHash(HASH_USER_1)).isEmpty(); + assertThat(dao.findByTokenHash(HASH_USER_2)).isPresent(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImplTest.java new file mode 100644 index 0000000..dacc9e3 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImplTest.java @@ -0,0 +1,128 @@ +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.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 org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +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 UserDaoImpl against the real PostgreSQL, with its own fixture file user-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// DAO tests run real SQL on a real PostgreSQL started by Testcontainers (Docker), migrated by Flyway +// exactly like production. @SpringJUnitConfig builds a Spring context from the two real configuration +// classes; @DynamicPropertySource injects the container's random JDBC url into the Environment because +// the port is only known once the container runs. @Sql executes this class's own fixture file before +// EACH test, so every test starts from the same known rows. +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/user-dao-dataset.sql") +class UserDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private UserDao userDao; + + @Test + void _01_ShouldReturnUser_WhenIdExists() { + User user = userDao.findById(1L).orElseThrow(); + + assertThat(user.getFirstName()).isEqualTo("Alice"); + assertThat(user.getLastName()).isEqualTo("Martin"); + assertThat(user.getEmail()).isEqualTo("alice@example.com"); + assertThat(user.isEnabled()).isTrue(); + assertThat(user.isAccountLocked()).isFalse(); + } + + @Test + void _02_ShouldReturnEmpty_WhenIdDoesNotExist() { + assertThat(userDao.findById(999L)).isEmpty(); + } + + @Test + void _03_ShouldFindUser_WhenEmailDiffersOnlyByCase() { + assertThat(userDao.findByEmail("ALICE@Example.COM")).get().extracting(User::getId).isEqualTo(1L); + } + + @Test + void _04_ShouldReturnEmpty_WhenEmailIsUnknown() { + assertThat(userDao.findByEmail("nobody@example.com")).isEmpty(); + } + + @Test + void _05_ShouldReturnTrue_WhenEmailExistsWhateverItsCase() { + assertThat(userDao.existsByEmail("BOB@EXAMPLE.com")).isTrue(); + } + + @Test + void _06_ShouldReturnFalse_WhenEmailDoesNotExist() { + assertThat(userDao.existsByEmail("nobody@example.com")).isFalse(); + } + + @Test + void _07_ShouldGenerateIdAndPersistRow_WhenUserIsInserted() { + User created = userDao.insert(new User(null, "Dave", "Roux", "dave@example.com", "hash-dave", false, false)); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + User reloaded = userDao.findById(created.getId()).orElseThrow(); + assertThat(reloaded.getEmail()).isEqualTo("dave@example.com"); + assertThat(reloaded.getPassword()).isEqualTo("hash-dave"); + assertThat(reloaded.isEnabled()).isFalse(); + } + + @Test + void _08_ShouldThrowDuplicateKeyException_WhenEmailDiffersOnlyByCaseFromAnExistingOne() { + User duplicate = new User(null, "Alice", "Clone", "ALICE@EXAMPLE.COM", "hash-clone", false, false); + + assertThatThrownBy(() -> userDao.insert(duplicate)).isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _09_ShouldStoreNewHash_WhenPasswordIsUpdated() { + userDao.updatePassword(2L, "new-hash"); + + assertThat(userDao.findById(2L).orElseThrow().getPassword()).isEqualTo("new-hash"); + } + + @Test + void _10_ShouldEnableAccount_WhenEnabledIsUpdated() { + userDao.updateEnabled(2L, true); + + assertThat(userDao.findById(2L).orElseThrow().isEnabled()).isTrue(); + } + + @Test + void _11_ShouldReturnRoleNamesSorted_WhenUserHasRoles() { + assertThat(userDao.findRoleNamesByUserId(1L)).containsExactly("ADMIN", "EDITOR"); + } + + @Test + void _12_ShouldReturnDistinctSortedPermissionCodes_WhenTwoRolesGrantTheSamePermission() { + // ADMIN grants USER:READ, USER:WRITE, ROLE:READ and EDITOR grants USER:READ again + assertThat(userDao.findPermissionCodesByUserId(1L)).containsExactly("ROLE:READ", "USER:READ", "USER:WRITE"); + } + + @Test + void _13_ShouldReturnEmptyLists_WhenUserHasNoRole() { + assertThat(userDao.findRoleNamesByUserId(2L)).isEmpty(); + assertThat(userDao.findPermissionCodesByUserId(2L)).isEmpty(); + } +} diff --git a/core/auth/src/test/resources/activation-token-dao-dataset.sql b/core/auth/src/test/resources/activation-token-dao-dataset.sql new file mode 100644 index 0000000..304a4ab --- /dev/null +++ b/core/auth/src/test/resources/activation-token-dao-dataset.sql @@ -0,0 +1,14 @@ +-- Fixture of ActivationTokenDaoImplTest only. Wipes the identity tables, then loads two users (1 and 2) +-- and two activation tokens: 'a1...' for user 1 (not validated), 'b2...' for user 2 (already validated). +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-1', FALSE, FALSE), + (2, 'Bob', 'Durand', 'bob@example.com', 'hash-2', TRUE, FALSE); + +INSERT INTO activation_tokens (id, user_id, token, created_at, expires_at, validated_at) VALUES + (1, 1, repeat('a1', 32), '2026-09-19T10:00:00Z', '2026-09-20T10:00:00Z', NULL), + (2, 2, repeat('b2', 32), '2026-09-18T10:00:00Z', '2026-09-19T10:00:00Z', '2026-09-18T11:00:00Z'); + +ALTER SEQUENCE users_id_seq RESTART WITH 100; +ALTER SEQUENCE activation_tokens_id_seq RESTART WITH 100; diff --git a/core/auth/src/test/resources/blacklisted-token-dao-dataset.sql b/core/auth/src/test/resources/blacklisted-token-dao-dataset.sql new file mode 100644 index 0000000..d3cc860 --- /dev/null +++ b/core/auth/src/test/resources/blacklisted-token-dao-dataset.sql @@ -0,0 +1,14 @@ +-- Fixture of BlacklistedTokenDaoImplTest only. Wipes the identity tables, then loads one user and three +-- blacklisted tokens: two expired before 2026-09-19T00:00:00Z ('e5...', 'f6...') and one still live ('a7...'). +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-1', TRUE, FALSE); + +INSERT INTO blacklisted_tokens (id, user_id, token, jti, blacklisted_at, created_at, expires_at, validated_at) VALUES + (1, 1, repeat('e5', 32), 'jti-expired-1', '2026-09-17T10:30:00Z', '2026-09-17T10:00:00Z', '2026-09-17T11:00:00Z', NULL), + (2, 1, repeat('f6', 32), 'jti-expired-2', '2026-09-17T12:30:00Z', '2026-09-17T12:00:00Z', '2026-09-17T13:00:00Z', NULL), + (3, 1, repeat('a7', 32), 'jti-live', '2026-09-19T10:30:00Z', '2026-09-19T10:00:00Z', '2026-09-19T11:00:00Z', NULL); + +ALTER SEQUENCE users_id_seq RESTART WITH 100; +ALTER SEQUENCE blacklisted_tokens_id_seq RESTART WITH 100; diff --git a/core/auth/src/test/resources/password-reset-token-dao-dataset.sql b/core/auth/src/test/resources/password-reset-token-dao-dataset.sql new file mode 100644 index 0000000..895ae6b --- /dev/null +++ b/core/auth/src/test/resources/password-reset-token-dao-dataset.sql @@ -0,0 +1,14 @@ +-- Fixture of PasswordResetTokenDaoImplTest only. Wipes the identity tables, then loads two users and +-- one reset token for each ('c3...' for user 1, 'd4...' for user 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-1', TRUE, FALSE), + (2, 'Bob', 'Durand', 'bob@example.com', 'hash-2', TRUE, FALSE); + +INSERT INTO password_reset_tokens (id, user_id, token, type, expiry_date) VALUES + (1, 1, repeat('c3', 32), 'PASSWORD_RESET', '2026-09-19T11:00:00Z'), + (2, 2, repeat('d4', 32), 'PASSWORD_RESET', '2026-09-19T12:00:00Z'); + +ALTER SEQUENCE users_id_seq RESTART WITH 100; +ALTER SEQUENCE password_reset_tokens_id_seq RESTART WITH 100; diff --git a/core/auth/src/test/resources/user-dao-dataset.sql b/core/auth/src/test/resources/user-dao-dataset.sql new file mode 100644 index 0000000..854dd4c --- /dev/null +++ b/core/auth/src/test/resources/user-dao-dataset.sql @@ -0,0 +1,22 @@ +-- Fixture of UserDaoImplTest only. Wipes the identity tables, then loads three users, three roles and +-- three permissions. alice (id 1) holds ADMIN and EDITOR, which both grant USER:READ (tests DISTINCT). +-- bob (id 2) is disabled with no role, carol (id 3) is enabled but locked. +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', '$2a$04$alicealicealicealiceaeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', TRUE, FALSE), + (2, 'Bob', 'Durand', 'bob@example.com', '$2a$04$bobbobbobbobbobbobbobeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', FALSE, FALSE), + (3, 'Carol', 'Petit', 'carol@example.com', '$2a$04$carolcarolcarolcarolceAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', TRUE, TRUE); + +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'); + +INSERT INTO role_permission (role_id, permission_id) VALUES (1, 1), (1, 2), (1, 3), (2, 1), (3, 3); + +INSERT INTO role_user (role_id, user_id) VALUES (1, 1), (2, 1); + +-- 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 56c753cda34e5e1b7236ab31a6ded4127bf38039 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 054/144] test(auth): test the RowMappers with a mocked ResultSet and UserMapper Each RowMapper is fed a Mockito mock of java.sql.ResultSet stubbed column by column, so no database is involved, including the case of a NULL validated_at. UserMapper is tested with hand-built objects and no mocks: toDto never copies the password hash and toEntity uses the hash it is given. These are the fastest tests of the strategy and pin down the mapping contracts the DAO and service layers rely on. --- .../mapper/ActivationTokenRowMapperTest.java | 62 +++++++++++++++++ .../mapper/BlacklistedTokenRowMapperTest.java | 67 +++++++++++++++++++ .../PasswordResetTokenRowMapperTest.java | 52 ++++++++++++++ .../core/auth/mapper/UserMapperTest.java | 64 ++++++++++++++++++ .../core/auth/mapper/UserRowMapperTest.java | 60 +++++++++++++++++ 5 files changed, 305 insertions(+) create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapperTest.java diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapperTest.java new file mode 100644 index 0000000..d0e1096 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapperTest.java @@ -0,0 +1,62 @@ +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.ActivationToken; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Tests ActivationTokenRowMapper against a mocked ResultSet, including a NULL validated_at. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class ActivationTokenRowMapperTest { + + private static final Instant CREATED = Instant.parse("2026-09-19T10:00:00Z"); + private static final Instant EXPIRES = Instant.parse("2026-09-20T10:00:00Z"); + + private final ActivationTokenRowMapper mapper = new ActivationTokenRowMapper(); + + private ResultSet baseRow() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(3L); + when(rs.getLong("user_id")).thenReturn(9L); + when(rs.getString("token")).thenReturn("hash"); + when(rs.getTimestamp("created_at")).thenReturn(Timestamp.from(CREATED)); + when(rs.getTimestamp("expires_at")).thenReturn(Timestamp.from(EXPIRES)); + return rs; + } + + @Test + void _01_ShouldMapEveryColumn_WhenTokenIsValidated() throws SQLException { + ResultSet rs = baseRow(); + Instant validated = Instant.parse("2026-09-19T11:00:00Z"); + when(rs.getTimestamp("validated_at")).thenReturn(Timestamp.from(validated)); + + ActivationToken token = mapper.mapRow(rs, 0); + + assertThat(token.getId()).isEqualTo(3L); + assertThat(token.getUserId()).isEqualTo(9L); + assertThat(token.getToken()).isEqualTo("hash"); + assertThat(token.getCreatedAt()).isEqualTo(CREATED); + assertThat(token.getExpiresAt()).isEqualTo(EXPIRES); + assertThat(token.getValidatedAt()).isEqualTo(validated); + } + + @Test + void _02_ShouldMapNullValidationDate_WhenColumnIsNull() throws SQLException { + ResultSet rs = baseRow(); + when(rs.getTimestamp("validated_at")).thenReturn(null); + + assertThat(mapper.mapRow(rs, 0).getValidatedAt()).isNull(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapperTest.java new file mode 100644 index 0000000..627d8fa --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapperTest.java @@ -0,0 +1,67 @@ +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.BlacklistedToken; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Tests BlacklistedTokenRowMapper against a mocked ResultSet, including a NULL validated_at. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class BlacklistedTokenRowMapperTest { + + private static final Instant BLACKLISTED = Instant.parse("2026-09-19T10:30:00Z"); + private static final Instant CREATED = Instant.parse("2026-09-19T10:00:00Z"); + private static final Instant EXPIRES = Instant.parse("2026-09-19T11:00:00Z"); + + private final BlacklistedTokenRowMapper mapper = new BlacklistedTokenRowMapper(); + + private ResultSet baseRow() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(4L); + when(rs.getLong("user_id")).thenReturn(9L); + when(rs.getString("token")).thenReturn("hash"); + when(rs.getString("jti")).thenReturn("jti-1"); + when(rs.getTimestamp("blacklisted_at")).thenReturn(Timestamp.from(BLACKLISTED)); + when(rs.getTimestamp("created_at")).thenReturn(Timestamp.from(CREATED)); + when(rs.getTimestamp("expires_at")).thenReturn(Timestamp.from(EXPIRES)); + return rs; + } + + @Test + void _01_ShouldMapEveryColumn_WhenValidationDateIsSet() throws SQLException { + ResultSet rs = baseRow(); + Instant validated = Instant.parse("2026-09-19T10:45:00Z"); + when(rs.getTimestamp("validated_at")).thenReturn(Timestamp.from(validated)); + + BlacklistedToken token = mapper.mapRow(rs, 0); + + assertThat(token.getId()).isEqualTo(4L); + assertThat(token.getUserId()).isEqualTo(9L); + assertThat(token.getToken()).isEqualTo("hash"); + assertThat(token.getJti()).isEqualTo("jti-1"); + assertThat(token.getBlacklistedAt()).isEqualTo(BLACKLISTED); + assertThat(token.getCreatedAt()).isEqualTo(CREATED); + assertThat(token.getExpiresAt()).isEqualTo(EXPIRES); + assertThat(token.getValidatedAt()).isEqualTo(validated); + } + + @Test + void _02_ShouldMapNullValidationDate_WhenColumnIsNull() throws SQLException { + ResultSet rs = baseRow(); + when(rs.getTimestamp("validated_at")).thenReturn(null); + + assertThat(mapper.mapRow(rs, 0).getValidatedAt()).isNull(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapperTest.java new file mode 100644 index 0000000..9a9d1f7 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapperTest.java @@ -0,0 +1,52 @@ +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.PasswordResetToken; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Tests PasswordResetTokenRowMapper against a mocked ResultSet, including a NULL expiry_date. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class PasswordResetTokenRowMapperTest { + + private final PasswordResetTokenRowMapper mapper = new PasswordResetTokenRowMapper(); + + @Test + void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException { + Instant expiry = Instant.parse("2026-09-19T11:00:00Z"); + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(5L); + when(rs.getLong("user_id")).thenReturn(9L); + when(rs.getString("token")).thenReturn("hash"); + when(rs.getString("type")).thenReturn("PASSWORD_RESET"); + when(rs.getTimestamp("expiry_date")).thenReturn(Timestamp.from(expiry)); + + PasswordResetToken token = mapper.mapRow(rs, 0); + + assertThat(token.getId()).isEqualTo(5L); + assertThat(token.getUserId()).isEqualTo(9L); + assertThat(token.getToken()).isEqualTo("hash"); + assertThat(token.getType()).isEqualTo("PASSWORD_RESET"); + assertThat(token.getExpiryDate()).isEqualTo(expiry); + } + + @Test + void _02_ShouldMapNullExpiry_WhenColumnIsNull() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getTimestamp("expiry_date")).thenReturn(null); + + assertThat(mapper.mapRow(rs, 0).getExpiryDate()).isNull(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapperTest.java new file mode 100644 index 0000000..5a0cc14 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapperTest.java @@ -0,0 +1,64 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests UserMapper (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 UserMapperTest { + + private final UserMapper mapper = new UserMapper(); + + @Test + void _01_ShouldCopyProfileFieldsButNeverThePassword_WhenConvertingEntityToDto() { + User user = new User(1L, "Alice", "Martin", "alice@example.com", "secret-hash", true, false); + + UserDto dto = mapper.toDto(user); + + assertThat(dto.getId()).isEqualTo(1L); + assertThat(dto.getFirstName()).isEqualTo("Alice"); + assertThat(dto.getLastName()).isEqualTo("Martin"); + assertThat(dto.getEmail()).isEqualTo("alice@example.com"); + assertThat(dto.isEnabled()).isTrue(); + assertThat(dto.isAccountLocked()).isFalse(); + assertThat(dto.getPassword()).isNull(); + assertThat(dto.getRoles()).isEmpty(); + assertThat(dto.getPermissions()).isEmpty(); + } + + @Test + void _02_ShouldReturnNull_WhenEntityIsNull() { + assertThat(mapper.toDto(null)).isNull(); + } + + @Test + void _03_ShouldUseTheGivenHashAndIgnoreTheDtoPassword_WhenConvertingDtoToEntity() { + UserDto dto = new UserDto(2L, "Bob", "Durand", "bob@example.com", "clear-password", false, true, + List.of("ADMIN"), List.of("USER:READ")); + + User user = mapper.toEntity(dto, "the-bcrypt-hash"); + + assertThat(user.getId()).isEqualTo(2L); + assertThat(user.getFirstName()).isEqualTo("Bob"); + assertThat(user.getLastName()).isEqualTo("Durand"); + assertThat(user.getEmail()).isEqualTo("bob@example.com"); + assertThat(user.getPassword()).isEqualTo("the-bcrypt-hash"); + assertThat(user.isEnabled()).isFalse(); + assertThat(user.isAccountLocked()).isTrue(); + } + + @Test + void _04_ShouldReturnNull_WhenDtoIsNull() { + assertThat(mapper.toEntity(null, "hash")).isNull(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapperTest.java new file mode 100644 index 0000000..90087f9 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapperTest.java @@ -0,0 +1,60 @@ +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.User; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** + * Tests UserRowMapper 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 UserRowMapperTest { + + private final UserRowMapper mapper = new UserRowMapper(); + + // A RowMapper only reads columns from the ResultSet it is given. Mockito.mock(ResultSet.class) + // creates a fake one whose getters return what the test stubs, column by column: no database, no + // driver, so the test only checks the column-to-field mapping. + @Test + void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(7L); + when(rs.getString("first_name")).thenReturn("Alice"); + when(rs.getString("last_name")).thenReturn("Martin"); + when(rs.getString("email")).thenReturn("alice@example.com"); + when(rs.getString("password")).thenReturn("hash"); + when(rs.getBoolean("enabled")).thenReturn(true); + when(rs.getBoolean("account_locked")).thenReturn(false); + + User user = mapper.mapRow(rs, 0); + + assertThat(user.getId()).isEqualTo(7L); + assertThat(user.getFirstName()).isEqualTo("Alice"); + assertThat(user.getLastName()).isEqualTo("Martin"); + assertThat(user.getEmail()).isEqualTo("alice@example.com"); + assertThat(user.getPassword()).isEqualTo("hash"); + assertThat(user.isEnabled()).isTrue(); + assertThat(user.isAccountLocked()).isFalse(); + } + + @Test + void _02_ShouldMapLockedAndDisabledFlags_WhenColumnsAreInverted() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getBoolean("enabled")).thenReturn(false); + when(rs.getBoolean("account_locked")).thenReturn(true); + + User user = mapper.mapRow(rs, 0); + + assertThat(user.isEnabled()).isFalse(); + assertThat(user.isAccountLocked()).isTrue(); + } +} From 19953bab8d2a0ee109441f65c2895a80bc3bbec3 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 055/144] test(auth): unit test AuthServiceImpl and the cleanup job with mocked DAOs The service is tested with Mockito DAOs, a real low-cost bcrypt encoder and a fixed Clock so expiry rules are exact. The tests cover the 72-byte limit with multibyte characters, the digest-only storage of tokens, one identical error for an unknown email and a wrong password, a password over 72 bytes treated as invalid credentials, the inactive-account error only after a correct password, an idempotent logout and a forgotPassword that never throws. ExpiredTokenCleanupJobTest calls run() directly, which is why the scheduled method is public. --- .../service/impl/AuthServiceImplTest.java | 517 ++++++++++++++++++ .../impl/ExpiredTokenCleanupJobTest.java | 29 + 2 files changed, 546 insertions(+) create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJobTest.java diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java new file mode 100644 index 0000000..aa3fa23 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java @@ -0,0 +1,517 @@ +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +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 com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.dto.BlacklistedTokenDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; +import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import com.edgareldy.springjdbctutorial.core.auth.exception.AccountNotActiveException; +import com.edgareldy.springjdbctutorial.core.auth.exception.InvalidCredentialsException; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.HexFormat; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * Unit tests of AuthServiceImpl: every DAO is a Mockito mock, the password encoder is a real + * low-cost BCryptPasswordEncoder and the clock is fixed, so expiry rules are deterministic. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// MockitoExtension creates the @Mock fields before each test and fails a test whose stubbing is never +// used (strict stubs), which keeps the tests free of dead set-up. No Spring context is started. +@ExtendWith(MockitoExtension.class) +class AuthServiceImplTest { + + private static final Instant NOW = Instant.parse("2026-09-19T12:00:00Z"); + private static final String PASSWORD = "Secret123!"; + private static final String HEX_64 = "[0-9a-f]{64}"; + + @Mock + private UserDao userDao; + @Mock + private ActivationTokenDao activationTokenDao; + @Mock + private PasswordResetTokenDao passwordResetTokenDao; + @Mock + private BlacklistedTokenDao blacklistedTokenDao; + + // A spy wraps the real encoder (cost 4 keeps the tests fast) and records the calls made on it + private final PasswordEncoder encoder = spy(new BCryptPasswordEncoder(4)); + + // Computed by a separate encoder, so building a User inside a when(...) never calls the spy + private final String passwordHash = new BCryptPasswordEncoder(4).encode(PASSWORD); + + private AuthServiceImpl service; + + @BeforeEach + void setUp() { + service = new AuthServiceImpl(userDao, activationTokenDao, passwordResetTokenDao, blacklistedTokenDao, + encoder, Clock.fixed(NOW, ZoneOffset.UTC)); + } + + private static String sha256(String value) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } + + private static UserDto registration(String email, String password) { + UserDto dto = new UserDto(); + dto.setFirstName("Alice"); + dto.setLastName("Martin"); + dto.setEmail(email); + dto.setPassword(password); + return dto; + } + + private User storedUser(boolean enabled, boolean locked) { + return new User(5L, "Alice", "Martin", "alice@example.com", passwordHash, enabled, locked); + } + + private void stubInsertAssigningId() { + when(userDao.insert(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + user.setId(42L); + return user; + }); + } + + // ---------------------------------------------------------------- register + + @Test + void _01_ShouldCreateDisabledUserWithHashedPassword_WhenRegistrationIsValid() { + stubInsertAssigningId(); + + UserDto created = service.register(registration("alice@example.com", PASSWORD)); + + assertThat(created.getId()).isEqualTo(42L); + assertThat(created.getPassword()).isNull(); + assertThat(created.isEnabled()).isFalse(); + ArgumentCaptor stored = ArgumentCaptor.forClass(User.class); + verify(userDao).insert(stored.capture()); + assertThat(stored.getValue().getPassword()).isNotEqualTo(PASSWORD); + assertThat(encoder.matches(PASSWORD, stored.getValue().getPassword())).isTrue(); + assertThat(stored.getValue().isEnabled()).isFalse(); + assertThat(stored.getValue().isAccountLocked()).isFalse(); + } + + @Test + void _02_ShouldTrimAndLowerCaseEmail_WhenRegistering() { + stubInsertAssigningId(); + + UserDto created = service.register(registration(" Alice@Example.COM ", PASSWORD)); + + assertThat(created.getEmail()).isEqualTo("alice@example.com"); + verify(userDao).existsByEmail("alice@example.com"); + } + + @Test + void _03_ShouldStoreOnlyTheSha256OfTheActivationToken_WhenRegistering() { + stubInsertAssigningId(); + + service.register(registration("alice@example.com", PASSWORD)); + + ArgumentCaptor stored = ArgumentCaptor.forClass(ActivationToken.class); + verify(activationTokenDao).insert(stored.capture()); + // A raw token is 43 base64url characters: a 64 lowercase hex value can only be a SHA-256 digest + assertThat(stored.getValue().getToken()).matches(HEX_64); + assertThat(stored.getValue().getUserId()).isEqualTo(42L); + assertThat(stored.getValue().getCreatedAt()).isEqualTo(NOW); + assertThat(stored.getValue().getExpiresAt()).isEqualTo(NOW.plusSeconds(24 * 3600)); + assertThat(stored.getValue().getValidatedAt()).isNull(); + } + + @Test + void _04_ShouldThrowBusinessRuleException_WhenEmailIsAlreadyRegistered() { + when(userDao.existsByEmail("alice@example.com")).thenReturn(true); + + assertThatThrownBy(() -> service.register(registration("Alice@example.com", PASSWORD))) + .isInstanceOf(BusinessRuleException.class) + .hasMessage("Email is already registered"); + + verify(userDao, never()).insert(any()); + verifyNoInteractions(activationTokenDao); + } + + @Test + void _05_ShouldThrowBusinessRuleException_WhenInsertLosesARaceOnTheUniqueEmailIndex() { + when(userDao.insert(any(User.class))).thenThrow(new DuplicateKeyException("uq_users_email_lower")); + + assertThatThrownBy(() -> service.register(registration("alice@example.com", PASSWORD))) + .isInstanceOf(BusinessRuleException.class) + .hasMessage("Email is already registered"); + + verifyNoInteractions(activationTokenDao); + } + + @Test + void _06_ShouldThrowBusinessRuleException_WhenPasswordExceeds72BytesWithMultibyteCharacters() { + // 37 x "e acute" is 37 characters but 74 bytes in UTF-8: a character count would let it through + String tooLong = "é".repeat(37); + + assertThatThrownBy(() -> service.register(registration("alice@example.com", tooLong))) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("72 bytes"); + + verify(userDao, never()).insert(any()); + } + + @Test + void _07_ShouldAcceptPassword_WhenItIsExactly72BytesWithMultibyteCharacters() { + stubInsertAssigningId(); + String atLimit = "é".repeat(36); + + UserDto created = service.register(registration("alice@example.com", atLimit)); + + assertThat(created.getId()).isEqualTo(42L); + } + + @Test + void _08_ShouldThrowBusinessRuleException_WhenPasswordIsEmpty() { + assertThatThrownBy(() -> service.register(registration("alice@example.com", ""))) + .isInstanceOf(BusinessRuleException.class); + } + + // ---------------------------------------------------------------- activateAccount + + @Test + void _09_ShouldEnableUserAndMarkTokenValidated_WhenTokenIsValid() throws Exception { + ActivationToken token = new ActivationToken(8L, 5L, sha256("raw-activation"), NOW.minusSeconds(60), + NOW.plusSeconds(3600), null); + // The stub is keyed on the SHA-256 of the raw value: the service must look up by hash, never by raw + when(activationTokenDao.findByTokenHash(sha256("raw-activation"))).thenReturn(Optional.of(token)); + + service.activateAccount("raw-activation"); + + verify(userDao).updateEnabled(5L, true); + verify(activationTokenDao).markValidated(8L, NOW); + } + + @Test + void _10_ShouldThrowBusinessRuleException_WhenActivationTokenIsUnknown() { + when(activationTokenDao.findByTokenHash(anyString())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.activateAccount("unknown")) + .isInstanceOf(BusinessRuleException.class); + + verify(userDao, never()).updateEnabled(anyLong(), anyBoolean()); + } + + @Test + void _11_ShouldThrowBusinessRuleException_WhenActivationTokenIsExpired() { + ActivationToken expired = new ActivationToken(8L, 5L, "h", NOW.minusSeconds(7200), NOW, null); + when(activationTokenDao.findByTokenHash(anyString())).thenReturn(Optional.of(expired)); + + assertThatThrownBy(() -> service.activateAccount("raw")) + .isInstanceOf(BusinessRuleException.class); + + verify(userDao, never()).updateEnabled(anyLong(), anyBoolean()); + verify(activationTokenDao, never()).markValidated(anyLong(), any()); + } + + @Test + void _12_ShouldThrowBusinessRuleException_WhenActivationTokenIsAlreadyValidated() { + ActivationToken used = new ActivationToken(8L, 5L, "h", NOW.minusSeconds(60), NOW.plusSeconds(3600), + NOW.minusSeconds(30)); + when(activationTokenDao.findByTokenHash(anyString())).thenReturn(Optional.of(used)); + + assertThatThrownBy(() -> service.activateAccount("raw")) + .isInstanceOf(BusinessRuleException.class); + + verify(userDao, never()).updateEnabled(anyLong(), anyBoolean()); + } + + // ---------------------------------------------------------------- login + + @Test + void _13_ShouldReturnUserWithRolesAndPermissionsAndNoPassword_WhenCredentialsAreValid() { + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(storedUser(true, false))); + when(userDao.findRoleNamesByUserId(5L)).thenReturn(List.of("ADMIN")); + when(userDao.findPermissionCodesByUserId(5L)).thenReturn(List.of("USER:READ", "USER:WRITE")); + + UserDto user = service.login(" Alice@Example.com ", PASSWORD); + + assertThat(user.getId()).isEqualTo(5L); + assertThat(user.getPassword()).isNull(); + assertThat(user.getRoles()).containsExactly("ADMIN"); + assertThat(user.getPermissions()).containsExactly("USER:READ", "USER:WRITE"); + } + + @Test + void _14_ShouldThrowInvalidCredentials_WhenEmailIsUnknown() { + when(userDao.findByEmail("nobody@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.login("nobody@example.com", PASSWORD)) + .isInstanceOf(InvalidCredentialsException.class); + + // The fake verification runs (one bcrypt check) but never against the submitted password + verify(encoder, times(1)).matches(eq("x"), anyString()); + verify(encoder, never()).matches(eq(PASSWORD), anyString()); + } + + @Test + void _15_ShouldThrowTheSameException_WhenPasswordIsWrongAsWhenEmailIsUnknown() { + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(storedUser(true, false))); + when(userDao.findByEmail("nobody@example.com")).thenReturn(Optional.empty()); + + InvalidCredentialsException wrongPassword = catchInvalid(() -> service.login("alice@example.com", "Wrong-pass1")); + InvalidCredentialsException unknownEmail = catchInvalid(() -> service.login("nobody@example.com", PASSWORD)); + + assertThat(wrongPassword).isNotNull(); + assertThat(unknownEmail).isNotNull(); + assertThat(wrongPassword.getMessage()).isEqualTo(unknownEmail.getMessage()); + assertThat(wrongPassword.getClass()).isEqualTo(unknownEmail.getClass()); + } + + private static InvalidCredentialsException catchInvalid(Runnable call) { + try { + call.run(); + } catch (InvalidCredentialsException e) { + return e; + } + return null; + } + + @Test + void _16_ShouldThrowInvalidCredentialsAndNeverReachTheHash_WhenPasswordExceeds72Bytes() { + User user = storedUser(true, false); + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(user)); + String tooLong = "é".repeat(37); + + assertThatThrownBy(() -> service.login("alice@example.com", tooLong)) + .isInstanceOf(InvalidCredentialsException.class); + + verify(encoder, never()).matches(eq(tooLong), anyString()); + } + + @Test + void _17_ShouldThrowAccountNotActive_WhenPasswordIsCorrectButAccountIsDisabled() { + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(storedUser(false, false))); + + assertThatThrownBy(() -> service.login("alice@example.com", PASSWORD)) + .isInstanceOf(AccountNotActiveException.class); + } + + @Test + void _18_ShouldThrowAccountNotActive_WhenPasswordIsCorrectButAccountIsLocked() { + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(storedUser(true, true))); + + assertThatThrownBy(() -> service.login("alice@example.com", PASSWORD)) + .isInstanceOf(AccountNotActiveException.class); + } + + @Test + void _19_ShouldThrowInvalidCredentialsNotAccountNotActive_WhenPasswordIsWrongOnADisabledAccount() { + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(storedUser(false, false))); + + assertThatThrownBy(() -> service.login("alice@example.com", "Wrong-pass1")) + .isInstanceOf(InvalidCredentialsException.class); + } + + @Test + void _20_ShouldThrowInvalidCredentials_WhenEmailIsNull() { + assertThatThrownBy(() -> service.login(null, PASSWORD)).isInstanceOf(InvalidCredentialsException.class); + } + + // ---------------------------------------------------------------- logout / blacklist + + @Test + void _21_ShouldStoreTokenHashAndJti_WhenLoggingOut() { + when(blacklistedTokenDao.existsByTokenHash("hash")).thenReturn(false); + Instant issued = NOW.minusSeconds(600); + Instant expires = NOW.plusSeconds(3000); + + service.logout(new BlacklistedTokenDto(5L, "hash", "jti-1", issued, expires)); + + ArgumentCaptor stored = ArgumentCaptor.forClass(BlacklistedToken.class); + verify(blacklistedTokenDao).insert(stored.capture()); + assertThat(stored.getValue().getToken()).isEqualTo("hash"); + assertThat(stored.getValue().getJti()).isEqualTo("jti-1"); + assertThat(stored.getValue().getUserId()).isEqualTo(5L); + assertThat(stored.getValue().getBlacklistedAt()).isEqualTo(NOW); + assertThat(stored.getValue().getCreatedAt()).isEqualTo(issued); + assertThat(stored.getValue().getExpiresAt()).isEqualTo(expires); + } + + @Test + void _22_ShouldInsertNothing_WhenTokenIsAlreadyBlacklisted() { + when(blacklistedTokenDao.existsByTokenHash("hash")).thenReturn(true); + + service.logout(new BlacklistedTokenDto(5L, "hash", "jti-1", NOW, NOW.plusSeconds(60))); + + verify(blacklistedTokenDao, never()).insert(any()); + } + + @Test + void _23_ShouldNotThrow_WhenConcurrentLogoutHitsTheUniqueIndex() { + when(blacklistedTokenDao.existsByTokenHash("hash")).thenReturn(false); + when(blacklistedTokenDao.insert(any())).thenThrow(new DuplicateKeyException("uq_blacklisted_tokens_jti")); + + assertThatCode(() -> service.logout(new BlacklistedTokenDto(5L, "hash", "jti-1", NOW, NOW.plusSeconds(60)))) + .doesNotThrowAnyException(); + } + + @Test + void _24_ShouldReturnDaoAnswer_WhenAskingIfTokenIsBlacklisted() { + when(blacklistedTokenDao.existsByTokenHash("revoked")).thenReturn(true); + when(blacklistedTokenDao.existsByTokenHash("fresh")).thenReturn(false); + + assertThat(service.isTokenBlacklisted("revoked")).isTrue(); + assertThat(service.isTokenBlacklisted("fresh")).isFalse(); + } + + // ---------------------------------------------------------------- getProfile + + @Test + void _25_ShouldReturnProfileWithRolesAndPermissions_WhenUserExists() { + when(userDao.findById(5L)).thenReturn(Optional.of(storedUser(true, false))); + when(userDao.findRoleNamesByUserId(5L)).thenReturn(List.of("ADMIN")); + when(userDao.findPermissionCodesByUserId(5L)).thenReturn(List.of("USER:READ")); + + UserDto profile = service.getProfile(5L); + + assertThat(profile.getEmail()).isEqualTo("alice@example.com"); + assertThat(profile.getPassword()).isNull(); + assertThat(profile.getRoles()).containsExactly("ADMIN"); + assertThat(profile.getPermissions()).containsExactly("USER:READ"); + } + + @Test + void _26_ShouldThrowResourceNotFound_WhenProfileUserDoesNotExist() { + when(userDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getProfile(99L)).isInstanceOf(ResourceNotFoundException.class); + } + + // ---------------------------------------------------------------- forgotPassword + + @Test + void _27_ShouldReplaceTokensAndStoreOnlyTheHash_WhenEmailIsKnown() { + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(storedUser(true, false))); + + service.forgotPassword("Alice@Example.com"); + + verify(passwordResetTokenDao).deleteByUserId(5L); + ArgumentCaptor stored = ArgumentCaptor.forClass(PasswordResetToken.class); + verify(passwordResetTokenDao).insert(stored.capture()); + assertThat(stored.getValue().getToken()).matches(HEX_64); + assertThat(stored.getValue().getUserId()).isEqualTo(5L); + assertThat(stored.getValue().getType()).isEqualTo("PASSWORD_RESET"); + assertThat(stored.getValue().getExpiryDate()).isEqualTo(NOW.plusSeconds(3600)); + } + + @Test + void _28_ShouldNotThrowAndStillRunTheDelete_WhenEmailIsUnknown() { + when(userDao.findByEmail("nobody@example.com")).thenReturn(Optional.empty()); + + assertThatCode(() -> service.forgotPassword("nobody@example.com")).doesNotThrowAnyException(); + + // Same lookup and same delete as for a known email: only the final insert is skipped + verify(passwordResetTokenDao).deleteByUserId(anyLong()); + verify(passwordResetTokenDao, never()).insert(any()); + } + + @Test + void _29_ShouldNotThrow_WhenEmailIsNull() { + assertThatCode(() -> service.forgotPassword(null)).doesNotThrowAnyException(); + + verify(passwordResetTokenDao, never()).insert(any()); + } + + // ---------------------------------------------------------------- resetPassword + + @Test + void _30_ShouldStoreNewHashAndDeleteResetTokens_WhenTokenIsValid() throws Exception { + PasswordResetToken token = new PasswordResetToken(3L, 5L, sha256("raw-reset"), "PASSWORD_RESET", + NOW.plusSeconds(600)); + when(passwordResetTokenDao.findByTokenHash(sha256("raw-reset"))).thenReturn(Optional.of(token)); + + service.resetPassword("raw-reset", "Brand-new-pass1"); + + ArgumentCaptor hash = ArgumentCaptor.forClass(String.class); + verify(userDao).updatePassword(eq(5L), hash.capture()); + assertThat(encoder.matches("Brand-new-pass1", hash.getValue())).isTrue(); + verify(passwordResetTokenDao).deleteByUserId(5L); + } + + @Test + void _31_ShouldThrowBusinessRuleException_WhenResetTokenIsExpired() { + PasswordResetToken expired = new PasswordResetToken(3L, 5L, "h", "PASSWORD_RESET", NOW); + when(passwordResetTokenDao.findByTokenHash(anyString())).thenReturn(Optional.of(expired)); + + assertThatThrownBy(() -> service.resetPassword("raw", "Brand-new-pass1")) + .isInstanceOf(BusinessRuleException.class); + + verify(userDao, never()).updatePassword(anyLong(), anyString()); + } + + @Test + void _32_ShouldThrowBusinessRuleException_WhenResetTokenIsUnknown() { + when(passwordResetTokenDao.findByTokenHash(anyString())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.resetPassword("raw", "Brand-new-pass1")) + .isInstanceOf(BusinessRuleException.class); + + verify(userDao, never()).updatePassword(anyLong(), anyString()); + } + + @Test + void _33_ShouldThrowBusinessRuleException_WhenNewPasswordExceeds72Bytes() { + PasswordResetToken token = new PasswordResetToken(3L, 5L, "h", "PASSWORD_RESET", NOW.plusSeconds(600)); + when(passwordResetTokenDao.findByTokenHash(anyString())).thenReturn(Optional.of(token)); + + assertThatThrownBy(() -> service.resetPassword("raw", "é".repeat(37))) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("72 bytes"); + + verify(userDao, never()).updatePassword(anyLong(), anyString()); + verify(passwordResetTokenDao, never()).deleteByUserId(anyLong()); + } + + // ---------------------------------------------------------------- purgeExpiredTokens + + @Test + void _34_ShouldDeleteRowsExpiredBeforeNowAndReturnTheCount_WhenPurging() { + when(blacklistedTokenDao.deleteExpiredBefore(NOW)).thenReturn(3); + + assertThat(service.purgeExpiredTokens()).isEqualTo(3); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJobTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJobTest.java new file mode 100644 index 0000000..fbc91ca --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJobTest.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import org.junit.jupiter.api.Test; + +/** + * Tests ExpiredTokenCleanupJob: run() is public, so it is called directly instead of waiting for the cron. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class ExpiredTokenCleanupJobTest { + + @Test + void _01_ShouldPurgeExpiredTokens_WhenRunIsCalled() { + AuthService authService = mock(AuthService.class); + when(authService.purgeExpiredTokens()).thenReturn(2); + + new ExpiredTokenCleanupJob(authService).run(); + + verify(authService).purgeExpiredTokens(); + } +} From a5a2336ae98f038c80f11d2f7ea93dab5cdc3135 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 056/144] test(ws): add shared fixtures and assertions for HTTP tests AuthTestSupport registers, activates and logs in a user through the real endpoints and returns the JWT, reading raw tokens from the INFO log with a logback ListAppender since no mailer exists. ApiAssertions checks the ApiResponse envelope of successes and errors, and JwtTestSupport builds the service on the development keys and forges tokens with other keys. They are meant to be reused by the controller tests of the later branches. --- .../ws/support/ApiAssertions.java | 57 +++++++ .../ws/support/AuthTestSupport.java | 140 ++++++++++++++++++ .../ws/support/JwtTestSupport.java | 114 ++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/ApiAssertions.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/AuthTestSupport.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/JwtTestSupport.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/ApiAssertions.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/ApiAssertions.java new file mode 100644 index 0000000..93ebd64 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/ApiAssertions.java @@ -0,0 +1,57 @@ +package com.edgareldy.springjdbctutorial.ws.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.UnsupportedEncodingException; +import org.springframework.test.web.servlet.MvcResult; + +/** + * Shared assertions on the ApiResponse envelope, so every test checks the README error format the same way. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public final class ApiAssertions { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ApiAssertions() { + } + + /** Parses the response body as JSON. */ + public static JsonNode json(MvcResult result) throws UnsupportedEncodingException { + try { + return MAPPER.readTree(result.getResponse().getContentAsString()); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new AssertionError("Response body is not JSON: " + result.getResponse().getContentAsString(), e); + } + } + + /** + * Asserts the README error format: the expected status, success=false, a message, no data and a timestamp. + * Returns the body for further assertions on the message. + */ + public static JsonNode assertError(MvcResult result, int expectedStatus) throws UnsupportedEncodingException { + assertThat(result.getResponse().getStatus()).isEqualTo(expectedStatus); + JsonNode json = json(result); + assertThat(json.get("success").asBoolean()).isFalse(); + assertThat(json.get("message").asText()).isNotBlank(); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + assertThat(json.get("timestamp").asText()).isNotBlank(); + return json; + } + + /** Asserts a success envelope with the expected status and returns the body. */ + public static JsonNode assertSuccess(MvcResult result, int expectedStatus) throws UnsupportedEncodingException { + assertThat(result.getResponse().getStatus()).isEqualTo(expectedStatus); + JsonNode json = json(result); + assertThat(json.get("success").asBoolean()).isTrue(); + assertThat(json.get("message").asText()).isNotBlank(); + assertThat(json.get("timestamp").asText()).isNotBlank(); + return json; + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/AuthTestSupport.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/AuthTestSupport.java new file mode 100644 index 0000000..b524904 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/AuthTestSupport.java @@ -0,0 +1,140 @@ +package com.edgareldy.springjdbctutorial.ws.support; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * Shared fixtures for full-stack tests: registers, activates and logs in a user through the real HTTP endpoints + * and returns the JWT. There is no mailer, so the raw activation and reset tokens are read from the INFO log + * lines of AuthServiceImpl with a logback appender. Reusable by later branches (admin and simple users). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public final class AuthTestSupport implements AutoCloseable { + + /** A password accepted by every validation rule. */ + public static final String PASSWORD = "Str0ng-Password!"; + + private static final String SERVICE_LOGGER = "com.edgareldy.springjdbctutorial.core.auth.service.impl.AuthServiceImpl"; + private static final Pattern ACTIVATION = Pattern.compile("Activation token for (\\S+): (\\S+)"); + private static final Pattern RESET = Pattern.compile("Password reset token for (\\S+): (\\S+)"); + + private final MockMvc mockMvc; + private final Logger logger; + // logback's ListAppender keeps every logged event in memory, which lets a test read them back + private final ListAppender appender = new ListAppender<>(); + + public AuthTestSupport(MockMvc mockMvc) { + this.mockMvc = mockMvc; + this.logger = (Logger) LoggerFactory.getLogger(SERVICE_LOGGER); + logger.setLevel(Level.INFO); + appender.start(); + logger.addAppender(appender); + } + + /** Detaches the appender: call it from an @AfterEach so log capture never leaks between test classes. */ + @Override + public void close() { + logger.detachAppender(appender); + appender.stop(); + } + + /** An email never used before, so tests sharing one database never collide. */ + public static String uniqueEmail(String prefix) { + return prefix + "-" + UUID.randomUUID() + "@example.com"; + } + + public static String registerBody(String email, String password) { + return "{\"firstName\":\"Test\",\"lastName\":\"User\",\"email\":\"" + email + "\",\"password\":\"" + password + "\"}"; + } + + public MvcResult register(String email, String password) throws Exception { + return mockMvc.perform(post("/api/v1/auth/register").contentType(MediaType.APPLICATION_JSON) + .content(registerBody(email, password))).andReturn(); + } + + public MvcResult activate(String rawToken) throws Exception { + return mockMvc.perform(get("/api/v1/auth/activate-account").param("token", rawToken)).andReturn(); + } + + public MvcResult login(String email, String password) throws Exception { + return mockMvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}")).andReturn(); + } + + public MvcResult forgotPassword(String email) throws Exception { + return mockMvc.perform(post("/api/v1/auth/forgot-password").contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + email + "\"}")).andReturn(); + } + + public MvcResult resetPassword(String rawToken, String newPassword) throws Exception { + return mockMvc.perform(post("/api/v1/auth/reset-password").contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"" + rawToken + "\",\"newPassword\":\"" + newPassword + "\"}")).andReturn(); + } + + public MvcResult logout(String jwt) throws Exception { + return mockMvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + jwt)).andReturn(); + } + + /** GET on a route with the Bearer header, for the common "call as this user" case. */ + public MvcResult getAs(String jwt, String url) throws Exception { + return mockMvc.perform(get(url).header("Authorization", "Bearer " + jwt)).andReturn(); + } + + /** The most recent raw activation token logged for the email. */ + public String activationToken(String email) { + return lastToken(ACTIVATION, email); + } + + /** The most recent raw password reset token logged for the email. */ + public String resetToken(String email) { + return lastToken(RESET, email); + } + + /** Registers, activates and logs in a brand new user, then returns its JWT. */ + public String registerActivateAndLogin(String email, String password) throws Exception { + register(email, password); + activate(activationToken(email)); + return loginToken(email, password); + } + + /** Logs in (expects success) and returns the JWT of the response. */ + public String loginToken(String email, String password) throws Exception { + MvcResult result = login(email, password); + JsonNode json = ApiAssertions.assertSuccess(result, 200); + return json.get("data").get("token").asText(); + } + + private String lastToken(Pattern pattern, String email) { + List events = new ArrayList<>(appender.list); + String found = null; + for (ILoggingEvent event : events) { + Matcher matcher = pattern.matcher(event.getFormattedMessage()); + if (matcher.find() && matcher.group(1).equalsIgnoreCase(email)) { + found = matcher.group(2); + } + } + if (found == null) { + throw new AssertionError("No token logged for " + email); + } + return found; + } +} 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 new file mode 100644 index 0000000..3c12ef6 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/JwtTestSupport.java @@ -0,0 +1,114 @@ +package com.edgareldy.springjdbctutorial.ws.support; + +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import javax.crypto.SecretKey; +import org.springframework.core.io.DefaultResourceLoader; + +/** + * Test helpers around JWTs: a JwtService built on the development key pair (the locations surefire passes as + * system properties), throw-away RSA keys, PEM encoding and forged tokens (foreign key, HS256, alg none...). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public final class JwtTestSupport { + + /** Issuer configured by default in SecurityConfig. */ + public static final String ISSUER = "spring-jdbc-tutorial"; + + private JwtTestSupport() { + } + + /** A JwtService on the dev keys, with the given clock and a one hour validity. */ + public static JwtService devJwtService(Clock clock) { + return new JwtService(new DefaultResourceLoader(), + "app.jwt.private-key-location", System.getProperty("app.jwt.private-key-location"), + "app.jwt.public-key-location", System.getProperty("app.jwt.public-key-location"), + 3600L, ISSUER, clock); + } + + /** A fresh 2048 bit RSA key pair, never related to the dev keys. */ + public static KeyPair generateKeyPair() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + /** Wraps DER bytes into a PEM block of the given type (for example "PRIVATE KEY"). */ + public static String pem(String type, byte[] der) { + String body = Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(der); + return "-----BEGIN " + type + "-----\n" + body + "\n-----END " + type + "-----\n"; + } + + public static String privatePem(PrivateKey key) { + return pem("PRIVATE KEY", key.getEncoded()); + } + + public static String publicPem(PublicKey key) { + return pem("PUBLIC KEY", key.getEncoded()); + } + + /** A well-formed RS256 token (issuer, jti, iat, exp, email, permissions) signed with the given private key. */ + public static String rs256Token(PrivateKey key, long userId, Instant issuedAt, Instant expiresAt) { + return baseBuilder(userId, issuedAt, expiresAt).signWith(key, Jwts.SIG.RS256).compact(); + } + + /** Same claims, signed with RS384: a valid signature that the service must still refuse. */ + public static String rs384Token(PrivateKey key, long userId, Instant issuedAt, Instant expiresAt) { + return baseBuilder(userId, issuedAt, expiresAt).signWith(key, Jwts.SIG.RS384).compact(); + } + + /** Algorithm confusion attack: an HS256 token whose HMAC secret is the (public) RSA public key bytes. */ + public static String hs256TokenSignedWithPublicKey(PublicKey publicKey, long userId, Instant issuedAt, + Instant expiresAt) { + SecretKey secret = Keys.hmacShaKeyFor(publicKey.getEncoded()); + return baseBuilder(userId, issuedAt, expiresAt).signWith(secret, Jwts.SIG.HS256).compact(); + } + + /** An unsecured JWT (header alg none, no signature). */ + public static String unsignedToken(long userId, Instant issuedAt, Instant expiresAt) { + return baseBuilder(userId, issuedAt, expiresAt).compact(); + } + + /** A signed token with a different issuer. */ + public static String rs256TokenWithIssuer(PrivateKey key, String issuer, long userId, Instant issuedAt, + Instant expiresAt) { + return Jwts.builder().subject(String.valueOf(userId)).id(UUID.randomUUID().toString()).issuer(issuer) + .issuedAt(Date.from(issuedAt)).expiration(Date.from(expiresAt)) + .claim("email", "forged@example.com").claim("permissions", List.of()) + .signWith(key, Jwts.SIG.RS256).compact(); + } + + /** A signed token that has no jti claim. */ + public static String rs256TokenWithoutJti(PrivateKey key, long userId, Instant issuedAt, Instant expiresAt) { + return Jwts.builder().subject(String.valueOf(userId)).issuer(ISSUER) + .issuedAt(Date.from(issuedAt)).expiration(Date.from(expiresAt)) + .claim("email", "forged@example.com").claim("permissions", List.of()) + .signWith(key, Jwts.SIG.RS256).compact(); + } + + private static io.jsonwebtoken.JwtBuilder baseBuilder(long userId, Instant issuedAt, Instant expiresAt) { + return Jwts.builder().subject(String.valueOf(userId)).id(UUID.randomUUID().toString()).issuer(ISSUER) + .issuedAt(Date.from(issuedAt)).expiration(Date.from(expiresAt)) + .claim("email", "forged@example.com").claim("permissions", List.of("USER:READ")); + } +} From 2d20d42a89672ca38a73c061be7a53441397d9ce Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 057/144] test(ws): test JwtService and JwtKeyLoader The tests cover the issue and parse round trip, unique jti values, a tampered signature or payload, expiry through a fixed Clock including the boundary, a wrong issuer, a foreign key, and the classic algorithm confusion attacks: HS256 signed with the public key, alg none and RS384. JwtKeyLoader is checked for a missing property, a PEM of the wrong type in both directions, an unreadable file and invalid Base64, each with a clear failure message. --- .../ws/security/JwtKeyLoaderTest.java | 104 +++++++++++ .../ws/security/JwtServiceTest.java | 171 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoaderTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtServiceTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoaderTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoaderTest.java new file mode 100644 index 0000000..a9f6665 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoaderTest.java @@ -0,0 +1,104 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.ws.support.JwtTestSupport; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.ResourceLoader; + +/** + * Tests JwtKeyLoader: valid PEM keys load, and every misconfiguration fails with a clear message. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class JwtKeyLoaderTest { + + private static final String PRIVATE_PROPERTY = "app.jwt.private-key-location"; + private static final String PUBLIC_PROPERTY = "app.jwt.public-key-location"; + + private final ResourceLoader loader = new DefaultResourceLoader(); + + @TempDir + Path tempDir; + + private String write(String name, String content) throws Exception { + return "file:" + Files.writeString(tempDir.resolve(name), content); + } + + @Test + void _01_ShouldLoadBothKeys_WhenPemFilesAreValid() throws Exception { + KeyPair pair = JwtTestSupport.generateKeyPair(); + String privateLocation = write("private.pem", JwtTestSupport.privatePem(pair.getPrivate())); + String publicLocation = write("public.pem", JwtTestSupport.publicPem(pair.getPublic())); + + assertThat(JwtKeyLoader.loadPrivateKey(loader, PRIVATE_PROPERTY, privateLocation)).isEqualTo(pair.getPrivate()); + assertThat(JwtKeyLoader.loadPublicKey(loader, PUBLIC_PROPERTY, publicLocation)).isEqualTo(pair.getPublic()); + } + + @Test + void _02_ShouldFailNamingTheProperty_WhenLocationIsMissing() { + assertThatThrownBy(() -> JwtKeyLoader.loadPrivateKey(loader, PRIVATE_PROPERTY, null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Missing required property " + PRIVATE_PROPERTY); + assertThatThrownBy(() -> JwtKeyLoader.loadPublicKey(loader, PUBLIC_PROPERTY, " ")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Missing required property " + PUBLIC_PROPERTY); + } + + @Test + void _03_ShouldFailWithWrongTypeMessage_WhenPublicKeyPemIsGivenAsPrivateKey() throws Exception { + KeyPair pair = JwtTestSupport.generateKeyPair(); + String location = write("public.pem", JwtTestSupport.publicPem(pair.getPublic())); + + assertThatThrownBy(() -> JwtKeyLoader.loadPrivateKey(loader, PRIVATE_PROPERTY, location)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("must be a PEM 'PRIVATE KEY' block"); + } + + @Test + void _04_ShouldFailWithWrongTypeMessage_WhenPrivateKeyPemIsGivenAsPublicKey() throws Exception { + KeyPair pair = JwtTestSupport.generateKeyPair(); + String location = write("private.pem", JwtTestSupport.privatePem(pair.getPrivate())); + + assertThatThrownBy(() -> JwtKeyLoader.loadPublicKey(loader, PUBLIC_PROPERTY, location)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("must be a PEM 'PUBLIC KEY' block"); + } + + @Test + void _05_ShouldFailWithReadMessage_WhenFileDoesNotExist() { + String location = "file:" + tempDir.resolve("absent.pem"); + + assertThatThrownBy(() -> JwtKeyLoader.loadPrivateKey(loader, PRIVATE_PROPERTY, location)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot read the key of " + PRIVATE_PROPERTY); + } + + @Test + void _06_ShouldFailWithInvalidKeyMessage_WhenPemBodyIsNotAKey() throws Exception { + String garbage = JwtTestSupport.pem("PRIVATE KEY", new byte[]{1, 2, 3, 4}); + String location = write("garbage.pem", garbage); + + assertThatThrownBy(() -> JwtKeyLoader.loadPrivateKey(loader, PRIVATE_PROPERTY, location)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Invalid RSA private key"); + } + + @Test + void _07_ShouldFailWithBase64Message_WhenPemBodyIsNotBase64() throws Exception { + String location = write("bad.pem", "-----BEGIN PUBLIC KEY-----\n***not base64***\n-----END PUBLIC KEY-----\n"); + + assertThatThrownBy(() -> JwtKeyLoader.loadPublicKey(loader, PUBLIC_PROPERTY, location)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not valid Base64"); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtServiceTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtServiceTest.java new file mode 100644 index 0000000..bef9e88 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtServiceTest.java @@ -0,0 +1,171 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.ws.support.JwtTestSupport; +import java.security.KeyPair; +import java.security.PublicKey; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.DefaultResourceLoader; + +/** + * Tests JwtService (RS256 issue and parse) with a fixed clock: round trip, expiry, tampering, wrong issuer, + * foreign key, algorithm confusion and unsigned tokens. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class JwtServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-19T12:00:00Z"); + + private final JwtService service = JwtTestSupport.devJwtService(Clock.fixed(NOW, ZoneOffset.UTC)); + + private static UserDto user() { + UserDto dto = new UserDto(); + dto.setId(5L); + dto.setEmail("alice@example.com"); + dto.setPermissions(List.of("USER:READ", "ROLE:WRITE")); + return dto; + } + + @Test + void _01_ShouldParseBackTheSameClaims_WhenTokenIsIssuedThenParsed() { + IssuedToken issued = service.issue(user()); + + ParsedToken parsed = service.parse(issued.getToken()).orElseThrow(); + + assertThat(parsed.getUserId()).isEqualTo(5L); + assertThat(parsed.getEmail()).isEqualTo("alice@example.com"); + assertThat(parsed.getPermissions()).containsExactly("USER:READ", "ROLE:WRITE"); + assertThat(parsed.getJti()).isNotBlank(); + assertThat(parsed.getIssuedAt()).isEqualTo(NOW); + assertThat(parsed.getExpiresAt()).isEqualTo(NOW.plusSeconds(3600)); + assertThat(issued.getExpiresAt()).isEqualTo(NOW.plusSeconds(3600)); + } + + @Test + void _02_ShouldIssueDifferentJti_WhenTwoTokensAreIssuedForTheSameUser() { + ParsedToken first = service.parse(service.issue(user()).getToken()).orElseThrow(); + ParsedToken second = service.parse(service.issue(user()).getToken()).orElseThrow(); + + assertThat(first.getJti()).isNotEqualTo(second.getJti()); + } + + @Test + void _03_ShouldReturnEmpty_WhenSignatureIsTampered() { + String token = service.issue(user()).getToken(); + int position = token.length() - 20; + char original = token.charAt(position); + String tampered = token.substring(0, position) + (original == 'A' ? 'B' : 'A') + token.substring(position + 1); + + assertThat(service.parse(tampered)).isEmpty(); + } + + @Test + void _04_ShouldReturnEmpty_WhenPayloadIsReplacedButSignatureKept() { + String[] parts = service.issue(user()).getToken().split("\\."); + String otherPayload = service.issue(user()).getToken().split("\\.")[1]; + String swapped = parts[0] + "." + otherPayload + "." + parts[2]; + + assertThat(service.parse(swapped)).isEmpty(); + } + + @Test + void _05_ShouldReturnEmpty_WhenTokenIsExpiredAccordingToTheClock() { + String token = service.issue(user()).getToken(); + JwtService later = JwtTestSupport.devJwtService(Clock.fixed(NOW.plusSeconds(3601), ZoneOffset.UTC)); + + assertThat(later.parse(token)).isEmpty(); + assertThat(JwtTestSupport.devJwtService(Clock.fixed(NOW.plusSeconds(3599), ZoneOffset.UTC)).parse(token)) + .isPresent(); + } + + @Test + void _06_ShouldReturnEmpty_WhenIssuerIsWrong() { + // Same dev key pair, so the signature is valid: only the issuer claim differs + JwtService otherIssuer = new JwtService(new DefaultResourceLoader(), + "app.jwt.private-key-location", System.getProperty("app.jwt.private-key-location"), + "app.jwt.public-key-location", System.getProperty("app.jwt.public-key-location"), + 3600L, "someone-else", Clock.fixed(NOW, ZoneOffset.UTC)); + String token = otherIssuer.issue(user()).getToken(); + + assertThat(service.parse(token)).isEmpty(); + assertThat(otherIssuer.parse(token)).isPresent(); + } + + @Test + void _07_ShouldReturnEmpty_WhenTokenIsSignedWithAnotherRsaKey() { + KeyPair foreign = JwtTestSupport.generateKeyPair(); + String forged = JwtTestSupport.rs256Token(foreign.getPrivate(), 5L, NOW, NOW.plusSeconds(3600)); + + assertThat(service.parse(forged)).isEmpty(); + } + + @Test + void _08_ShouldReturnEmpty_WhenTokenIsHs256SignedWithThePublicKeyBytes() { + PublicKey publicKey = JwtTestSupport.generateKeyPair().getPublic(); + String forged = JwtTestSupport.hs256TokenSignedWithPublicKey(publicKey, 5L, NOW, NOW.plusSeconds(3600)); + + assertThat(service.parse(forged)).isEmpty(); + } + + @Test + void _09_ShouldReturnEmpty_WhenTokenIsUnsignedWithAlgNone() { + String unsigned = JwtTestSupport.unsignedToken(5L, NOW, NOW.plusSeconds(3600)); + + assertThat(service.parse(unsigned)).isEmpty(); + } + + @Test + void _10_ShouldReturnEmpty_WhenAlgorithmIsRs384EvenWithAValidSignature() throws Exception { + // Only RS256 is ever issued: a token with another RSA algorithm is refused, whoever signed it. + // Signed here with the dev private key, which is the only key whose signature would be accepted. + java.security.PrivateKey devPrivate = JwtKeyLoader.loadPrivateKey(new DefaultResourceLoader(), + "app.jwt.private-key-location", System.getProperty("app.jwt.private-key-location")); + String rs384 = JwtTestSupport.rs384Token(devPrivate, 5L, NOW, NOW.plusSeconds(3600)); + + assertThat(service.parse(rs384)).isEmpty(); + } + + @Test + void _11_ShouldReturnEmpty_WhenTokenHasNoJti() { + java.security.PrivateKey devPrivate = JwtKeyLoader.loadPrivateKey(new DefaultResourceLoader(), + "app.jwt.private-key-location", System.getProperty("app.jwt.private-key-location")); + String noJti = JwtTestSupport.rs256TokenWithoutJti(devPrivate, 5L, NOW, NOW.plusSeconds(3600)); + + assertThat(service.parse(noJti)).isEmpty(); + } + + @Test + void _12_ShouldReturnEmpty_WhenTokenIsNullBlankOrGarbage() { + assertThat(service.parse(null)).isEmpty(); + assertThat(service.parse(" ")).isEmpty(); + assertThat(service.parse("not-a-jwt")).isEmpty(); + assertThat(service.parse("a.b.c")).isEmpty(); + } + + @Test + void _13_ShouldIssueEmptyPermissions_WhenUserHasNone() { + UserDto noPermissions = user(); + noPermissions.setPermissions(null); + + Optional parsed = service.parse(service.issue(noPermissions).getToken()); + + assertThat(parsed.orElseThrow().getPermissions()).isEmpty(); + } + + @Test + void _14_ShouldComputeKnownSha256_WhenHashingAValue() { + assertThat(JwtService.sha256Hex("abc")) + .isEqualTo("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + } +} From 7caed75ed4b72cf7484e8cff13279a6bfdcb82c2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 058/144] test(ws): test the auth endpoints and the security chain with MockMvc The real SecurityConfig and a mocked AuthService are driven through MockMvc with springSecurity() applied. Every route is checked for status and ApiResponse shape, validation errors are checked per field, and the chain must answer 401 in the ApiResponse format for a missing, garbage, expired, wrongly signed, HS256, alg none or blacklisted token. forgot-password is asserted to return the same status and body for a known and an unknown email, and logout to build its BlacklistedTokenDto from the authenticated principal. --- .../ws/mvc/AuthControllerTest.java | 455 ++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AuthControllerTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AuthControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AuthControllerTest.java new file mode 100644 index 0000000..d1a6848 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AuthControllerTest.java @@ -0,0 +1,455 @@ +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.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import com.edgareldy.springjdbctutorial.core.auth.dto.BlacklistedTokenDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.exception.AccountNotActiveException; +import com.edgareldy.springjdbctutorial.core.auth.exception.InvalidCredentialsException; +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.ws.config.SecurityConfig; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.controller.AuthController; +import com.edgareldy.springjdbctutorial.ws.exception.GlobalExceptionHandler; +import com.edgareldy.springjdbctutorial.ws.security.IssuedToken; +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import com.edgareldy.springjdbctutorial.ws.support.JwtTestSupport; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.security.KeyPair; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +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.validation.Validator; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * MockMvc tests of AuthController with AuthService mocked (no database) but the REAL SecurityConfig: JWT + * filter, entry point, advice and validation all run, so status codes and the ApiResponse shape are the + * production ones. + *

+ * 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. The JWT key locations come from the system properties surefire +// sets (see ws/pom.xml), read by SecurityConfig through the Spring Environment. +@SpringJUnitWebConfig(AuthControllerTest.TestConfig.class) +class AuthControllerTest { + + /** + * Test context: MVC, the real SecurityConfig (JwtService, filter chain, 401/403 handlers), the real + * controller and advice, and a Mockito mock in place of the AuthService. + */ + @Configuration + @EnableWebMvc + @Import(SecurityConfig.class) + static class TestConfig implements WebMvcConfigurer { + + private final WebMvcConfig real = new WebMvcConfig(); + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + + @Bean + ObjectMapper objectMapper() { + return real.objectMapper(); + } + + @Bean + AuthService authService() { + return mock(AuthService.class); + } + + @Bean + AuthController authController(AuthService authService, JwtService jwtService) { + return new AuthController(authService, jwtService); + } + + @Bean + GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Override + public void extendMessageConverters(List> converters) { + real.extendMessageConverters(converters); + } + + @Override + public Validator getValidator() { + return real.getValidator(); + } + } + + @Autowired + private WebApplicationContext context; + @Autowired + private AuthService authService; + @Autowired + private JwtService jwtService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + reset(authService); + // springSecurity() adds the springSecurityFilterChain bean of the context in front of the + // DispatcherServlet, exactly like the DelegatingFilterProxy does in Tomcat. Without it MockMvc + // would bypass the JWT filter and every request would look unauthenticated-but-allowed. + mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + } + + private static UserDto profile() { + return new UserDto(5L, "Alice", "Martin", "alice@example.com", null, true, false, + List.of("ADMIN"), List.of("USER:READ")); + } + + private IssuedToken issueToken() { + return jwtService.issue(profile()); + } + + private MvcResult call(MockHttpServletRequestBuilder request) throws Exception { + return mockMvc.perform(request).andReturn(); + } + + private MockHttpServletRequestBuilder me(String jwt) { + return get("/api/v1/auth/me").header("Authorization", "Bearer " + jwt); + } + + private static MockHttpServletRequestBuilder postJson(String url, String body) { + return post(url).contentType(MediaType.APPLICATION_JSON).content(body); + } + + // ---------------------------------------------------------------- register + + @Test + void _01_ShouldReturn201WithProfileAndNoPassword_WhenRegistrationIsValid() throws Exception { + when(authService.register(any(UserDto.class))) + .thenReturn(new UserDto(42L, "Alice", "Martin", "alice@example.com", null, false, false, + List.of(), List.of())); + + MvcResult result = call(postJson("/api/v1/auth/register", + "{\"firstName\":\"Alice\",\"lastName\":\"Martin\",\"email\":\"alice@example.com\",\"password\":\"Str0ng-Password!\"}")); + + JsonNode json = assertSuccess(result, 201); + assertThat(json.get("data").get("id").asLong()).isEqualTo(42L); + assertThat(json.get("data").get("email").asText()).isEqualTo("alice@example.com"); + assertThat(json.get("data").get("enabled").asBoolean()).isFalse(); + assertThat(json.get("data").has("password")).isFalse(); + ArgumentCaptor sent = ArgumentCaptor.forClass(UserDto.class); + verify(authService).register(sent.capture()); + assertThat(sent.getValue().getFirstName()).isEqualTo("Alice"); + assertThat(sent.getValue().getPassword()).isEqualTo("Str0ng-Password!"); + } + + @Test + void _02_ShouldReturn400WithPerFieldMessages_WhenRegistrationBodyIsInvalid() throws Exception { + MvcResult result = call(postJson("/api/v1/auth/register", + "{\"firstName\":\"\",\"lastName\":\"Martin\",\"email\":\"not-an-email\",\"password\":\"short\"}")); + + JsonNode json = assertError(result, 400); + String message = json.get("message").asText(); + assertThat(message).contains("firstName: ").contains("email: ").contains("password: "); + assertThat(message).doesNotContain("lastName"); + verifyNoInteractions(authService); + } + + @Test + void _03_ShouldReturn422_WhenServiceRefusesADuplicateEmail() throws Exception { + when(authService.register(any(UserDto.class))).thenThrow(new BusinessRuleException("Email is already registered")); + + MvcResult result = call(postJson("/api/v1/auth/register", + "{\"firstName\":\"Alice\",\"lastName\":\"Martin\",\"email\":\"alice@example.com\",\"password\":\"Str0ng-Password!\"}")); + + JsonNode json = assertError(result, 422); + assertThat(json.get("message").asText()).isEqualTo("Email is already registered"); + } + + @Test + void _04_ShouldReturn400_WhenRegistrationJsonIsMalformed() throws Exception { + MvcResult result = call(postJson("/api/v1/auth/register", "{\"firstName\":")); + + assertError(result, 400); + } + + @Test + void _05_ShouldReturn415_WhenRegistrationContentTypeIsNotJson() throws Exception { + MvcResult result = call(post("/api/v1/auth/register").contentType(MediaType.TEXT_PLAIN).content("hello")); + + assertError(result, 415); + } + + @Test + void _06_ShouldReturn405_WhenAuthenticatedUserCallsRegisterWithGet() throws Exception { + MvcResult result = call(get("/api/v1/auth/register") + .header("Authorization", "Bearer " + issueToken().getToken())); + + assertError(result, 405); + } + + // ---------------------------------------------------------------- activate-account + + @Test + void _07_ShouldReturn200_WhenActivationTokenIsAccepted() throws Exception { + MvcResult result = call(get("/api/v1/auth/activate-account").param("token", "raw-token")); + + assertSuccess(result, 200); + verify(authService).activateAccount("raw-token"); + } + + @Test + void _08_ShouldReturn400_WhenActivationTokenParameterIsMissing() throws Exception { + MvcResult result = call(get("/api/v1/auth/activate-account")); + + JsonNode json = assertError(result, 400); + assertThat(json.get("message").asText()).isEqualTo("Missing parameter: token"); + } + + @Test + void _09_ShouldReturn422_WhenActivationTokenIsRefusedByTheService() throws Exception { + doThrow(new BusinessRuleException("Invalid or expired activation token")) + .when(authService).activateAccount("bad"); + + assertError(call(get("/api/v1/auth/activate-account").param("token", "bad")), 422); + } + + // ---------------------------------------------------------------- login + + @Test + void _10_ShouldReturn200WithAValidJwt_WhenCredentialsAreCorrect() throws Exception { + when(authService.login("alice@example.com", "Str0ng-Password!")).thenReturn(profile()); + + MvcResult result = call(postJson("/api/v1/auth/login", + "{\"email\":\"alice@example.com\",\"password\":\"Str0ng-Password!\"}")); + + JsonNode json = assertSuccess(result, 200); + assertThat(json.get("data").get("tokenType").asText()).isEqualTo("Bearer"); + assertThat(Instant.parse(json.get("data").get("expiresAt").asText())).isAfter(Instant.now()); + String token = json.get("data").get("token").asText(); + assertThat(jwtService.parse(token).orElseThrow().getUserId()).isEqualTo(5L); + assertThat(jwtService.parse(token).orElseThrow().getPermissions()).containsExactly("USER:READ"); + } + + @Test + void _11_ShouldReturn401WithErrorShape_WhenCredentialsAreInvalid() throws Exception { + when(authService.login(anyString(), anyString())).thenThrow(new InvalidCredentialsException()); + + JsonNode json = assertError(call(postJson("/api/v1/auth/login", + "{\"email\":\"alice@example.com\",\"password\":\"wrong\"}")), 401); + + assertThat(json.get("message").asText()).isEqualTo("Invalid email or password"); + } + + @Test + void _12_ShouldReturn401_WhenAccountIsNotActive() throws Exception { + when(authService.login(anyString(), anyString())).thenThrow(new AccountNotActiveException()); + + JsonNode json = assertError(call(postJson("/api/v1/auth/login", + "{\"email\":\"alice@example.com\",\"password\":\"Str0ng-Password!\"}")), 401); + + assertThat(json.get("message").asText()).isEqualTo("Account is not activated or is locked"); + } + + @Test + void _13_ShouldReturn400WithPerFieldMessages_WhenLoginBodyIsBlank() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/auth/login", "{\"email\":\"\",\"password\":\"\"}")), 400); + + assertThat(json.get("message").asText()).contains("email: ").contains("password: "); + verifyNoInteractions(authService); + } + + // ---------------------------------------------------------------- /me and the JWT filter + + @Test + void _14_ShouldReturn200WithProfile_WhenBearerTokenIsValid() throws Exception { + when(authService.getProfile(5L)).thenReturn(profile()); + + JsonNode json = assertSuccess(call(me(issueToken().getToken())), 200); + + assertThat(json.get("data").get("email").asText()).isEqualTo("alice@example.com"); + assertThat(json.get("data").get("roles").get(0).asText()).isEqualTo("ADMIN"); + assertThat(json.get("data").get("permissions").get(0).asText()).isEqualTo("USER:READ"); + assertThat(json.get("data").has("password")).isFalse(); + } + + @Test + void _15_ShouldReturn401ErrorShape_WhenNoTokenIsSent() throws Exception { + assertError(call(get("/api/v1/auth/me")), 401); + + verifyNoInteractions(authService); + } + + @Test + void _16_ShouldReturn401_WhenTokenIsGarbage() throws Exception { + assertError(call(me("this-is-not-a-jwt")), 401); + } + + @Test + void _17_ShouldReturn401_WhenTokenIsExpired() throws Exception { + JwtService twoHoursAgo = JwtTestSupport.devJwtService( + Clock.fixed(Instant.now().minusSeconds(7200), ZoneOffset.UTC)); + + assertError(call(me(twoHoursAgo.issue(profile()).getToken())), 401); + } + + @Test + void _18_ShouldReturn401_WhenTokenIsSignedWithAnotherRsaKey() throws Exception { + KeyPair foreign = JwtTestSupport.generateKeyPair(); + String forged = JwtTestSupport.rs256Token(foreign.getPrivate(), 5L, Instant.now(), + Instant.now().plusSeconds(3600)); + + assertError(call(me(forged)), 401); + } + + @Test + void _19_ShouldReturn401_WhenTokenIsHs256SignedWithThePublicKey() throws Exception { + KeyPair pair = JwtTestSupport.generateKeyPair(); + String forged = JwtTestSupport.hs256TokenSignedWithPublicKey(pair.getPublic(), 5L, Instant.now(), + Instant.now().plusSeconds(3600)); + + assertError(call(me(forged)), 401); + } + + @Test + void _20_ShouldReturn401_WhenTokenIsUnsignedWithAlgNone() throws Exception { + String unsigned = JwtTestSupport.unsignedToken(5L, Instant.now(), Instant.now().plusSeconds(3600)); + + assertError(call(me(unsigned)), 401); + } + + @Test + void _21_ShouldReturn401AndNeverLoadTheProfile_WhenTokenIsBlacklisted() throws Exception { + String token = issueToken().getToken(); + when(authService.isTokenBlacklisted(JwtService.sha256Hex(token))).thenReturn(true); + + assertError(call(me(token)), 401); + + verify(authService, never()).getProfile(anyLong()); + } + + // ---------------------------------------------------------------- logout + + @Test + void _22_ShouldBlacklistTheTokenBuiltFromThePrincipal_WhenLoggingOut() throws Exception { + IssuedToken issued = issueToken(); + + assertSuccess(call(post("/api/v1/auth/logout").header("Authorization", "Bearer " + issued.getToken())), 200); + + ArgumentCaptor sent = ArgumentCaptor.forClass(BlacklistedTokenDto.class); + verify(authService).logout(sent.capture()); + BlacklistedTokenDto dto = sent.getValue(); + assertThat(dto.getUserId()).isEqualTo(5L); + assertThat(dto.getTokenHash()).isEqualTo(JwtService.sha256Hex(issued.getToken())); + assertThat(dto.getTokenHash()).isNotEqualTo(issued.getToken()); + assertThat(dto.getJti()).isEqualTo(jwtService.parse(issued.getToken()).orElseThrow().getJti()); + assertThat(dto.getIssuedAt()).isEqualTo(issued.getIssuedAt()); + assertThat(dto.getExpiresAt()).isEqualTo(issued.getExpiresAt()); + } + + @Test + void _23_ShouldReturn401AndNotTouchTheService_WhenLoggingOutWithoutToken() throws Exception { + assertError(call(post("/api/v1/auth/logout")), 401); + + verify(authService, never()).logout(any()); + } + + // ---------------------------------------------------------------- forgot / reset password + + @Test + void _24_ShouldReturnTheSameStatusAndMessage_WhenEmailIsKnownOrUnknown() throws Exception { + JsonNode known = assertSuccess(call(postJson("/api/v1/auth/forgot-password", + "{\"email\":\"alice@example.com\"}")), 200); + JsonNode unknown = assertSuccess(call(postJson("/api/v1/auth/forgot-password", + "{\"email\":\"nobody@example.com\"}")), 200); + + assertThat(known.get("message").asText()).isEqualTo(unknown.get("message").asText()); + assertThat(known.has("data") && !known.get("data").isNull()).isFalse(); + assertThat(unknown.has("data") && !unknown.get("data").isNull()).isFalse(); + verify(authService).forgotPassword("alice@example.com"); + verify(authService).forgotPassword("nobody@example.com"); + } + + @Test + void _25_ShouldReturn400_WhenForgotPasswordEmailIsBlank() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/auth/forgot-password", "{\"email\":\"\"}")), 400); + + assertThat(json.get("message").asText()).contains("email: "); + } + + @Test + void _26_ShouldReturn200_WhenResetPasswordIsAccepted() throws Exception { + assertSuccess(call(postJson("/api/v1/auth/reset-password", + "{\"token\":\"raw-reset\",\"newPassword\":\"Brand-new-pass1\"}")), 200); + + verify(authService).resetPassword("raw-reset", "Brand-new-pass1"); + } + + @Test + void _27_ShouldReturn422_WhenResetTokenIsRefusedByTheService() throws Exception { + doThrow(new BusinessRuleException("Invalid or expired reset token")) + .when(authService).resetPassword(anyString(), anyString()); + + assertError(call(postJson("/api/v1/auth/reset-password", + "{\"token\":\"bad\",\"newPassword\":\"Brand-new-pass1\"}")), 422); + } + + @Test + void _28_ShouldReturn400WithPerFieldMessages_WhenNewPasswordIsTooShort() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/auth/reset-password", + "{\"token\":\"raw\",\"newPassword\":\"short\"}")), 400); + + assertThat(json.get("message").asText()).contains("newPassword: "); + verifyNoInteractions(authService); + } + + // ---------------------------------------------------------------- unknown URL + + @Test + void _29_ShouldReturn401NotSpecific404_WhenUnknownUrlIsCalledWithoutToken() throws Exception { + assertError(call(get("/api/v1/does-not-exist")), 401); + } + + @Test + void _30_ShouldReturn404ErrorShape_WhenUnknownUrlIsCalledWithAValidToken() throws Exception { + assertError(call(get("/api/v1/does-not-exist") + .header("Authorization", "Bearer " + issueToken().getToken())), 404); + } +} From 28d372167f73e47dea5d9d27e33d386ea48c68f3 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 059/144] test(ws): prove the full auth flow against the real stack The integration test boots the real WebMvcConfig on a Testcontainers database and walks register, activate, login, /me, logout. The same token then gets 401 while a second token of the same user still gets 200, which proves the blacklist really rejects a revoked token. It also checks that only SHA-256 digests are stored, the reset flow, single-use tokens, identical answers for wrong password, unknown email and over-long password, and the cleanup of expired blacklist rows. The existing initializer and context tests are adapted to the new security filter and to the fact that an unknown URL is 401 without a token. --- .../ws/config/WebAppInitializerTest.java | 35 +++ .../config/WebMvcConfigIntegrationTest.java | 31 +- .../integration/AuthFlowIntegrationTest.java | 266 ++++++++++++++++++ 3 files changed, 320 insertions(+), 12 deletions(-) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AuthFlowIntegrationTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java index fa1304d..26388ca 100644 --- a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java @@ -3,19 +3,24 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import jakarta.servlet.DispatcherType; import jakarta.servlet.Filter; import jakarta.servlet.FilterRegistration; import jakarta.servlet.Servlet; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletRegistration; +import java.util.EnumSet; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.springframework.web.context.ContextLoaderListener; import org.springframework.web.filter.CharacterEncodingFilter; +import org.springframework.web.filter.DelegatingFilterProxy; import org.springframework.web.servlet.DispatcherServlet; /** @@ -37,6 +42,8 @@ void _01_ShouldRegisterDispatcherServletOnRoot_WhenApplicationStarts() { when(servletContext.addServlet(eq("dispatcher"), any(Servlet.class))).thenReturn(servletRegistration); when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))) .thenReturn(filterRegistration); + when(servletContext.addFilter(eq("springSecurityFilterChain"), any(Filter.class))) + .thenReturn(mock(FilterRegistration.Dynamic.class)); new WebAppInitializer().onStartup(servletContext); @@ -54,6 +61,8 @@ void _02_ShouldRegisterContextLoaderListener_WhenApplicationStarts() { .thenReturn(mock(ServletRegistration.Dynamic.class)); when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))) .thenReturn(mock(FilterRegistration.Dynamic.class)); + when(servletContext.addFilter(eq("springSecurityFilterChain"), any(Filter.class))) + .thenReturn(mock(FilterRegistration.Dynamic.class)); new WebAppInitializer().onStartup(servletContext); @@ -70,6 +79,8 @@ void _03_ShouldRegisterUtf8EncodingFilterOnEveryUrl_WhenApplicationStarts() { .thenReturn(mock(ServletRegistration.Dynamic.class)); when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))) .thenReturn(filterRegistration); + when(servletContext.addFilter(eq("springSecurityFilterChain"), any(Filter.class))) + .thenReturn(mock(FilterRegistration.Dynamic.class)); new WebAppInitializer().onStartup(servletContext); @@ -78,4 +89,28 @@ void _03_ShouldRegisterUtf8EncodingFilterOnEveryUrl_WhenApplicationStarts() { assertThat(filter.getValue()).isInstanceOf(CharacterEncodingFilter.class); verify(filterRegistration).addMappingForUrlPatterns(any(), eq(false), eq("/*")); } + + @Test + void _04_ShouldRegisterSecurityFilterChainProxyAfterEncodingFilter_WhenApplicationStarts() { + ServletContext servletContext = mock(ServletContext.class); + FilterRegistration.Dynamic encoding = mock(FilterRegistration.Dynamic.class); + FilterRegistration.Dynamic security = mock(FilterRegistration.Dynamic.class); + when(servletContext.addServlet(eq("dispatcher"), any(Servlet.class))) + .thenReturn(mock(ServletRegistration.Dynamic.class)); + when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))).thenReturn(encoding); + when(servletContext.addFilter(eq("springSecurityFilterChain"), any(Filter.class))).thenReturn(security); + + new WebAppInitializer().onStartup(servletContext); + + // Registration order is the servlet filter order: encoding first, then security + InOrder order = inOrder(servletContext); + order.verify(servletContext).addFilter(eq("characterEncodingFilter"), any(Filter.class)); + order.verify(servletContext).addFilter(eq("springSecurityFilterChain"), any(Filter.class)); + ArgumentCaptor filter = ArgumentCaptor.forClass(Filter.class); + verify(servletContext).addFilter(eq("springSecurityFilterChain"), filter.capture()); + assertThat(filter.getValue()).isInstanceOf(DelegatingFilterProxy.class); + verify(security).addMappingForUrlPatterns( + eq(EnumSet.of(DispatcherType.REQUEST, DispatcherType.ERROR, DispatcherType.ASYNC)), eq(false), + eq("/*")); + } } diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java index a9a4333..056342c 100644 --- a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java @@ -1,9 +1,12 @@ package com.edgareldy.springjdbctutorial.ws.config; import static org.assertj.core.api.Assertions.assertThat; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.ws.support.AuthTestSupport; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -18,8 +21,8 @@ import org.springframework.web.context.WebApplicationContext; /** - * Full-stack integration test: the REAL WebMvcConfig (CommonConfig, DataSourceConfig, Flyway, real - * service and DAO) against the Testcontainers PostgreSQL, driven through MockMvc. + * Full-stack integration test: the REAL WebMvcConfig (CommonConfig, DataSourceConfig, Flyway, auth services, + * SecurityConfig, real service and DAO) against the Testcontainers PostgreSQL, driven through MockMvc. *

* Created edgar.muhamyangabo on 9/19/26 * Author : edgar.muhamyangabo @@ -45,11 +48,13 @@ static void datasource(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { - mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + // springSecurity() adds the springSecurityFilterChain bean in front of MVC, as the DelegatingFilterProxy + // does in Tomcat, so the routes are really protected in these tests + mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); } @Test - void _01_ShouldReturn200WithStatusUp_WhenHealthRouteIsCalledOnARealDatabase() throws Exception { + void _01_ShouldReturn200WithStatusUpWithoutToken_WhenHealthRouteIsCalledOnARealDatabase() throws Exception { MvcResult result = mockMvc.perform(get("/api/v1/health")).andReturn(); assertThat(result.getResponse().getStatus()).isEqualTo(200); @@ -60,14 +65,16 @@ void _01_ShouldReturn200WithStatusUp_WhenHealthRouteIsCalledOnARealDatabase() th } @Test - void _02_ShouldReturn404ApiResponse_WhenUrlIsUnknown() throws Exception { - MvcResult result = mockMvc.perform(get("/api/v1/nothing-here")).andReturn(); + void _02_ShouldReturn401ApiResponse_WhenUrlIsUnknownAndNoTokenIsSent() throws Exception { + assertError(mockMvc.perform(get("/api/v1/nothing-here")).andReturn(), 401); + } - assertThat(result.getResponse().getStatus()).isEqualTo(404); - JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); - assertThat(json.get("success").asBoolean()).isFalse(); - assertThat(json.get("message").asText()).isNotBlank(); - assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); - assertThat(json.get("timestamp").asText()).isNotBlank(); + @Test + void _03_ShouldReturn404ApiResponse_WhenUrlIsUnknownAndTokenIsValid() throws Exception { + try (AuthTestSupport support = new AuthTestSupport(mockMvc)) { + String token = support.registerActivateAndLogin(AuthTestSupport.uniqueEmail("config"), AuthTestSupport.PASSWORD); + + assertError(support.getAs(token, "/api/v1/nothing-here"), 404); + } } } diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AuthFlowIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AuthFlowIntegrationTest.java new file mode 100644 index 0000000..15b8c08 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AuthFlowIntegrationTest.java @@ -0,0 +1,266 @@ +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 com.edgareldy.springjdbctutorial.ws.support.AuthTestSupport.PASSWORD; +import static com.edgareldy.springjdbctutorial.ws.support.AuthTestSupport.uniqueEmail; +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.get; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import com.edgareldy.springjdbctutorial.ws.support.AuthTestSupport; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.UUID; +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.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * Full-stack flows of feature/auth: the real WebMvcConfig (real services, DAOs, Flyway, security chain) + * against PostgreSQL in Testcontainers, driven through MockMvc. Every test uses its own random email, so + * tests sharing the one database never collide. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@SpringJUnitWebConfig(WebMvcConfig.class) +class AuthFlowIntegrationTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private WebApplicationContext context; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private AuthService authService; + + private AuthTestSupport support; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + // Same wiring as production: springSecurity() puts the springSecurityFilterChain in front of MVC + mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + support = new AuthTestSupport(mockMvc); + } + + @AfterEach + void tearDown() { + support.close(); + } + + private long count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Long.class, args); + } + + // ---------------------------------------------------------------- the README flow + + @Test + void _01_ShouldCompleteRegisterActivateLoginMeLogout_WhenFollowingTheHappyPath() throws Exception { + String email = uniqueEmail("flow"); + + JsonNode registered = assertSuccess(support.register(email, PASSWORD), 201); + assertThat(registered.get("data").get("enabled").asBoolean()).isFalse(); + assertThat(registered.get("data").has("password")).isFalse(); + + // The raw token exists only in the log: the database holds its SHA-256 digest, never the raw value + String rawActivation = support.activationToken(email); + assertThat(count("SELECT COUNT(*) FROM activation_tokens WHERE token = ?", rawActivation)).isZero(); + assertThat(count("SELECT COUNT(*) FROM activation_tokens WHERE token = ?", + JwtService.sha256Hex(rawActivation))).isOne(); + assertThat(count("SELECT COUNT(*) FROM users WHERE email = ? AND password = ?", email, PASSWORD)).isZero(); + + assertSuccess(support.activate(rawActivation), 200); + + String jwt = support.loginToken(email, PASSWORD); + JsonNode me = assertSuccess(support.getAs(jwt, "/api/v1/auth/me"), 200); + assertThat(me.get("data").get("email").asText()).isEqualTo(email); + assertThat(me.get("data").get("enabled").asBoolean()).isTrue(); + assertThat(me.get("data").has("password")).isFalse(); + + assertSuccess(support.logout(jwt), 200); + assertError(support.getAs(jwt, "/api/v1/auth/me"), 401); + } + + @Test + void _02_ShouldRejectOnlyTheLoggedOutToken_WhenTheSameUserHoldsTwoTokens() throws Exception { + String email = uniqueEmail("blacklist"); + String tokenA = support.registerActivateAndLogin(email, PASSWORD); + String tokenB = support.loginToken(email, PASSWORD); + assertThat(tokenB).isNotEqualTo(tokenA); + + assertSuccess(support.logout(tokenA), 200); + + assertError(support.getAs(tokenA, "/api/v1/auth/me"), 401); + assertSuccess(support.getAs(tokenB, "/api/v1/auth/me"), 200); + assertThat(count("SELECT COUNT(*) FROM blacklisted_tokens WHERE token = ?", JwtService.sha256Hex(tokenA))).isOne(); + assertThat(count("SELECT COUNT(*) FROM blacklisted_tokens WHERE token = ?", tokenA)).isZero(); + } + + @Test + void _03_ShouldReturn401AndKeepOneBlacklistRow_WhenTheRevokedTokenLogsOutAgain() throws Exception { + String email = uniqueEmail("logout-twice"); + String token = support.registerActivateAndLogin(email, PASSWORD); + + assertSuccess(support.logout(token), 200); + // The revoked token is refused by the filter before the controller runs: no second row, no 500 + assertError(support.logout(token), 401); + + assertThat(count("SELECT COUNT(*) FROM blacklisted_tokens WHERE user_id = " + + "(SELECT id FROM users WHERE email = ?)", email)).isOne(); + } + + @Test + void _04_ShouldResetPasswordAndRefuseTheOldOne_WhenForgotAndResetAreFollowed() throws Exception { + String email = uniqueEmail("reset"); + support.registerActivateAndLogin(email, PASSWORD); + + assertSuccess(support.forgotPassword(email), 200); + String rawReset = support.resetToken(email); + assertThat(count("SELECT COUNT(*) FROM password_reset_tokens WHERE token = ?", rawReset)).isZero(); + assertThat(count("SELECT COUNT(*) FROM password_reset_tokens WHERE token = ?", + JwtService.sha256Hex(rawReset))).isOne(); + + assertSuccess(support.resetPassword(rawReset, "Brand-new-pass1"), 200); + + assertError(support.login(email, PASSWORD), 401); + assertSuccess(support.login(email, "Brand-new-pass1"), 200); + // A reset token is single use + assertError(support.resetPassword(rawReset, "Another-pass-2"), 422); + } + + // ---------------------------------------------------------------- rejections + + @Test + void _05_ShouldReturn401AndNoToken_WhenAccountIsNotActivatedYet() throws Exception { + String email = uniqueEmail("inactive"); + support.register(email, PASSWORD); + + JsonNode json = assertError(support.login(email, PASSWORD), 401); + + assertThat(json.get("message").asText()).isEqualTo("Account is not activated or is locked"); + } + + @Test + void _06_ShouldReturn422_WhenEmailIsAlreadyRegisteredWhateverItsCase() throws Exception { + String email = uniqueEmail("dup"); + assertSuccess(support.register(email, PASSWORD), 201); + + assertError(support.register(email, PASSWORD), 422); + assertError(support.register(email.toUpperCase(), PASSWORD), 422); + } + + @Test + void _07_ShouldReturn400_WhenPasswordHas73AsciiBytes() throws Exception { + MvcResult result = support.register(uniqueEmail("long"), "a".repeat(73)); + + assertError(result, 400); + } + + @Test + void _08_ShouldReturn422NotAServerError_WhenPasswordIsUnder72CharactersButOver72Bytes() throws Exception { + // 40 x "e acute" passes the 72 character validation but is 80 bytes: the service must refuse it cleanly + MvcResult result = support.register(uniqueEmail("multibyte"), "é".repeat(40)); + + JsonNode json = assertError(result, 422); + assertThat(json.get("message").asText()).contains("72 bytes"); + } + + @Test + void _09_ShouldGiveTheSameAnswer_WhenEmailIsUnknownOrPasswordIsWrong() throws Exception { + String email = uniqueEmail("creds"); + support.registerActivateAndLogin(email, PASSWORD); + + JsonNode wrongPassword = assertError(support.login(email, "Wrong-password-1"), 401); + JsonNode unknownEmail = assertError(support.login(uniqueEmail("ghost"), PASSWORD), 401); + JsonNode tooLong = assertError(support.login(email, "x".repeat(200)), 401); + + assertThat(wrongPassword.get("message").asText()).isEqualTo(unknownEmail.get("message").asText()); + assertThat(tooLong.get("message").asText()).isEqualTo(unknownEmail.get("message").asText()); + } + + @Test + void _10_ShouldGiveTheSameAnswer_WhenForgotPasswordIsCalledForKnownAndUnknownEmails() throws Exception { + String email = uniqueEmail("forgot"); + support.registerActivateAndLogin(email, PASSWORD); + + MvcResult known = support.forgotPassword(email); + MvcResult unknown = support.forgotPassword(uniqueEmail("nobody")); + + assertThat(known.getResponse().getStatus()).isEqualTo(200).isEqualTo(unknown.getResponse().getStatus()); + assertThat(assertSuccess(known, 200).get("message").asText()) + .isEqualTo(assertSuccess(unknown, 200).get("message").asText()); + } + + @Test + void _11_ShouldReturn422_WhenActivationTokenIsUnknownReusedOrExpired() throws Exception { + String email = uniqueEmail("activation"); + support.register(email, PASSWORD); + String raw = support.activationToken(email); + + assertError(support.activate("unknown-token"), 422); + assertSuccess(support.activate(raw), 200); + assertError(support.activate(raw), 422); + + String expiredEmail = uniqueEmail("expired"); + support.register(expiredEmail, PASSWORD); + String expiredRaw = support.activationToken(expiredEmail); + jdbcTemplate.update("UPDATE activation_tokens SET expires_at = now() - interval '1 hour' WHERE token = ?", + JwtService.sha256Hex(expiredRaw)); + assertError(support.activate(expiredRaw), 422); + assertError(support.login(expiredEmail, PASSWORD), 401); + } + + @Test + void _12_ShouldReturn401WhateverThePath_WhenNoTokenIsSent() throws Exception { + assertError(mockMvc.perform(get("/api/v1/nothing-here")).andReturn(), 401); + assertError(mockMvc.perform(get("/api/v1/auth/me")).andReturn(), 401); + } + + @Test + void _13_ShouldReturn404_WhenUnknownUrlIsCalledWithAValidToken() throws Exception { + String token = support.registerActivateAndLogin(uniqueEmail("notfound"), PASSWORD); + + assertError(support.getAs(token, "/api/v1/nothing-here"), 404); + } + + // ---------------------------------------------------------------- scheduled cleanup + + @Test + void _14_ShouldDeleteOnlyExpiredBlacklistRows_WhenTheCleanupRuns() throws Exception { + String email = uniqueEmail("purge"); + JsonNode registered = assertSuccess(support.register(email, PASSWORD), 201); + long userId = registered.get("data").get("id").asLong(); + String expiredHash = JwtService.sha256Hex(UUID.randomUUID().toString()); + String liveHash = JwtService.sha256Hex(UUID.randomUUID().toString()); + String insert = "INSERT INTO blacklisted_tokens (user_id, token, jti, blacklisted_at, created_at, expires_at) " + + "VALUES (?, ?, ?, now(), now() - interval '2 days', now() + (?::interval))"; + jdbcTemplate.update(insert, userId, expiredHash, UUID.randomUUID().toString(), "-1 day"); + jdbcTemplate.update(insert, userId, liveHash, UUID.randomUUID().toString(), "1 day"); + + int removed = authService.purgeExpiredTokens(); + + assertThat(removed).isGreaterThanOrEqualTo(1); + assertThat(count("SELECT COUNT(*) FROM blacklisted_tokens WHERE token = ?", expiredHash)).isZero(); + assertThat(count("SELECT COUNT(*) FROM blacklisted_tokens WHERE token = ?", liveHash)).isOne(); + } +} From c25531885cc2b18bf7b2290d9e6966777280ecf2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:22:46 +0400 Subject: [PATCH 060/144] build(docker): pass the JWT key locations and mount the production keys The app service now receives APP_JWT_PRIVATE_KEY_LOCATION and APP_JWT_PUBLIC_KEY_LOCATION, which Spring maps to app.jwt.* properties, and mounts ./keys read-only at /keys. There is no default key: without the mounted pair the application refuses to start. The README of the compose file now applies to the whole project since authentication exists. The keys directory is ignored by git. --- docker-compose.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index d19c854..a845601 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ # # Before the first "docker compose up --build": # 1. cp .env.example .env and set DB_PASSWORD (there is no default password on purpose) -# 2. from the feature/auth branch on, create a production RSA key pair in ./keys (the keys under +# 2. create a production RSA key pair in ./keys (the keys under # dev-keys/ are for development only): # mkdir -p keys # openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out keys/privateKey.pem @@ -40,6 +40,11 @@ services: APP_DATASOURCE_URL: jdbc:postgresql://db:5432/${DB_NAME:-spring_jdbc_tutorial} APP_DATASOURCE_USERNAME: ${DB_USER:-spring} APP_DATASOURCE_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + # No default key: the application refuses to start without these two locations + APP_JWT_PRIVATE_KEY_LOCATION: file:/keys/privateKey.pem + APP_JWT_PUBLIC_KEY_LOCATION: file:/keys/publicKey.pem + volumes: + - ./keys:/keys:ro ports: - "${APP_PORT:-8080}:8080" From a2c2d0cfb494f38387f3ecc925c6b735344e2c8c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:25:16 +0400 Subject: [PATCH 061/144] fix(auth): treat a blank email as unknown in forgotPassword normalizeEmail throws a BusinessRuleException for a blank value, so a whitespace-only email produced a 422 while every other input, known or unknown, gave the generic answer. forgotPassword now handles a null or blank email like an unknown account and never throws. The unit test suite gets a non-regression test for it, and the following test methods are renumbered to keep the sequence. --- .../auth/service/impl/AuthServiceImpl.java | 4 +++- .../auth/service/impl/AuthServiceImplTest.java | 18 +++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java index 99d343e..5cfd6a3 100644 --- a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java @@ -176,7 +176,9 @@ public void forgotPassword(String email) { // Token and hash are generated whether or not the account exists, so the work is alike String rawToken = tokenHasher.generateRawToken(); String tokenHash = tokenHasher.sha256Hex(rawToken); - String normalized = email == null ? "" : normalizeEmail(email); + // A null or blank email is simply an unknown account here: normalizeEmail would throw a 422 and + // reveal a different behaviour than the generic answer + String normalized = email == null || email.isBlank() ? "" : normalizeEmail(email); User user = userDao.findByEmail(normalized).orElse(null); // The delete runs for an unknown account too (on an id that matches nothing), so the only work // skipped when the email is unknown is the final insert and the log line diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java index aa3fa23..493768a 100644 --- a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java @@ -456,10 +456,18 @@ void _29_ShouldNotThrow_WhenEmailIsNull() { verify(passwordResetTokenDao, never()).insert(any()); } + // Non-regression: a blank email used to throw a 422 here, unlike the generic answer for an unknown one + @Test + void _30_ShouldNotThrow_WhenEmailIsBlank() { + assertThatCode(() -> service.forgotPassword(" ")).doesNotThrowAnyException(); + + verify(passwordResetTokenDao, never()).insert(any()); + } + // ---------------------------------------------------------------- resetPassword @Test - void _30_ShouldStoreNewHashAndDeleteResetTokens_WhenTokenIsValid() throws Exception { + void _31_ShouldStoreNewHashAndDeleteResetTokens_WhenTokenIsValid() throws Exception { PasswordResetToken token = new PasswordResetToken(3L, 5L, sha256("raw-reset"), "PASSWORD_RESET", NOW.plusSeconds(600)); when(passwordResetTokenDao.findByTokenHash(sha256("raw-reset"))).thenReturn(Optional.of(token)); @@ -473,7 +481,7 @@ void _30_ShouldStoreNewHashAndDeleteResetTokens_WhenTokenIsValid() throws Except } @Test - void _31_ShouldThrowBusinessRuleException_WhenResetTokenIsExpired() { + void _32_ShouldThrowBusinessRuleException_WhenResetTokenIsExpired() { PasswordResetToken expired = new PasswordResetToken(3L, 5L, "h", "PASSWORD_RESET", NOW); when(passwordResetTokenDao.findByTokenHash(anyString())).thenReturn(Optional.of(expired)); @@ -484,7 +492,7 @@ void _31_ShouldThrowBusinessRuleException_WhenResetTokenIsExpired() { } @Test - void _32_ShouldThrowBusinessRuleException_WhenResetTokenIsUnknown() { + void _33_ShouldThrowBusinessRuleException_WhenResetTokenIsUnknown() { when(passwordResetTokenDao.findByTokenHash(anyString())).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.resetPassword("raw", "Brand-new-pass1")) @@ -494,7 +502,7 @@ void _32_ShouldThrowBusinessRuleException_WhenResetTokenIsUnknown() { } @Test - void _33_ShouldThrowBusinessRuleException_WhenNewPasswordExceeds72Bytes() { + void _34_ShouldThrowBusinessRuleException_WhenNewPasswordExceeds72Bytes() { PasswordResetToken token = new PasswordResetToken(3L, 5L, "h", "PASSWORD_RESET", NOW.plusSeconds(600)); when(passwordResetTokenDao.findByTokenHash(anyString())).thenReturn(Optional.of(token)); @@ -509,7 +517,7 @@ void _33_ShouldThrowBusinessRuleException_WhenNewPasswordExceeds72Bytes() { // ---------------------------------------------------------------- purgeExpiredTokens @Test - void _34_ShouldDeleteRowsExpiredBeforeNowAndReturnTheCount_WhenPurging() { + void _35_ShouldDeleteRowsExpiredBeforeNowAndReturnTheCount_WhenPurging() { when(blacklistedTokenDao.deleteExpiredBefore(NOW)).thenReturn(3); assertThat(service.purgeExpiredTokens()).isEqualTo(3); From dfd45f08ab8ca62e7892356ae0e388faa76c0775 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:25:16 +0400 Subject: [PATCH 062/144] docs(readme): tick the feature/auth tasks All nine tasks of the auth branch are checked. They cover the entities and dto classes, the RowMappers and UserMapper, the DAOs, AuthService with its constant-outcome forgotPassword, the module wiring, the JWT service and filter, the controller, the WebMvcConfig imports and the tests at all five layers plus the full register to login flow. --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2268d3e..8bf93af 100644 --- a/README.md +++ b/README.md @@ -334,15 +334,15 @@ No other naming style (`shouldX()`, `testX()`, `givenX_whenY_thenZ()`) is used a ### Tasks -- [ ] `core/auth`: `User`, `ActivationToken`, `BlacklistedToken`, `PasswordResetToken` **entity** classes (`entity/`, `@Table("users")`/`@Table("activation_tokens")`/etc., `@Id`/`@Column` on fields, plain Java otherwise) and their matching `UserDto`, `ActivationTokenDto`, `BlacklistedTokenDto`, `PasswordResetTokenDto` classes (`dto/`) -- [ ] `UserRowMapper`, `ActivationTokenRowMapper`, `BlacklistedTokenRowMapper`, `PasswordResetTokenRowMapper` (`ResultSet` → entity), and `UserMapper` (entity ↔ dto, the only one of these four actually needed yet - the token entities/dtos are used directly by `AuthServiceImpl` without a full bidirectional mapper, since nothing outside the service ever needs a token dto's shape beyond what `AuthController` reads directly off `AuthService`'s own return types) -- [ ] `UserDao` + `UserDaoImpl` (and the token DAOs), all `JdbcTemplate`-based, all unannotated, all working in `entity/` types -- [ ] `AuthService` (interface) + `AuthServiceImpl`: registration, activation, login (password hashing/verification via `org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder`, used standalone), logout, forgot/reset password - every public method takes/returns `dto/` types, converting to/from `entity/` via `UserMapper` around each `UserDao` call -- [ ] `forgotPassword` returns the exact same outcome - same behavior, roughly the same timing - whether or not the submitted email matches an existing account, so the endpoint can't be used to enumerate registered emails -- [ ] `core/auth`'s `DaoConfig`/`ServiceConfig` -- [ ] `ws`: `JwtService` (issuance/validation via `jjwt`, unique `jti` per token), `JwtAuthFilter`, `AuthController`, `UserConverter` (`core.auth.dto.UserDto` ↔ `ws.payload.auth.*`) -- [ ] `WebMvcConfig` updated to import `core/auth`'s `DaoConfig`/`ServiceConfig` -- [ ] Tests at all five layers (see [Testing strategy](#testing-strategy)): `UserDao`/token DAOs against Testcontainers with their own fixture files, each `RowMapper` against a mocked `ResultSet`, `UserMapper` against a hand-built entity/dto pair (pure unit test, no mocks needed), `AuthServiceImpl` with `UserDao` mocked, `AuthController` via `MockMvc` with `AuthService` mocked - plus one full register → activate → login → `/me` flow +- [x] `core/auth`: `User`, `ActivationToken`, `BlacklistedToken`, `PasswordResetToken` **entity** classes (`entity/`, `@Table("users")`/`@Table("activation_tokens")`/etc., `@Id`/`@Column` on fields, plain Java otherwise) and their matching `UserDto`, `ActivationTokenDto`, `BlacklistedTokenDto`, `PasswordResetTokenDto` classes (`dto/`) +- [x] `UserRowMapper`, `ActivationTokenRowMapper`, `BlacklistedTokenRowMapper`, `PasswordResetTokenRowMapper` (`ResultSet` → entity), and `UserMapper` (entity ↔ dto, the only one of these four actually needed yet - the token entities/dtos are used directly by `AuthServiceImpl` without a full bidirectional mapper, since nothing outside the service ever needs a token dto's shape beyond what `AuthController` reads directly off `AuthService`'s own return types) +- [x] `UserDao` + `UserDaoImpl` (and the token DAOs), all `JdbcTemplate`-based, all unannotated, all working in `entity/` types +- [x] `AuthService` (interface) + `AuthServiceImpl`: registration, activation, login (password hashing/verification via `org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder`, used standalone), logout, forgot/reset password - every public method takes/returns `dto/` types, converting to/from `entity/` via `UserMapper` around each `UserDao` call +- [x] `forgotPassword` returns the exact same outcome - same behavior, roughly the same timing - whether or not the submitted email matches an existing account, so the endpoint can't be used to enumerate registered emails +- [x] `core/auth`'s `DaoConfig`/`ServiceConfig` +- [x] `ws`: `JwtService` (issuance/validation via `jjwt`, unique `jti` per token), `JwtAuthFilter`, `AuthController`, `UserConverter` (`core.auth.dto.UserDto` ↔ `ws.payload.auth.*`) +- [x] `WebMvcConfig` updated to import `core/auth`'s `DaoConfig`/`ServiceConfig` +- [x] Tests at all five layers (see [Testing strategy](#testing-strategy)): `UserDao`/token DAOs against Testcontainers with their own fixture files, each `RowMapper` against a mocked `ResultSet`, `UserMapper` against a hand-built entity/dto pair (pure unit test, no mocks needed), `AuthServiceImpl` with `UserDao` mocked, `AuthController` via `MockMvc` with `AuthService` mocked - plus one full register → activate → login → `/me` flow ## feature/rbac From 766944afc7c5b383bd6e2c368c72c9f575af128c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:39:44 +0400 Subject: [PATCH 063/144] 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 064/144] 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 065/144] 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 066/144] 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 067/144] 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 068/144] 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 069/144] 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 070/144] 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 071/144] 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 072/144] 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 073/144] 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 074/144] 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 075/144] 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 076/144] 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 077/144] 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 078/144] 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 079/144] 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 080/144] 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 081/144] 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 082/144] 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 083/144] 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 084/144] 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 085/144] 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 086/144] 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 087/144] 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 088/144] 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 089/144] 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 090/144] 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 091/144] 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 092/144] 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. From 26ca072b94cccb3d99245da488a9899151b21980 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 093/144] feat(catalog): add the Category and Product entities and dto classes Category and Product are table-shaped classes documented with the jakarta.persistence annotations, and CategoryDto and ProductDto are the business shapes the services expose. The unit price is a BigDecimal on both sides, never a double, because money must not suffer binary rounding. The two shapes stay separate classes even though their fields match today. --- .../core/catalog/dto/CategoryDto.java | 67 ++++++++++++ .../core/catalog/dto/ProductDto.java | 92 ++++++++++++++++ .../core/catalog/entity/Category.java | 74 +++++++++++++ .../core/catalog/entity/Product.java | 101 ++++++++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/CategoryDto.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/ProductDto.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Category.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Product.java diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/CategoryDto.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/CategoryDto.java new file mode 100644 index 0000000..9c1cfc1 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/CategoryDto.java @@ -0,0 +1,67 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dto; + +import java.util.Objects; + +/** + * Business shape of a category. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryDto { + + private Long id; + + private String categoryName; + + public CategoryDto() { + } + + public CategoryDto(Long id, String categoryName) { + this.id = id; + this.categoryName = categoryName; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CategoryDto that = (CategoryDto) o; + return Objects.equals(id, that.id) && Objects.equals(categoryName, that.categoryName); + } + + @Override + public int hashCode() { + return Objects.hash(id, categoryName); + } + + @Override + public String toString() { + return "CategoryDto{" + + "id=" + id + + ", categoryName=" + categoryName + + "}"; + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/ProductDto.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/ProductDto.java new file mode 100644 index 0000000..e130fcc --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dto/ProductDto.java @@ -0,0 +1,92 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dto; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Business shape of a product. The unit price is a decimal with two digits. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductDto { + + private Long id; + + private Long categoryId; + + private String productName; + + private BigDecimal unitPrice; + + public ProductDto() { + } + + public ProductDto(Long id, Long categoryId, String productName, BigDecimal unitPrice) { + this.id = id; + this.categoryId = categoryId; + this.productName = productName; + this.unitPrice = unitPrice; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCategoryId() { + return categoryId; + } + + public void setCategoryId(Long categoryId) { + this.categoryId = categoryId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public void setUnitPrice(BigDecimal unitPrice) { + this.unitPrice = unitPrice; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ProductDto that = (ProductDto) o; + return Objects.equals(id, that.id) && Objects.equals(categoryId, that.categoryId) && Objects.equals(productName, that.productName) && Objects.equals(unitPrice, that.unitPrice); + } + + @Override + public int hashCode() { + return Objects.hash(id, categoryId, productName, unitPrice); + } + + @Override + public String toString() { + return "ProductDto{" + + "id=" + id + + ", categoryId=" + categoryId + + ", productName=" + productName + + ", unitPrice=" + unitPrice + + "}"; + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Category.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Category.java new file mode 100644 index 0000000..8a49b04 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Category.java @@ -0,0 +1,74 @@ +package com.edgareldy.springjdbctutorial.core.catalog.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.Objects; + +/** + * Row of the categories table. The jakarta.persistence annotations are documentation only, nothing reads them at runtime. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "categories") +public class Category { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "category_name") + private String categoryName; + + public Category() { + } + + public Category(Long id, String categoryName) { + this.id = id; + this.categoryName = categoryName; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Category that = (Category) o; + return Objects.equals(id, that.id) && Objects.equals(categoryName, that.categoryName); + } + + @Override + public int hashCode() { + return Objects.hash(id, categoryName); + } + + @Override + public String toString() { + return "Category{" + + "id=" + id + + ", categoryName=" + categoryName + + "}"; + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Product.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Product.java new file mode 100644 index 0000000..725080a --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/entity/Product.java @@ -0,0 +1,101 @@ +package com.edgareldy.springjdbctutorial.core.catalog.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Row of the products table. The jakarta.persistence annotations are documentation only, nothing reads them at runtime. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "products") +public class Product { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "category_id") + private Long categoryId; + + @Column(name = "product_name") + private String productName; + + @Column(name = "unit_price") + private BigDecimal unitPrice; + + public Product() { + } + + public Product(Long id, Long categoryId, String productName, BigDecimal unitPrice) { + this.id = id; + this.categoryId = categoryId; + this.productName = productName; + this.unitPrice = unitPrice; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCategoryId() { + return categoryId; + } + + public void setCategoryId(Long categoryId) { + this.categoryId = categoryId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public void setUnitPrice(BigDecimal unitPrice) { + this.unitPrice = unitPrice; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Product that = (Product) o; + return Objects.equals(id, that.id) && Objects.equals(categoryId, that.categoryId) && Objects.equals(productName, that.productName) && Objects.equals(unitPrice, that.unitPrice); + } + + @Override + public int hashCode() { + return Objects.hash(id, categoryId, productName, unitPrice); + } + + @Override + public String toString() { + return "Product{" + + "id=" + id + + ", categoryId=" + categoryId + + ", productName=" + productName + + ", unitPrice=" + unitPrice + + "}"; + } +} From eef96e844c7baa109e33fc871395417fe75f0a21 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 094/144] feat(catalog): add the RowMappers and entity mappers of the catalog The RowMappers read one row into an entity, using rs.getBigDecimal for the NUMERIC unit price so the exact decimal value survives the round trip. The entity mappers convert between entity and dto in both directions. Neither queries the database or holds any business rule. --- .../core/catalog/mapper/CategoryMapper.java | 29 ++++++++++++++++++ .../catalog/mapper/CategoryRowMapper.java | 25 ++++++++++++++++ .../core/catalog/mapper/ProductMapper.java | 30 +++++++++++++++++++ .../core/catalog/mapper/ProductRowMapper.java | 27 +++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapper.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapper.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapper.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapper.java diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapper.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapper.java new file mode 100644 index 0000000..b138ecd --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapper.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; + +/** + * Converts between the Category entity and the CategoryDto. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryMapper { + + public CategoryDto toDto(Category category) { + if (category == null) { + return null; + } + return new CategoryDto(category.getId(), category.getCategoryName()); + } + + public Category toEntity(CategoryDto dto) { + if (dto == null) { + return null; + } + return new Category(dto.getId(), dto.getCategoryName()); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapper.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapper.java new file mode 100644 index 0000000..c0550b5 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapper.java @@ -0,0 +1,25 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the categories table into a Category entity. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryRowMapper implements RowMapper { + + @Override + public Category mapRow(ResultSet rs, int rowNum) throws SQLException { + return new Category( + rs.getLong("id"), + rs.getString("category_name")); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapper.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapper.java new file mode 100644 index 0000000..991bf2f --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapper.java @@ -0,0 +1,30 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; + +/** + * Converts between the Product entity and the ProductDto. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductMapper { + + public ProductDto toDto(Product product) { + if (product == null) { + return null; + } + return new ProductDto(product.getId(), product.getCategoryId(), product.getProductName(), + product.getUnitPrice()); + } + + public Product toEntity(ProductDto dto) { + if (dto == null) { + return null; + } + return new Product(dto.getId(), dto.getCategoryId(), dto.getProductName(), dto.getUnitPrice()); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapper.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapper.java new file mode 100644 index 0000000..acbb972 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapper.java @@ -0,0 +1,27 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the products table into a Product entity. NUMERIC is read with getBigDecimal to keep exact cents. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductRowMapper implements RowMapper { + + @Override + public Product mapRow(ResultSet rs, int rowNum) throws SQLException { + return new Product( + rs.getLong("id"), + rs.getLong("category_id"), + rs.getString("product_name"), + rs.getBigDecimal("unit_price")); + } +} From e7ff607afdc62acd465f775ed05a1a08729088af Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 095/144] feat(catalog): add the category and product DAOs The DAOs write the SQL of the catalog with JdbcTemplate through AbstractDao: paginated reads ordered by id with LIMIT and OFFSET, INSERT ... RETURNING id, updates and deletes. ProductDao chooses between two statements depending on whether a category filter is given, which keeps every query plain and index-friendly, and counts products per category for the delete rule. They work in entity types only and have no stereotype annotation. --- .../core/catalog/dao/CategoryDao.java | 33 ++++++++ .../core/catalog/dao/ProductDao.java | 34 ++++++++ .../catalog/dao/impl/CategoryDaoImpl.java | 68 ++++++++++++++++ .../core/catalog/dao/impl/ProductDaoImpl.java | 78 +++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/CategoryDao.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/ProductDao.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImpl.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImpl.java diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/CategoryDao.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/CategoryDao.java new file mode 100644 index 0000000..76b4282 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/CategoryDao.java @@ -0,0 +1,33 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dao; + +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for categories. Entity types only. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface CategoryDao { + + /** One page of categories ordered by id. */ + List findPage(int page, int size); + + long countAll(); + + Optional findById(Long id); + + boolean existsByCategoryName(String categoryName); + + /** Inserts the category and returns it with its generated id. */ + Category insert(Category category); + + void update(Category category); + + void delete(Long id); +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/ProductDao.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/ProductDao.java new file mode 100644 index 0000000..ff73c2b --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/ProductDao.java @@ -0,0 +1,34 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dao; + +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for products. Entity types only. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface ProductDao { + + /** One page of products ordered by id, restricted to a category when categoryId is not null. */ + List findPage(int page, int size, Long categoryId); + + /** Number of products, restricted to a category when categoryId is not null. */ + long count(Long categoryId); + + Optional findById(Long id); + + /** Inserts the product and returns it with its generated id. */ + Product insert(Product product); + + void update(Product product); + + void delete(Long id); + + long countByCategoryId(Long categoryId); +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImpl.java new file mode 100644 index 0000000..749800d --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImpl.java @@ -0,0 +1,68 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dao.impl; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import com.edgareldy.springjdbctutorial.core.catalog.mapper.CategoryRowMapper; +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 CategoryDao. Declared by @Bean in DaoConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryDaoImpl extends AbstractDao implements CategoryDao { + + private final CategoryRowMapper rowMapper = new CategoryRowMapper(); + + public CategoryDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public List findPage(int page, int size) { + return getJdbcTemplate().query( + "SELECT id, category_name FROM categories ORDER BY id LIMIT ? OFFSET ?", + rowMapper, size, (long) page * size); + } + + @Override + public long countAll() { + return count("SELECT COUNT(*) FROM categories"); + } + + @Override + public Optional findById(Long id) { + return findOne("SELECT id, category_name FROM categories WHERE id = ?", rowMapper, id); + } + + @Override + public boolean existsByCategoryName(String categoryName) { + return count("SELECT COUNT(*) FROM categories WHERE category_name = ?", categoryName) > 0; + } + + @Override + public Category insert(Category category) { + long id = insertReturningId("INSERT INTO categories (category_name) VALUES (?) RETURNING id", + category.getCategoryName()); + category.setId(id); + return category; + } + + @Override + public void update(Category category) { + getJdbcTemplate().update("UPDATE categories SET category_name = ? WHERE id = ?", + category.getCategoryName(), category.getId()); + } + + @Override + public void delete(Long id) { + getJdbcTemplate().update("DELETE FROM categories WHERE id = ?", id); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImpl.java new file mode 100644 index 0000000..f2f3e44 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImpl.java @@ -0,0 +1,78 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dao.impl; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +import com.edgareldy.springjdbctutorial.core.catalog.mapper.ProductRowMapper; +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 ProductDao. Declared by @Bean in DaoConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductDaoImpl extends AbstractDao implements ProductDao { + + private static final String COLUMNS = "SELECT id, category_id, product_name, unit_price FROM products"; + + private final ProductRowMapper rowMapper = new ProductRowMapper(); + + public ProductDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public List findPage(int page, int size, Long categoryId) { + long offset = (long) page * size; + if (categoryId == null) { + return getJdbcTemplate().query(COLUMNS + " ORDER BY id LIMIT ? OFFSET ?", rowMapper, size, offset); + } + return getJdbcTemplate().query(COLUMNS + " WHERE category_id = ? ORDER BY id LIMIT ? OFFSET ?", + rowMapper, categoryId, size, offset); + } + + @Override + public long count(Long categoryId) { + if (categoryId == null) { + return count("SELECT COUNT(*) FROM products"); + } + return countByCategoryId(categoryId); + } + + @Override + public Optional findById(Long id) { + return findOne(COLUMNS + " WHERE id = ?", rowMapper, id); + } + + @Override + public Product insert(Product product) { + long id = insertReturningId( + "INSERT INTO products (category_id, product_name, unit_price) VALUES (?, ?, ?) RETURNING id", + product.getCategoryId(), product.getProductName(), product.getUnitPrice()); + product.setId(id); + return product; + } + + @Override + public void update(Product product) { + getJdbcTemplate().update( + "UPDATE products SET category_id = ?, product_name = ?, unit_price = ? WHERE id = ?", + product.getCategoryId(), product.getProductName(), product.getUnitPrice(), product.getId()); + } + + @Override + public void delete(Long id) { + getJdbcTemplate().update("DELETE FROM products WHERE id = ?", id); + } + + @Override + public long countByCategoryId(Long categoryId) { + return count("SELECT COUNT(*) FROM products WHERE category_id = ?", categoryId); + } +} From 3760e00b024ba173a8ffc7298db6c2438d3f9bd7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 096/144] feat(catalog): implement the category and product services The services own the business rules: names are trimmed and bounded, category names are unique, a category that still has products cannot be deleted, a product must reference an existing category, and prices are non-negative decimals with at most two digits after the point, normalised to scale two. Races are handled without an exception translator: a DuplicateKeyException or DataIntegrityViolationException raised by a concurrent change is turned into the same business refusal as the pre-check, so it never leaks as a 500. The service returns dto types and pages through PageDto. --- .../core/catalog/service/CategoryService.java | 29 ++++ .../core/catalog/service/ProductService.java | 29 ++++ .../service/impl/CategoryServiceImpl.java | 125 ++++++++++++++++ .../service/impl/ProductServiceImpl.java | 140 ++++++++++++++++++ 4 files changed, 323 insertions(+) create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/CategoryService.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/ProductService.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImpl.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/CategoryService.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/CategoryService.java new file mode 100644 index 0000000..13a4355 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/CategoryService.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.catalog.service; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; + +/** + * Business operations on categories. Dto types only at this boundary. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface CategoryService { + + /** One page of categories. Page must be >= 0 and size in 1..100, else BusinessRuleException. */ + PageDto list(int page, int size); + + /** Throws ResourceNotFoundException when the id is unknown. */ + CategoryDto get(Long id); + + /** Only the category name of the input is read. Names are trimmed and unique. */ + CategoryDto create(CategoryDto input); + + CategoryDto update(Long id, CategoryDto input); + + /** Refused with BusinessRuleException while the category still has products. */ + void delete(Long id); +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/ProductService.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/ProductService.java new file mode 100644 index 0000000..3b746e6 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/ProductService.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.catalog.service; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; + +/** + * Business operations on products. Dto types only at this boundary. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface ProductService { + + /** One page of products, optionally restricted to a category (categoryId may be null). */ + PageDto list(int page, int size, Long categoryId); + + /** Throws ResourceNotFoundException when the id is unknown. */ + ProductDto get(Long id); + + /** The category must exist. The unit price is normalised to two decimals. */ + ProductDto create(ProductDto input); + + ProductDto update(Long id, ProductDto input); + + /** Refused with BusinessRuleException when orders reference the product. */ + void delete(Long id); +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImpl.java new file mode 100644 index 0000000..b9c7aa3 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImpl.java @@ -0,0 +1,125 @@ +package com.edgareldy.springjdbctutorial.core.catalog.service.impl; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import com.edgareldy.springjdbctutorial.core.catalog.mapper.CategoryMapper; +import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService; +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; + +/** + * Implementation of CategoryService. Declared by @Bean in ServiceConfig (no stereotype) and returned as the interface so the @Transactional advice applies through the proxy. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryServiceImpl implements CategoryService { + + private static final int MAX_PAGE_SIZE = 100; + private static final int MAX_NAME_LENGTH = 100; + + private final CategoryDao categoryDao; + private final ProductDao productDao; + private final CategoryMapper mapper = new CategoryMapper(); + + public CategoryServiceImpl(CategoryDao categoryDao, ProductDao productDao) { + this.categoryDao = categoryDao; + this.productDao = productDao; + } + + @Override + @Transactional(readOnly = true) + public PageDto list(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 (Category category : categoryDao.findPage(page, size)) { + content.add(mapper.toDto(category)); + } + return new PageDto<>(content, page, size, categoryDao.countAll()); + } + + @Override + @Transactional(readOnly = true) + public CategoryDto get(Long id) { + return mapper.toDto(load(id)); + } + + @Override + @Transactional + public CategoryDto create(CategoryDto input) { + String name = cleanName(input.getCategoryName()); + if (categoryDao.existsByCategoryName(name)) { + throw new BusinessRuleException("Category name already exists"); + } + try { + return mapper.toDto(categoryDao.insert(new Category(null, name))); + } catch (DuplicateKeyException e) { + // Lost a race against a concurrent identical insert: the unique constraint decided + throw new BusinessRuleException("Category name already exists", e); + } + } + + @Override + @Transactional + public CategoryDto update(Long id, CategoryDto input) { + Category category = load(id); + String name = cleanName(input.getCategoryName()); + if (!name.equals(category.getCategoryName()) && categoryDao.existsByCategoryName(name)) { + throw new BusinessRuleException("Category name already exists"); + } + category.setCategoryName(name); + try { + categoryDao.update(category); + } catch (DuplicateKeyException e) { + throw new BusinessRuleException("Category name already exists", e); + } + return mapper.toDto(category); + } + + @Override + @Transactional + public void delete(Long id) { + load(id); + if (productDao.countByCategoryId(id) > 0) { + throw new BusinessRuleException("Category still has products"); + } + try { + categoryDao.delete(id); + } catch (DataIntegrityViolationException e) { + // A product was added between the check and the delete: the foreign key refused it + throw new BusinessRuleException("Category still has products", e); + } + } + + private Category load(Long id) { + return categoryDao.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Category not found: " + id)); + } + + private static String cleanName(String raw) { + String name = raw == null ? "" : raw.trim(); + if (name.isEmpty()) { + throw new BusinessRuleException("Category name must not be blank"); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new BusinessRuleException("Category name must be at most " + MAX_NAME_LENGTH + " characters"); + } + return name; + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java new file mode 100644 index 0000000..bc664f6 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java @@ -0,0 +1,140 @@ +package com.edgareldy.springjdbctutorial.core.catalog.service.impl; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +import com.edgareldy.springjdbctutorial.core.catalog.mapper.ProductMapper; +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +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.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * Implementation of ProductService. Declared by @Bean in ServiceConfig (no stereotype). It reads CategoryDao of the same module to check the category exists. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductServiceImpl implements ProductService { + + private static final int MAX_PAGE_SIZE = 100; + private static final int MAX_NAME_LENGTH = 150; + private static final int PRICE_SCALE = 2; + // NUMERIC(12, 2) holds at most 10 integer digits + private static final BigDecimal MAX_PRICE = new BigDecimal("9999999999.99"); + + private final ProductDao productDao; + private final CategoryDao categoryDao; + private final ProductMapper mapper = new ProductMapper(); + + public ProductServiceImpl(ProductDao productDao, CategoryDao categoryDao) { + this.productDao = productDao; + this.categoryDao = categoryDao; + } + + @Override + @Transactional(readOnly = true) + public PageDto list(int page, int size, Long categoryId) { + 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 (Product product : productDao.findPage(page, size, categoryId)) { + content.add(mapper.toDto(product)); + } + return new PageDto<>(content, page, size, productDao.count(categoryId)); + } + + @Override + @Transactional(readOnly = true) + public ProductDto get(Long id) { + return mapper.toDto(load(id)); + } + + @Override + @Transactional + public ProductDto create(ProductDto input) { + String name = cleanName(input.getProductName()); + BigDecimal price = cleanPrice(input.getUnitPrice()); + requireCategory(input.getCategoryId()); + return mapper.toDto(productDao.insert(new Product(null, input.getCategoryId(), name, price))); + } + + @Override + @Transactional + public ProductDto update(Long id, ProductDto input) { + Product product = load(id); + String name = cleanName(input.getProductName()); + BigDecimal price = cleanPrice(input.getUnitPrice()); + requireCategory(input.getCategoryId()); + product.setCategoryId(input.getCategoryId()); + product.setProductName(name); + product.setUnitPrice(price); + productDao.update(product); + return mapper.toDto(product); + } + + @Override + @Transactional + public void delete(Long id) { + load(id); + try { + productDao.delete(id); + } catch (DataIntegrityViolationException e) { + // The orders table references products: its foreign key refuses the delete + throw new BusinessRuleException("Product is referenced by orders", e); + } + } + + private Product load(Long id) { + return productDao.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id)); + } + + private void requireCategory(Long categoryId) { + if (categoryId == null || categoryDao.findById(categoryId).isEmpty()) { + throw new ResourceNotFoundException("Category not found: " + categoryId); + } + } + + private static String cleanName(String raw) { + String name = raw == null ? "" : raw.trim(); + if (name.isEmpty()) { + throw new BusinessRuleException("Product name must not be blank"); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new BusinessRuleException("Product name must be at most " + MAX_NAME_LENGTH + " characters"); + } + return name; + } + + /** Rejects null, negative, too large or more than two decimals, then returns the price with exactly two decimals. */ + private static BigDecimal cleanPrice(BigDecimal raw) { + if (raw == null) { + throw new BusinessRuleException("Unit price is required"); + } + if (raw.signum() < 0) { + throw new BusinessRuleException("Unit price must not be negative"); + } + if (raw.stripTrailingZeros().scale() > PRICE_SCALE) { + throw new BusinessRuleException("Unit price must have at most " + PRICE_SCALE + " decimals"); + } + BigDecimal price = raw.setScale(PRICE_SCALE); + if (price.compareTo(MAX_PRICE) > 0) { + throw new BusinessRuleException("Unit price must not exceed " + MAX_PRICE.toPlainString()); + } + return price; + } +} From fecd079df68f324c5e5be5f5deadc312758d4402 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 097/144] feat(catalog): wire the catalog DAOs and services explicitly DaoConfig and ServiceConfig declare the beans typed by interface, and ServiceConfig imports DaoConfig so importing it alone is enough. The service beans are proxied for @Transactional through their interfaces. The catalog module needs no configuration of its own beyond these two classes. --- .../core/catalog/config/DaoConfig.java | 31 +++++++++++++++++ .../core/catalog/config/ServiceConfig.java | 34 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/DaoConfig.java create mode 100644 core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/ServiceConfig.java diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/DaoConfig.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/DaoConfig.java new file mode 100644 index 0000000..9c70acf --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/DaoConfig.java @@ -0,0 +1,31 @@ +package com.edgareldy.springjdbctutorial.core.catalog.config; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.impl.CategoryDaoImpl; +import com.edgareldy.springjdbctutorial.core.catalog.dao.impl.ProductDaoImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Declares the DAO beans of the catalog module, typed by interface (no stereotype on the implementations). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +public class DaoConfig { + + @Bean + public CategoryDao categoryDao(JdbcTemplate jdbcTemplate) { + return new CategoryDaoImpl(jdbcTemplate); + } + + @Bean + public ProductDao productDao(JdbcTemplate jdbcTemplate) { + return new ProductDaoImpl(jdbcTemplate); + } +} diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/ServiceConfig.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/ServiceConfig.java new file mode 100644 index 0000000..c323837 --- /dev/null +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/config/ServiceConfig.java @@ -0,0 +1,34 @@ +package com.edgareldy.springjdbctutorial.core.catalog.config; + +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService; +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +import com.edgareldy.springjdbctutorial.core.catalog.service.impl.CategoryServiceImpl; +import com.edgareldy.springjdbctutorial.core.catalog.service.impl.ProductServiceImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * Declares the service beans of the catalog module. Imports its DaoConfig so importing this class alone is enough. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +@Import(DaoConfig.class) +public class ServiceConfig { + + @Bean + public CategoryService categoryService(CategoryDao categoryDao, ProductDao productDao) { + return new CategoryServiceImpl(categoryDao, productDao); + } + + @Bean + public ProductService productService(ProductDao productDao, CategoryDao categoryDao) { + return new ProductServiceImpl(productDao, categoryDao); + } +} From 53f4b96e845cb95f46a165e887f79a5d56c358e6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 098/144] feat(ws): add the catalog payloads and converters Requests carry Bean Validation constraints: bounded names, and for the unit price a non-null decimal that is at least 0.00 with at most ten integer digits and two decimals, so a malformed price is a 400 before it reaches the service. Converters map to and from the core dto classes and build the PageResponse. Payload classes stay distinct from core dto classes. --- .../ws/converter/CategoryConverter.java | 42 +++++++++ .../ws/converter/ProductConverter.java | 44 +++++++++ .../ws/payload/catalog/CategoryRequest.java | 56 +++++++++++ .../ws/payload/catalog/CategoryResponse.java | 67 ++++++++++++++ .../ws/payload/catalog/ProductRequest.java | 87 ++++++++++++++++++ .../ws/payload/catalog/ProductResponse.java | 92 +++++++++++++++++++ 6 files changed, 388 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CategoryConverter.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/ProductConverter.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryResponse.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductResponse.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CategoryConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CategoryConverter.java new file mode 100644 index 0000000..34bed91 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CategoryConverter.java @@ -0,0 +1,42 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.CategoryRequest; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.CategoryResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts between the core CategoryDto and the category HTTP payloads. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public final class CategoryConverter { + + private CategoryConverter() { + } + + public static CategoryDto toDto(CategoryRequest request) { + CategoryDto dto = new CategoryDto(); + dto.setCategoryName(request.getCategoryName()); + return dto; + } + + public static CategoryResponse toResponse(CategoryDto dto) { + return new CategoryResponse(dto.getId(), dto.getCategoryName()); + } + + public static PageResponse toPageResponse(PageDto page) { + List content = new ArrayList<>(); + for (CategoryDto 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/converter/ProductConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/ProductConverter.java new file mode 100644 index 0000000..906f204 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/ProductConverter.java @@ -0,0 +1,44 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.ProductRequest; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.ProductResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts between the core ProductDto and the product HTTP payloads. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public final class ProductConverter { + + private ProductConverter() { + } + + public static ProductDto toDto(ProductRequest request) { + ProductDto dto = new ProductDto(); + dto.setCategoryId(request.getCategoryId()); + dto.setProductName(request.getProductName()); + dto.setUnitPrice(request.getUnitPrice()); + return dto; + } + + public static ProductResponse toResponse(ProductDto dto) { + return new ProductResponse(dto.getId(), dto.getCategoryId(), dto.getProductName(), dto.getUnitPrice()); + } + + public static PageResponse toPageResponse(PageDto page) { + List content = new ArrayList<>(); + for (ProductDto 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/catalog/CategoryRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryRequest.java new file mode 100644 index 0000000..cf4ceff --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryRequest.java @@ -0,0 +1,56 @@ +package com.edgareldy.springjdbctutorial.ws.payload.catalog; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.Objects; + +/** + * Body of the create and update category calls. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryRequest { + + @NotBlank + @Size(max = 100) + private String categoryName; + + public CategoryRequest() { + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CategoryRequest that = (CategoryRequest) o; + return Objects.equals(categoryName, that.categoryName); + } + + @Override + public int hashCode() { + return Objects.hash(categoryName); + } + + @Override + public String toString() { + return "CategoryRequest{" + + "categoryName=" + categoryName + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryResponse.java new file mode 100644 index 0000000..8888024 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/CategoryResponse.java @@ -0,0 +1,67 @@ +package com.edgareldy.springjdbctutorial.ws.payload.catalog; + +import java.util.Objects; + +/** + * A category as returned by the API. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CategoryResponse { + + private Long id; + + private String categoryName; + + public CategoryResponse() { + } + + public CategoryResponse(Long id, String categoryName) { + this.id = id; + this.categoryName = categoryName; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CategoryResponse that = (CategoryResponse) o; + return Objects.equals(id, that.id) && Objects.equals(categoryName, that.categoryName); + } + + @Override + public int hashCode() { + return Objects.hash(id, categoryName); + } + + @Override + public String toString() { + return "CategoryResponse{" + + "id=" + id + + ", categoryName=" + categoryName + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductRequest.java new file mode 100644 index 0000000..a5cf851 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductRequest.java @@ -0,0 +1,87 @@ +package com.edgareldy.springjdbctutorial.ws.payload.catalog; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Body of the create and update product calls. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductRequest { + + @NotNull + private Long categoryId; + + @NotBlank + @Size(max = 150) + private String productName; + + @NotNull + @DecimalMin("0.00") + @Digits(integer = 10, fraction = 2) + private BigDecimal unitPrice; + + public ProductRequest() { + } + + public Long getCategoryId() { + return categoryId; + } + + public void setCategoryId(Long categoryId) { + this.categoryId = categoryId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public void setUnitPrice(BigDecimal unitPrice) { + this.unitPrice = unitPrice; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ProductRequest that = (ProductRequest) o; + return Objects.equals(categoryId, that.categoryId) && Objects.equals(productName, that.productName) + && Objects.equals(unitPrice, that.unitPrice); + } + + @Override + public int hashCode() { + return Objects.hash(categoryId, productName, unitPrice); + } + + @Override + public String toString() { + return "ProductRequest{" + + "categoryId=" + categoryId + + ", productName=" + productName + + ", unitPrice=" + unitPrice + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductResponse.java new file mode 100644 index 0000000..4773b5e --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/catalog/ProductResponse.java @@ -0,0 +1,92 @@ +package com.edgareldy.springjdbctutorial.ws.payload.catalog; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * A product as returned by the API. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class ProductResponse { + + private Long id; + + private Long categoryId; + + private String productName; + + private BigDecimal unitPrice; + + public ProductResponse() { + } + + public ProductResponse(Long id, Long categoryId, String productName, BigDecimal unitPrice) { + this.id = id; + this.categoryId = categoryId; + this.productName = productName; + this.unitPrice = unitPrice; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCategoryId() { + return categoryId; + } + + public void setCategoryId(Long categoryId) { + this.categoryId = categoryId; + } + + public String getProductName() { + return productName; + } + + public void setProductName(String productName) { + this.productName = productName; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public void setUnitPrice(BigDecimal unitPrice) { + this.unitPrice = unitPrice; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ProductResponse that = (ProductResponse) o; + return Objects.equals(id, that.id) && Objects.equals(categoryId, that.categoryId) && Objects.equals(productName, that.productName) && Objects.equals(unitPrice, that.unitPrice); + } + + @Override + public int hashCode() { + return Objects.hash(id, categoryId, productName, unitPrice); + } + + @Override + public String toString() { + return "ProductResponse{" + + "id=" + id + + ", categoryId=" + categoryId + + ", productName=" + productName + + ", unitPrice=" + unitPrice + + "}"; + } +} From bab6a02792479e18c3591d86e93bf72c26143d8b Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 099/144] feat(ws): expose the category and product endpoints Both controllers follow the README table with declarative @PreAuthorize("hasPermission('CATEGORY'|'PRODUCT', 'READ'|'WRITE')"), only call their service and return ApiResponse for every answer. The list endpoints validate page and size, and the product list accepts an optional categoryId filter. Creations return 201, and there is no business logic in either controller. --- .../ws/controller/CategoryController.java | 77 ++++++++++++++++++ .../ws/controller/ProductController.java | 78 +++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CategoryController.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/ProductController.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CategoryController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CategoryController.java new file mode 100644 index 0000000..6678c55 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CategoryController.java @@ -0,0 +1,77 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService; +import com.edgareldy.springjdbctutorial.ws.converter.CategoryConverter; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.CategoryRequest; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.CategoryResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +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.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points to manage categories. No business logic. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@RestController +@RequestMapping("/api/v1/categories") +public class CategoryController { + + private final CategoryService categoryService; + + public CategoryController(CategoryService categoryService) { + this.categoryService = categoryService; + } + + @GetMapping + @PreAuthorize("hasPermission('CATEGORY','READ')") + public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ApiResponse.success(CategoryConverter.toPageResponse(categoryService.list(page, size)), "Categories"); + } + + @GetMapping("/{id}") + @PreAuthorize("hasPermission('CATEGORY','READ')") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.success(CategoryConverter.toResponse(categoryService.get(id)), "Category"); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasPermission('CATEGORY','WRITE')") + public ApiResponse create(@Valid @RequestBody CategoryRequest request) { + return ApiResponse.success(CategoryConverter.toResponse(categoryService.create(CategoryConverter.toDto(request))), + "Category created"); + } + + @PutMapping("/{id}") + @PreAuthorize("hasPermission('CATEGORY','WRITE')") + public ApiResponse update(@PathVariable Long id, @Valid @RequestBody CategoryRequest request) { + return ApiResponse.success(CategoryConverter.toResponse(categoryService.update(id, CategoryConverter.toDto(request))), + "Category updated"); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasPermission('CATEGORY','WRITE')") + public ApiResponse delete(@PathVariable Long id) { + categoryService.delete(id); + return ApiResponse.success(null, "Category deleted"); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/ProductController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/ProductController.java new file mode 100644 index 0000000..ced30db --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/ProductController.java @@ -0,0 +1,78 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +import com.edgareldy.springjdbctutorial.ws.converter.ProductConverter; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.ProductRequest; +import com.edgareldy.springjdbctutorial.ws.payload.catalog.ProductResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +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.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points to manage products. No business logic. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@RestController +@RequestMapping("/api/v1/products") +public class ProductController { + + private final ProductService productService; + + public ProductController(ProductService productService) { + this.productService = productService; + } + + @GetMapping + @PreAuthorize("hasPermission('PRODUCT','READ')") + public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, + @RequestParam(required = false) Long categoryId) { + return ApiResponse.success(ProductConverter.toPageResponse(productService.list(page, size, categoryId)), "Products"); + } + + @GetMapping("/{id}") + @PreAuthorize("hasPermission('PRODUCT','READ')") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.success(ProductConverter.toResponse(productService.get(id)), "Product"); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasPermission('PRODUCT','WRITE')") + public ApiResponse create(@Valid @RequestBody ProductRequest request) { + return ApiResponse.success(ProductConverter.toResponse(productService.create(ProductConverter.toDto(request))), + "Product created"); + } + + @PutMapping("/{id}") + @PreAuthorize("hasPermission('PRODUCT','WRITE')") + public ApiResponse update(@PathVariable Long id, @Valid @RequestBody ProductRequest request) { + return ApiResponse.success(ProductConverter.toResponse(productService.update(id, ProductConverter.toDto(request))), + "Product updated"); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasPermission('PRODUCT','WRITE')") + public ApiResponse delete(@PathVariable Long id) { + productService.delete(id); + return ApiResponse.success(null, "Product deleted"); + } +} From f79baf831541e6fc903d0b99ee9d4bd966b6f92e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:15:54 +0400 Subject: [PATCH 100/144] feat(ws): import the catalog module into the shared context The catalog ServiceConfig is added to the WebMvcConfig import list, by fully qualified name because every core module has a class with that simple name. Importing the ServiceConfig alone is enough since it imports its own DaoConfig. --- .../edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index 295e659..e1185f7 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -37,7 +37,8 @@ // @Bean methods, so nothing gets wired by accident. @Configuration @EnableWebMvc -@Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class, SecurityConfig.class}) +@Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class, + com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class, SecurityConfig.class}) @ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"}) public class WebMvcConfig implements WebMvcConfigurer { From d9667888a8a543f5eac8394096418f78abca19e0 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 101/144] build(catalog): add Testcontainers test dependencies to the catalog module The catalog DAO tests run against a real PostgreSQL, so the module declares the Testcontainers JUnit and PostgreSQL artifacts and the common test-jar holding the shared singleton container, all with test scope, exactly like the auth module. They stay out of the parent pom because only modules with DAO tests need them. --- core/catalog/pom.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/core/catalog/pom.xml b/core/catalog/pom.xml index 5c4dca8..7363b0f 100644 --- a/core/catalog/pom.xml +++ b/core/catalog/pom.xml @@ -19,5 +19,22 @@ com.edgareldy.springjdbctutorial common + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + From 1b535c1d6e93a308ffae48752163bd7a8e8e0045 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 102/144] test(catalog): cover the category and product DAOs against PostgreSQL Each DAO test class loads its own dedicated fixture, truncating only the tables it touches. They check CRUD, page ordering and offsets, the optional category filter, counts per category, a duplicate category name, and the constraints owned by the schema: the foreign key on the category, the CHECK on a negative price and the exact NUMERIC round trip of 12.50. A delete refused by a foreign key surfaces as DataIntegrityViolationException, which is what the service relies on for its race handling. --- .../catalog/dao/impl/CategoryDaoImplTest.java | 122 +++++++++++++++ .../catalog/dao/impl/ProductDaoImplTest.java | 145 ++++++++++++++++++ .../test/resources/category-dao-dataset.sql | 11 ++ .../test/resources/product-dao-dataset.sql | 16 ++ 4 files changed, 294 insertions(+) create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImplTest.java create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImplTest.java create mode 100644 core/catalog/src/test/resources/category-dao-dataset.sql create mode 100644 core/catalog/src/test/resources/product-dao-dataset.sql diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImplTest.java new file mode 100644 index 0000000..f7e66b9 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/CategoryDaoImplTest.java @@ -0,0 +1,122 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.catalog.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +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 CategoryDaoImpl against the real PostgreSQL, with its own fixture file category-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Testcontainers starts a throw-away PostgreSQL in Docker (shared by every test class of the JVM through +// PostgresTestContainer); @DynamicPropertySource injects its url into the Spring Environment before the context +// is built, so DataSourceConfig connects to it and Flyway migrates the REAL schema. @Sql runs this class's own +// fixture file before EACH test so every test starts from the same rows. +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/category-dao-dataset.sql") +class CategoryDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private CategoryDao categoryDao; + + @Test + void _01_ShouldReturnCategory_WhenIdExists() { + assertThat(categoryDao.findById(2L)).get().extracting(Category::getCategoryName).isEqualTo("Games"); + } + + @Test + void _02_ShouldReturnEmpty_WhenIdDoesNotExist() { + assertThat(categoryDao.findById(999L)).isEmpty(); + } + + @Test + void _03_ShouldReturnFirstPageOrderedById_WhenPageIsZero() { + assertThat(categoryDao.findPage(0, 2)).extracting(Category::getId).containsExactly(1L, 2L); + } + + @Test + void _04_ShouldApplyOffsetOfPageTimesSize_WhenPageIsOne() { + assertThat(categoryDao.findPage(1, 2)).extracting(Category::getId).containsExactly(3L); + } + + @Test + void _05_ShouldReturnEmptyList_WhenPageIsBeyondTheLastOne() { + assertThat(categoryDao.findPage(50, 10)).isEmpty(); + } + + @Test + void _06_ShouldCountEveryRow_WhenCountingAll() { + assertThat(categoryDao.countAll()).isEqualTo(3L); + } + + @Test + void _07_ShouldReportExistence_WhenCheckingByName() { + assertThat(categoryDao.existsByCategoryName("Books")).isTrue(); + assertThat(categoryDao.existsByCategoryName("Nothing")).isFalse(); + } + + @Test + void _08_ShouldGenerateIdAndPersistRow_WhenCategoryIsInserted() { + Category created = categoryDao.insert(new Category(null, "Music")); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + assertThat(categoryDao.findById(created.getId())).get().extracting(Category::getCategoryName).isEqualTo("Music"); + assertThat(categoryDao.countAll()).isEqualTo(4L); + } + + @Test + void _09_ShouldThrowDuplicateKeyException_WhenCategoryNameAlreadyExists() { + assertThatThrownBy(() -> categoryDao.insert(new Category(null, "Books"))) + .isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _10_ShouldRenameCategory_WhenUpdated() { + categoryDao.update(new Category(2L, "Video games")); + + assertThat(categoryDao.findById(2L)).get().extracting(Category::getCategoryName).isEqualTo("Video games"); + } + + @Test + void _11_ShouldThrowDuplicateKeyException_WhenRenamingToAnExistingName() { + assertThatThrownBy(() -> categoryDao.update(new Category(2L, "Books"))) + .isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _12_ShouldDeleteCategory_WhenNoProductReferencesIt() { + categoryDao.delete(1L); + + assertThat(categoryDao.findById(1L)).isEmpty(); + assertThat(categoryDao.countAll()).isEqualTo(2L); + } + + @Test + void _13_ShouldThrowDataIntegrityViolationException_WhenAProductStillReferencesTheCategory() { + assertThatThrownBy(() -> categoryDao.delete(3L)).isInstanceOf(DataIntegrityViolationException.class); + + assertThat(categoryDao.findById(3L)).isPresent(); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImplTest.java new file mode 100644 index 0000000..e85892f --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/dao/impl/ProductDaoImplTest.java @@ -0,0 +1,145 @@ +package com.edgareldy.springjdbctutorial.core.catalog.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.catalog.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +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 ProductDaoImpl against the real PostgreSQL, with its own fixture file product-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as CategoryDaoImplTest: Testcontainers PostgreSQL migrated by Flyway, url injected by +// @DynamicPropertySource, this class's own fixture loaded by @Sql before each test. +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/product-dao-dataset.sql") +class ProductDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private ProductDao productDao; + + @Test + void _01_ShouldReturnProductWithExactPrice_WhenIdExists() { + Product product = productDao.findById(1L).orElseThrow(); + + assertThat(product.getProductName()).isEqualTo("Novel"); + assertThat(product.getCategoryId()).isEqualTo(1L); + assertThat(product.getUnitPrice()).isEqualTo(new BigDecimal("12.50")); + } + + @Test + void _02_ShouldReturnEmpty_WhenIdDoesNotExist() { + assertThat(productDao.findById(999L)).isEmpty(); + } + + @Test + void _03_ShouldReturnEveryProductOrderedById_WhenNoFilterIsGiven() { + assertThat(productDao.findPage(0, 10, null)).extracting(Product::getId).containsExactly(1L, 2L, 3L, 4L); + } + + @Test + void _04_ShouldApplyOffsetOfPageTimesSize_WhenPagingWithoutFilter() { + assertThat(productDao.findPage(1, 3, null)).extracting(Product::getId).containsExactly(4L); + assertThat(productDao.findPage(9, 3, null)).isEmpty(); + } + + @Test + void _05_ShouldKeepOnlyTheProductsOfThatCategory_WhenFilteringByCategoryId() { + assertThat(productDao.findPage(0, 10, 2L)).extracting(Product::getId).containsExactly(3L, 4L); + assertThat(productDao.findPage(1, 1, 2L)).extracting(Product::getId).containsExactly(4L); + } + + @Test + void _06_ShouldReturnEmptyList_WhenFilteredCategoryHasNoProduct() { + assertThat(productDao.findPage(0, 10, 3L)).isEmpty(); + } + + @Test + void _07_ShouldCountWithAndWithoutFilter_WhenCounting() { + assertThat(productDao.count(null)).isEqualTo(4L); + assertThat(productDao.count(1L)).isEqualTo(2L); + assertThat(productDao.count(3L)).isZero(); + } + + @Test + void _08_ShouldCountTheProductsOfTheCategory_WhenCountingByCategoryId() { + assertThat(productDao.countByCategoryId(2L)).isEqualTo(2L); + assertThat(productDao.countByCategoryId(3L)).isZero(); + } + + @Test + void _09_ShouldGenerateIdAndKeepScale_WhenProductIsInserted() { + Product created = productDao.insert(new Product(null, 3L, "Lamp", new BigDecimal("12.50"))); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + Product reloaded = productDao.findById(created.getId()).orElseThrow(); + // NUMERIC(12, 2) round trip: 12.50 comes back as 12.50 (scale 2), not 12.5 + assertThat(reloaded.getUnitPrice()).isEqualTo(new BigDecimal("12.50")); + assertThat(reloaded.getUnitPrice().scale()).isEqualTo(2); + assertThat(reloaded.getCategoryId()).isEqualTo(3L); + } + + @Test + void _10_ShouldThrowDataIntegrityViolationException_WhenCategoryDoesNotExist() { + assertThatThrownBy(() -> productDao.insert(new Product(null, 999L, "Ghost", new BigDecimal("1.00")))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void _11_ShouldThrowDataIntegrityViolationException_WhenUnitPriceIsNegative() { + assertThatThrownBy(() -> productDao.insert(new Product(null, 1L, "Broken", new BigDecimal("-0.01")))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void _12_ShouldAcceptZeroPrice_WhenInserting() { + Product created = productDao.insert(new Product(null, 1L, "Free", BigDecimal.ZERO)); + + assertThat(productDao.findById(created.getId()).orElseThrow().getUnitPrice()).isEqualByComparingTo("0"); + } + + @Test + void _13_ShouldUpdateEveryColumn_WhenProductIsUpdated() { + productDao.update(new Product(1L, 2L, "Novel 2", new BigDecimal("15.75"))); + + Product reloaded = productDao.findById(1L).orElseThrow(); + assertThat(reloaded.getCategoryId()).isEqualTo(2L); + assertThat(reloaded.getProductName()).isEqualTo("Novel 2"); + assertThat(reloaded.getUnitPrice()).isEqualTo(new BigDecimal("15.75")); + } + + @Test + void _14_ShouldThrowDataIntegrityViolationException_WhenUpdatingToAnUnknownCategory() { + assertThatThrownBy(() -> productDao.update(new Product(1L, 999L, "Novel", new BigDecimal("12.50")))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void _15_ShouldDeleteProduct_WhenIdExists() { + productDao.delete(4L); + + assertThat(productDao.findById(4L)).isEmpty(); + assertThat(productDao.count(null)).isEqualTo(3L); + } +} diff --git a/core/catalog/src/test/resources/category-dao-dataset.sql b/core/catalog/src/test/resources/category-dao-dataset.sql new file mode 100644 index 0000000..d668268 --- /dev/null +++ b/core/catalog/src/test/resources/category-dao-dataset.sql @@ -0,0 +1,11 @@ +-- Fixture of CategoryDaoImplTest only. Wipes the catalog tables (products first, by cascade), then loads three +-- categories: Books (1), Games (2) and Toys (3). Category 3 has one product so the foreign key can be exercised. +TRUNCATE TABLE products, categories RESTART IDENTITY CASCADE; + +INSERT INTO categories (id, category_name) VALUES (1, 'Books'), (2, 'Games'), (3, 'Toys'); + +INSERT INTO products (id, category_id, product_name, unit_price) VALUES (1, 3, 'Robot', 19.90); + +-- Explicit ids do not advance the sequences: move them high so generated ids never collide +ALTER SEQUENCE categories_id_seq RESTART WITH 100; +ALTER SEQUENCE products_id_seq RESTART WITH 100; diff --git a/core/catalog/src/test/resources/product-dao-dataset.sql b/core/catalog/src/test/resources/product-dao-dataset.sql new file mode 100644 index 0000000..0852879 --- /dev/null +++ b/core/catalog/src/test/resources/product-dao-dataset.sql @@ -0,0 +1,16 @@ +-- Fixture of ProductDaoImplTest only. Wipes the catalog tables, then loads three categories (Books 1, Games 2 and +-- Empty 3 which holds no product) and four products: Novel (1) and Atlas (2) in Books, Chess (3) and Cards (4) +-- in Games. Ids are ordered so pagination is predictable. +TRUNCATE TABLE products, categories RESTART IDENTITY CASCADE; + +INSERT INTO categories (id, category_name) VALUES (1, 'Books'), (2, 'Games'), (3, 'Empty'); + +INSERT INTO products (id, category_id, product_name, unit_price) VALUES + (1, 1, 'Novel', 12.50), + (2, 1, 'Atlas', 30.00), + (3, 2, 'Chess', 25.99), + (4, 2, 'Cards', 0.00); + +-- Explicit ids do not advance the sequences: move them high so generated ids never collide +ALTER SEQUENCE categories_id_seq RESTART WITH 100; +ALTER SEQUENCE products_id_seq RESTART WITH 100; From c247a968c1dedc76fd38c7697bfac633516d4cb8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 103/144] test(catalog): test the catalog RowMappers and entity mappers RowMapper tests use a mocked ResultSet, including the BigDecimal unit price, and the entity mappers are checked with hand-built objects and no mocks. They pin the mapping contracts without needing a database. --- .../catalog/mapper/CategoryMapperTest.java | 46 +++++++++++++++++ .../catalog/mapper/CategoryRowMapperTest.java | 37 ++++++++++++++ .../catalog/mapper/ProductMapperTest.java | 51 +++++++++++++++++++ .../catalog/mapper/ProductRowMapperTest.java | 41 +++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapperTest.java create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapperTest.java create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapperTest.java create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapperTest.java diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapperTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapperTest.java new file mode 100644 index 0000000..4db78d1 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryMapperTest.java @@ -0,0 +1,46 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import org.junit.jupiter.api.Test; + +/** + * Tests CategoryMapper (entity to dto and back) on hand-built objects, no mocks. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CategoryMapperTest { + + private final CategoryMapper mapper = new CategoryMapper(); + + @Test + void _01_ShouldCopyIdAndName_WhenConvertingEntityToDto() { + CategoryDto dto = mapper.toDto(new Category(3L, "Books")); + + assertThat(dto.getId()).isEqualTo(3L); + assertThat(dto.getCategoryName()).isEqualTo("Books"); + } + + @Test + void _02_ShouldReturnNull_WhenEntityIsNull() { + assertThat(mapper.toDto(null)).isNull(); + } + + @Test + void _03_ShouldCopyIdAndName_WhenConvertingDtoToEntity() { + Category category = mapper.toEntity(new CategoryDto(3L, "Books")); + + assertThat(category.getId()).isEqualTo(3L); + assertThat(category.getCategoryName()).isEqualTo("Books"); + } + + @Test + void _04_ShouldReturnNull_WhenDtoIsNull() { + assertThat(mapper.toEntity(null)).isNull(); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapperTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapperTest.java new file mode 100644 index 0000000..000f240 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/CategoryRowMapperTest.java @@ -0,0 +1,37 @@ +package com.edgareldy.springjdbctutorial.core.catalog.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.catalog.entity.Category; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** + * Tests CategoryRowMapper against a mocked ResultSet, without any database. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CategoryRowMapperTest { + + private final CategoryRowMapper mapper = new CategoryRowMapper(); + + // 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(7L); + when(rs.getString("category_name")).thenReturn("Books"); + + Category category = mapper.mapRow(rs, 0); + + assertThat(category.getId()).isEqualTo(7L); + assertThat(category.getCategoryName()).isEqualTo("Books"); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapperTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapperTest.java new file mode 100644 index 0000000..e267159 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductMapperTest.java @@ -0,0 +1,51 @@ +package com.edgareldy.springjdbctutorial.core.catalog.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +/** + * Tests ProductMapper (entity to dto and back) on hand-built objects, no mocks. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class ProductMapperTest { + + private final ProductMapper mapper = new ProductMapper(); + + @Test + void _01_ShouldCopyEveryField_WhenConvertingEntityToDto() { + ProductDto dto = mapper.toDto(new Product(3L, 2L, "Chess", new BigDecimal("25.99"))); + + assertThat(dto.getId()).isEqualTo(3L); + assertThat(dto.getCategoryId()).isEqualTo(2L); + assertThat(dto.getProductName()).isEqualTo("Chess"); + assertThat(dto.getUnitPrice()).isEqualTo(new BigDecimal("25.99")); + } + + @Test + void _02_ShouldReturnNull_WhenEntityIsNull() { + assertThat(mapper.toDto(null)).isNull(); + } + + @Test + void _03_ShouldCopyEveryField_WhenConvertingDtoToEntity() { + Product product = mapper.toEntity(new ProductDto(3L, 2L, "Chess", new BigDecimal("25.99"))); + + assertThat(product.getId()).isEqualTo(3L); + assertThat(product.getCategoryId()).isEqualTo(2L); + assertThat(product.getProductName()).isEqualTo("Chess"); + assertThat(product.getUnitPrice()).isEqualTo(new BigDecimal("25.99")); + } + + @Test + void _04_ShouldReturnNull_WhenDtoIsNull() { + assertThat(mapper.toEntity(null)).isNull(); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapperTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapperTest.java new file mode 100644 index 0000000..d48ca45 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/mapper/ProductRowMapperTest.java @@ -0,0 +1,41 @@ +package com.edgareldy.springjdbctutorial.core.catalog.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.catalog.entity.Product; +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** + * Tests ProductRowMapper against a mocked ResultSet, including the BigDecimal price column. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class ProductRowMapperTest { + + private final ProductRowMapper mapper = new ProductRowMapper(); + + @Test + void _01_ShouldMapEveryColumnAndKeepThePriceScale_WhenRowIsComplete() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(4L); + when(rs.getLong("category_id")).thenReturn(2L); + when(rs.getString("product_name")).thenReturn("Chess"); + when(rs.getBigDecimal("unit_price")).thenReturn(new BigDecimal("12.50")); + + Product product = mapper.mapRow(rs, 0); + + assertThat(product.getId()).isEqualTo(4L); + assertThat(product.getCategoryId()).isEqualTo(2L); + assertThat(product.getProductName()).isEqualTo("Chess"); + assertThat(product.getUnitPrice()).isEqualTo(new BigDecimal("12.50")); + assertThat(product.getUnitPrice().scale()).isEqualTo(2); + } +} From aeee5c5d3f76fc9292f1d9f53512b61999668916 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 104/144] test(catalog): unit test the category and product services With mocked DAOs the tests cover the page bounds, the 404 cases, name trimming and length limits, duplicate names including the DuplicateKeyException race, and the refusal to delete a category that still has products, both from the count and from the foreign key race. For products they cover an unknown category, every price rule (negative, too many decimals, too large, normalised to two decimals) and the foreign key race on delete. --- .../service/impl/CategoryServiceImplTest.java | 293 ++++++++++++++++ .../service/impl/ProductServiceImplTest.java | 317 ++++++++++++++++++ 2 files changed, 610 insertions(+) create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImplTest.java create mode 100644 core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImplTest.java new file mode 100644 index 0000000..09cf379 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/CategoryServiceImplTest.java @@ -0,0 +1,293 @@ +package com.edgareldy.springjdbctutorial.core.catalog.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.Mockito.doThrow; +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.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +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.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.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; + +/** + * Unit tests of CategoryServiceImpl: both DAOs are Mockito mocks, assertions are made on the dto objects. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// MockitoExtension builds the @Mock fields before each test and fails a test whose stubbing is never used +// (strict stubs), so no test keeps a stub it does not need. No Spring context and no database here. +@ExtendWith(MockitoExtension.class) +class CategoryServiceImplTest { + + @Mock + private CategoryDao categoryDao; + @Mock + private ProductDao productDao; + + private CategoryServiceImpl service() { + return new CategoryServiceImpl(categoryDao, productDao); + } + + private static CategoryDto input(String name) { + return new CategoryDto(null, name); + } + + // ---------------------------------------------------------------- list + + @Test + void _01_ShouldReturnPageOfDtos_WhenListing() { + when(categoryDao.findPage(1, 2)).thenReturn(List.of(new Category(3L, "Toys"))); + when(categoryDao.countAll()).thenReturn(3L); + + PageDto page = service().list(1, 2); + + assertThat(page.getContent()).containsExactly(new CategoryDto(3L, "Toys")); + assertThat(page.getPage()).isEqualTo(1); + assertThat(page.getSize()).isEqualTo(2); + assertThat(page.getTotalElements()).isEqualTo(3L); + } + + @Test + void _02_ShouldReturnEmptyContent_WhenPageIsBeyondTheLastOne() { + when(categoryDao.findPage(50, 10)).thenReturn(List.of()); + when(categoryDao.countAll()).thenReturn(3L); + + PageDto page = service().list(50, 10); + + assertThat(page.getContent()).isEmpty(); + assertThat(page.getTotalElements()).isEqualTo(3L); + } + + @Test + void _03_ShouldThrowBusinessRuleException_WhenPageIsNegative() { + assertThatThrownBy(() -> service().list(-1, 10)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(categoryDao); + } + + @Test + void _04_ShouldThrowBusinessRuleException_WhenSizeIsZero() { + assertThatThrownBy(() -> service().list(0, 0)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(categoryDao); + } + + @Test + void _05_ShouldThrowBusinessRuleException_WhenSizeIsAbove100() { + assertThatThrownBy(() -> service().list(0, 101)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(categoryDao); + } + + @Test + void _06_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() { + when(categoryDao.findPage(0, 1)).thenReturn(List.of()); + when(categoryDao.findPage(0, 100)).thenReturn(List.of()); + + assertThat(service().list(0, 1).getSize()).isEqualTo(1); + assertThat(service().list(0, 100).getSize()).isEqualTo(100); + } + + // ---------------------------------------------------------------- get + + @Test + void _07_ShouldReturnDto_WhenCategoryExists() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + + assertThat(service().get(2L)).isEqualTo(new CategoryDto(2L, "Games")); + } + + @Test + void _08_ShouldThrowResourceNotFoundException_WhenCategoryDoesNotExist() { + when(categoryDao.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().get(9L)).isInstanceOf(ResourceNotFoundException.class); + } + + // ---------------------------------------------------------------- create + + @Test + void _09_ShouldInsertTrimmedNameAndReturnDto_WhenNameIsNew() { + when(categoryDao.existsByCategoryName("Books")).thenReturn(false); + when(categoryDao.insert(any(Category.class))).thenAnswer(invocation -> { + Category created = invocation.getArgument(0); + created.setId(11L); + return created; + }); + + CategoryDto created = service().create(input(" Books ")); + + assertThat(created).isEqualTo(new CategoryDto(11L, "Books")); + ArgumentCaptor saved = ArgumentCaptor.forClass(Category.class); + verify(categoryDao).insert(saved.capture()); + assertThat(saved.getValue().getCategoryName()).isEqualTo("Books"); + } + + @Test + void _10_ShouldThrowBusinessRuleException_WhenNameIsBlank() { + assertThatThrownBy(() -> service().create(input(" "))).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(categoryDao); + } + + @Test + void _11_ShouldThrowBusinessRuleException_WhenNameIsNull() { + assertThatThrownBy(() -> service().create(input(null))).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(categoryDao); + } + + @Test + void _12_ShouldThrowBusinessRuleException_WhenNameIsLongerThan100Characters() { + assertThatThrownBy(() -> service().create(input("x".repeat(101)))).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(categoryDao); + } + + @Test + void _13_ShouldAcceptName_WhenExactly100Characters() { + String name = "x".repeat(100); + when(categoryDao.existsByCategoryName(name)).thenReturn(false); + when(categoryDao.insert(any(Category.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + assertThat(service().create(input(name)).getCategoryName()).isEqualTo(name); + } + + @Test + void _14_ShouldThrowBusinessRuleExceptionWithoutInserting_WhenNameAlreadyExists() { + when(categoryDao.existsByCategoryName("Books")).thenReturn(true); + + assertThatThrownBy(() -> service().create(input("Books"))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Category name already exists"); + verify(categoryDao, never()).insert(any()); + } + + @Test + void _15_ShouldTurnDuplicateKeyExceptionIntoBusinessRuleException_WhenAConcurrentInsertWonTheRace() { + DuplicateKeyException race = new DuplicateKeyException("uq_categories_category_name"); + when(categoryDao.existsByCategoryName("Books")).thenReturn(false); + when(categoryDao.insert(any(Category.class))).thenThrow(race); + + assertThatThrownBy(() -> service().create(input("Books"))) + .isInstanceOf(BusinessRuleException.class).hasCause(race); + } + + // ---------------------------------------------------------------- update + + @Test + void _16_ShouldRenameAndReturnDto_WhenNameIsFree() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + when(categoryDao.existsByCategoryName("Video games")).thenReturn(false); + + CategoryDto updated = service().update(2L, input(" Video games ")); + + assertThat(updated).isEqualTo(new CategoryDto(2L, "Video games")); + ArgumentCaptor saved = ArgumentCaptor.forClass(Category.class); + verify(categoryDao).update(saved.capture()); + assertThat(saved.getValue().getCategoryName()).isEqualTo("Video games"); + } + + @Test + void _17_ShouldThrowResourceNotFoundException_WhenUpdatingAnUnknownCategory() { + when(categoryDao.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().update(9L, input("Books"))).isInstanceOf(ResourceNotFoundException.class); + verify(categoryDao, never()).update(any()); + } + + @Test + void _18_ShouldAllowIt_WhenTheNameIsUnchanged() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + + CategoryDto updated = service().update(2L, input("Games")); + + assertThat(updated.getCategoryName()).isEqualTo("Games"); + // The name is the category's own: no uniqueness lookup, so it never collides with itself + verify(categoryDao, never()).existsByCategoryName(any()); + verify(categoryDao).update(any(Category.class)); + } + + @Test + void _19_ShouldThrowBusinessRuleException_WhenRenamingToAnotherCategoryName() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + when(categoryDao.existsByCategoryName("Books")).thenReturn(true); + + assertThatThrownBy(() -> service().update(2L, input("Books"))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Category name already exists"); + verify(categoryDao, never()).update(any()); + } + + @Test + void _20_ShouldThrowBusinessRuleException_WhenUpdatedNameIsBlank() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + + assertThatThrownBy(() -> service().update(2L, input(" "))).isInstanceOf(BusinessRuleException.class); + verify(categoryDao, never()).update(any()); + } + + @Test + void _21_ShouldTurnDuplicateKeyExceptionIntoBusinessRuleException_WhenRenameLosesTheRace() { + DuplicateKeyException race = new DuplicateKeyException("uq_categories_category_name"); + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + when(categoryDao.existsByCategoryName("Books")).thenReturn(false); + doThrow(race).when(categoryDao).update(any(Category.class)); + + assertThatThrownBy(() -> service().update(2L, input("Books"))) + .isInstanceOf(BusinessRuleException.class).hasCause(race); + } + + // ---------------------------------------------------------------- delete + + @Test + void _22_ShouldDeleteCategory_WhenNoProductReferencesIt() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + when(productDao.countByCategoryId(2L)).thenReturn(0L); + + service().delete(2L); + + verify(categoryDao).delete(2L); + } + + @Test + void _23_ShouldThrowResourceNotFoundException_WhenDeletingAnUnknownCategory() { + when(categoryDao.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().delete(9L)).isInstanceOf(ResourceNotFoundException.class); + verify(categoryDao, never()).delete(any()); + } + + @Test + void _24_ShouldThrowBusinessRuleExceptionWithoutDeleting_WhenProductsStillReferenceTheCategory() { + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + when(productDao.countByCategoryId(2L)).thenReturn(2L); + + assertThatThrownBy(() -> service().delete(2L)) + .isInstanceOf(BusinessRuleException.class).hasMessage("Category still has products"); + verify(categoryDao, never()).delete(any()); + } + + @Test + void _25_ShouldTurnDataIntegrityViolationIntoBusinessRuleException_WhenAProductAppearsBetweenCheckAndDelete() { + DataIntegrityViolationException race = new DataIntegrityViolationException("fk_products_category"); + when(categoryDao.findById(2L)).thenReturn(Optional.of(new Category(2L, "Games"))); + when(productDao.countByCategoryId(2L)).thenReturn(0L); + doThrow(race).when(categoryDao).delete(2L); + + assertThatThrownBy(() -> service().delete(2L)) + .isInstanceOf(BusinessRuleException.class).hasCause(race); + } +} diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java new file mode 100644 index 0000000..5987705 --- /dev/null +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java @@ -0,0 +1,317 @@ +package com.edgareldy.springjdbctutorial.core.catalog.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.Mockito.doThrow; +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.catalog.dao.CategoryDao; +import com.edgareldy.springjdbctutorial.core.catalog.dao.ProductDao; +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Category; +import com.edgareldy.springjdbctutorial.core.catalog.entity.Product; +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.math.BigDecimal; +import java.util.List; +import java.util.Optional; +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.DataIntegrityViolationException; + +/** + * Unit tests of ProductServiceImpl: both DAOs are Mockito mocks, assertions are made on the dto objects. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@ExtendWith(MockitoExtension.class) +class ProductServiceImplTest { + + @Mock + private ProductDao productDao; + @Mock + private CategoryDao categoryDao; + + private ProductServiceImpl service() { + return new ProductServiceImpl(productDao, categoryDao); + } + + private static ProductDto input(Long categoryId, String name, String price) { + return new ProductDto(null, categoryId, name, price == null ? null : new BigDecimal(price)); + } + + private void categoryExists(long id) { + when(categoryDao.findById(id)).thenReturn(Optional.of(new Category(id, "Games"))); + } + + private void insertAssignsId(long id) { + when(productDao.insert(any(Product.class))).thenAnswer(invocation -> { + Product created = invocation.getArgument(0); + created.setId(id); + return created; + }); + } + + // ---------------------------------------------------------------- list + + @Test + void _01_ShouldReturnPageOfDtosAndPassTheFilter_WhenListingByCategory() { + when(productDao.findPage(0, 10, 2L)) + .thenReturn(List.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + when(productDao.count(2L)).thenReturn(1L); + + PageDto page = service().list(0, 10, 2L); + + assertThat(page.getContent()).containsExactly(new ProductDto(3L, 2L, "Chess", new BigDecimal("25.99"))); + assertThat(page.getTotalElements()).isEqualTo(1L); + } + + @Test + void _02_ShouldPassNullFilter_WhenNoCategoryIsGiven() { + when(productDao.findPage(0, 20, null)).thenReturn(List.of()); + when(productDao.count(null)).thenReturn(4L); + + PageDto page = service().list(0, 20, null); + + assertThat(page.getContent()).isEmpty(); + assertThat(page.getTotalElements()).isEqualTo(4L); + } + + @Test + void _03_ShouldThrowBusinessRuleException_WhenPageIsNegative() { + assertThatThrownBy(() -> service().list(-1, 10, null)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(productDao); + } + + @Test + void _04_ShouldThrowBusinessRuleException_WhenSizeIsZero() { + assertThatThrownBy(() -> service().list(0, 0, null)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(productDao); + } + + @Test + void _05_ShouldThrowBusinessRuleException_WhenSizeIsAbove100() { + assertThatThrownBy(() -> service().list(0, 101, null)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(productDao); + } + + @Test + void _06_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() { + when(productDao.findPage(0, 1, null)).thenReturn(List.of()); + when(productDao.findPage(0, 100, null)).thenReturn(List.of()); + + assertThat(service().list(0, 1, null).getSize()).isEqualTo(1); + assertThat(service().list(0, 100, null).getSize()).isEqualTo(100); + } + + // ---------------------------------------------------------------- get + + @Test + void _07_ShouldReturnDto_WhenProductExists() { + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + + assertThat(service().get(3L)).isEqualTo(new ProductDto(3L, 2L, "Chess", new BigDecimal("25.99"))); + } + + @Test + void _08_ShouldThrowResourceNotFoundException_WhenProductDoesNotExist() { + when(productDao.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().get(9L)).isInstanceOf(ResourceNotFoundException.class); + } + + // ---------------------------------------------------------------- create + + @Test + void _09_ShouldInsertTrimmedNameAndReturnDto_WhenInputIsValid() { + categoryExists(2L); + insertAssignsId(11L); + + ProductDto created = service().create(input(2L, " Chess ", "25.99")); + + assertThat(created).isEqualTo(new ProductDto(11L, 2L, "Chess", new BigDecimal("25.99"))); + ArgumentCaptor saved = ArgumentCaptor.forClass(Product.class); + verify(productDao).insert(saved.capture()); + assertThat(saved.getValue().getProductName()).isEqualTo("Chess"); + assertThat(saved.getValue().getCategoryId()).isEqualTo(2L); + } + + @Test + void _10_ShouldThrowResourceNotFoundException_WhenCategoryDoesNotExist() { + when(categoryDao.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().create(input(999L, "Chess", "25.99"))) + .isInstanceOf(ResourceNotFoundException.class); + verify(productDao, never()).insert(any()); + } + + @Test + void _11_ShouldThrowResourceNotFoundException_WhenCategoryIdIsNull() { + assertThatThrownBy(() -> service().create(input(null, "Chess", "25.99"))) + .isInstanceOf(ResourceNotFoundException.class); + verify(productDao, never()).insert(any()); + } + + @Test + void _12_ShouldThrowBusinessRuleException_WhenNameIsBlank() { + assertThatThrownBy(() -> service().create(input(2L, " ", "25.99"))).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(productDao); + } + + @Test + void _13_ShouldThrowBusinessRuleException_WhenNameIsLongerThan150Characters() { + assertThatThrownBy(() -> service().create(input(2L, "x".repeat(151), "25.99"))) + .isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(productDao); + } + + @Test + void _14_ShouldAcceptName_WhenExactly150Characters() { + categoryExists(2L); + insertAssignsId(11L); + + assertThat(service().create(input(2L, "x".repeat(150), "1.00")).getProductName()).hasSize(150); + } + + @Test + void _15_ShouldThrowBusinessRuleException_WhenPriceIsNull() { + assertThatThrownBy(() -> service().create(input(2L, "Chess", null))).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(productDao); + } + + @Test + void _16_ShouldThrowBusinessRuleException_WhenPriceIsNegative() { + assertThatThrownBy(() -> service().create(input(2L, "Chess", "-0.01"))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("negative"); + verifyNoInteractions(productDao); + } + + @Test + void _17_ShouldThrowBusinessRuleException_WhenPriceHasMoreThanTwoDecimals() { + assertThatThrownBy(() -> service().create(input(2L, "Chess", "1.005"))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("2 decimals"); + verifyNoInteractions(productDao); + } + + @Test + void _18_ShouldNormaliseScaleToTwo_WhenPriceHasFewerDecimals() { + categoryExists(2L); + insertAssignsId(11L); + + ProductDto created = service().create(input(2L, "Chess", "12.5")); + + assertThat(created.getUnitPrice()).isEqualTo(new BigDecimal("12.50")); + assertThat(created.getUnitPrice().scale()).isEqualTo(2); + } + + @Test + void _19_ShouldAcceptPrice_WhenExtraDecimalsAreOnlyTrailingZeros() { + categoryExists(2L); + insertAssignsId(11L); + + ProductDto created = service().create(input(2L, "Chess", "1.500")); + + assertThat(created.getUnitPrice()).isEqualTo(new BigDecimal("1.50")); + } + + @Test + void _20_ShouldAcceptZeroAndMaximumPrices_WhenOnTheBoundaries() { + categoryExists(2L); + insertAssignsId(11L); + + assertThat(service().create(input(2L, "Free", "0")).getUnitPrice()).isEqualTo(new BigDecimal("0.00")); + assertThat(service().create(input(2L, "Gold", "9999999999.99")).getUnitPrice()) + .isEqualTo(new BigDecimal("9999999999.99")); + } + + @Test + void _21_ShouldThrowBusinessRuleException_WhenPriceExceedsTheColumnCapacity() { + assertThatThrownBy(() -> service().create(input(2L, "Chess", "10000000000.00"))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("exceed"); + verifyNoInteractions(productDao); + } + + // ---------------------------------------------------------------- update + + @Test + void _22_ShouldUpdateEveryFieldAndReturnDto_WhenInputIsValid() { + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + categoryExists(1L); + + ProductDto updated = service().update(3L, input(1L, " Atlas ", "30")); + + assertThat(updated).isEqualTo(new ProductDto(3L, 1L, "Atlas", new BigDecimal("30.00"))); + ArgumentCaptor saved = ArgumentCaptor.forClass(Product.class); + verify(productDao).update(saved.capture()); + assertThat(saved.getValue().getCategoryId()).isEqualTo(1L); + assertThat(saved.getValue().getProductName()).isEqualTo("Atlas"); + } + + @Test + void _23_ShouldThrowResourceNotFoundException_WhenUpdatingAnUnknownProduct() { + when(productDao.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().update(9L, input(2L, "Chess", "1.00"))) + .isInstanceOf(ResourceNotFoundException.class); + verify(productDao, never()).update(any()); + } + + @Test + void _24_ShouldThrowResourceNotFoundException_WhenUpdatingToAnUnknownCategory() { + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + when(categoryDao.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().update(3L, input(999L, "Chess", "1.00"))) + .isInstanceOf(ResourceNotFoundException.class); + verify(productDao, never()).update(any()); + } + + @Test + void _25_ShouldThrowBusinessRuleException_WhenUpdatedPriceIsInvalid() { + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + + assertThatThrownBy(() -> service().update(3L, input(2L, "Chess", "-1"))) + .isInstanceOf(BusinessRuleException.class); + verify(productDao, never()).update(any()); + } + + // ---------------------------------------------------------------- delete + + @Test + void _26_ShouldDeleteProduct_WhenItExists() { + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + + service().delete(3L); + + verify(productDao).delete(3L); + } + + @Test + void _27_ShouldThrowResourceNotFoundException_WhenDeletingAnUnknownProduct() { + when(productDao.findById(9L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().delete(9L)).isInstanceOf(ResourceNotFoundException.class); + verify(productDao, never()).delete(any()); + } + + @Test + void _28_ShouldTurnDataIntegrityViolationIntoBusinessRuleException_WhenAnOrderReferencesTheProduct() { + DataIntegrityViolationException fk = new DataIntegrityViolationException("fk_orders_product"); + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + doThrow(fk).when(productDao).delete(3L); + + assertThatThrownBy(() -> service().delete(3L)) + .isInstanceOf(BusinessRuleException.class).hasCause(fk); + } +} From 2bfaa859cc251f67bcb782ca26f63c3879f99de4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 105/144] test(ws): test the catalog controllers with MockMvc The real security configuration and a real signed JWT are used with mocked services. Every endpoint is checked for status and JSON shape, validation errors per field including each price case, page and size violations, a page beyond the last that keeps content as an empty array, and the categoryId filter passed to the service. The 404 and 422 mappings, malformed JSON, 415 and 405 are covered too. --- .../ws/mvc/AbstractCatalogMvcTest.java | 95 ++++++ .../ws/mvc/CatalogMvcTestConfig.java | 93 +++++ .../ws/mvc/CategoryControllerTest.java | 268 +++++++++++++++ .../ws/mvc/ProductControllerTest.java | 317 ++++++++++++++++++ 4 files changed, 773 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCatalogMvcTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogMvcTestConfig.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CategoryControllerTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/ProductControllerTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCatalogMvcTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCatalogMvcTest.java new file mode 100644 index 0000000..250df27 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCatalogMvcTest.java @@ -0,0 +1,95 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.mockito.Mockito.reset; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +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; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.catalog.service.CategoryService; +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +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; + +/** + * Common base of the catalog controller tests: builds MockMvc on the shared CatalogMvcTestConfig context, resets + * the mocked services before each test and offers request helpers (signed tokens, JSON bodies). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// MockMvc + springSecurity() runs the real filter chain in front of the DispatcherServlet (see +// AbstractRbacMvcTest): the JWT filter and @PreAuthorize are exercised, only the services are mocked. +@SpringJUnitWebConfig(CatalogMvcTestConfig.class) +abstract class AbstractCatalogMvcTest { + + @Autowired + private WebApplicationContext context; + @Autowired + protected CategoryService categoryService; + @Autowired + protected ProductService productService; + @Autowired + protected AuthService authService; + + protected MockMvc mockMvc; + + @BeforeEach + void setUpMockMvc() { + reset(categoryService, productService, 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 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/CatalogMvcTestConfig.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogMvcTestConfig.java new file mode 100644 index 0000000..91f99c9 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogMvcTestConfig.java @@ -0,0 +1,93 @@ +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.catalog.service.CategoryService; +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +import com.edgareldy.springjdbctutorial.ws.config.SecurityConfig; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.controller.CategoryController; +import com.edgareldy.springjdbctutorial.ws.controller.ProductController; +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 catalog 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 CategoryService and ProductService (no database). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as RbacMvcTestConfig, kept in ws.mvc and never in ws.controller so WebMvcConfig's component scan +// cannot pick it up. The real controllers are declared as beans wired to the mocked services. +@Configuration +@EnableWebMvc +@Import(SecurityConfig.class) +public class CatalogMvcTestConfig 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 + CategoryService categoryService() { + return mock(CategoryService.class); + } + + @Bean + ProductService productService() { + return mock(ProductService.class); + } + + @Bean + CategoryController categoryController(CategoryService categoryService) { + return new CategoryController(categoryService); + } + + @Bean + ProductController productController(ProductService productService) { + return new ProductController(productService); + } + + @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/CategoryControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CategoryControllerTest.java new file mode 100644 index 0000000..28c3280 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CategoryControllerTest.java @@ -0,0 +1,268 @@ +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.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.catalog.dto.CategoryDto; +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.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * MockMvc tests of CategoryController with CategoryService mocked but the real security and validation: statuses, + * ApiResponse shape, page/size bounds, per-field body validation and the 404/422 refusals. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CategoryControllerTest extends AbstractCatalogMvcTest { + + private static final String READ = "CATEGORY:READ"; + private static final String WRITE = "CATEGORY:WRITE"; + private static final String VALID_BODY = "{\"categoryName\":\"Books\"}"; + + // ---------------------------------------------------------------- GET /categories + + @Test + void _01_ShouldReturn200WithPageAndDefaults_WhenTokenHoldsCategoryRead() throws Exception { + when(categoryService.list(0, 20)).thenReturn(new PageDto<>(List.of(new CategoryDto(1L, "Books")), 0, 20, 1)); + + JsonNode json = assertSuccess(call(getWith("/api/v1/categories", token(READ))), 200); + + JsonNode data = json.get("data"); + assertThat(data.get("content").get(0).get("id").asLong()).isEqualTo(1L); + assertThat(data.get("content").get(0).get("categoryName").asText()).isEqualTo("Books"); + 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_ShouldPassPageAndSizeToTheService_WhenTheyAreGiven() throws Exception { + when(categoryService.list(2, 5)).thenReturn(new PageDto<>(List.of(), 2, 5, 11)); + + JsonNode json = assertSuccess(call(getWith("/api/v1/categories?page=2&size=5", token(READ))), 200); + + assertThat(json.get("data").get("page").asInt()).isEqualTo(2); + assertThat(json.get("data").get("size").asInt()).isEqualTo(5); + verify(categoryService).list(2, 5); + } + + @Test + void _03_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + when(categoryService.list(99, 10)).thenReturn(new PageDto<>(List.of(), 99, 10, 3)); + + JsonNode data = assertSuccess(call(getWith("/api/v1/categories?page=99&size=10", token(READ))), 200).get("data"); + + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("totalElements").asLong()).isEqualTo(3L); + } + + @Test + void _04_ShouldReturn400NamingPage_WhenPageIsNegative() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/categories?page=-1", token(READ))), 400); + + assertThat(json.get("message").asText()).contains("page: ").doesNotContain("size: "); + verifyNoInteractions(categoryService); + } + + @Test + void _05_ShouldReturn400NamingSize_WhenSizeIsZeroOrAbove100() throws Exception { + JsonNode zero = assertError(call(getWith("/api/v1/categories?size=0", token(READ))), 400); + JsonNode big = assertError(call(getWith("/api/v1/categories?size=101", token(READ))), 400); + + assertThat(zero.get("message").asText()).contains("size: ").doesNotContain("page: "); + assertThat(big.get("message").asText()).contains("size: "); + verifyNoInteractions(categoryService); + } + + @Test + void _06_ShouldReturn400NamingBoth_WhenPageAndSizeAreInvalid() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/categories?page=-5&size=500", token(READ))), 400); + + assertThat(json.get("message").asText()).contains("page: ").contains("size: "); + } + + @Test + void _07_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() throws Exception { + when(categoryService.list(0, 1)).thenReturn(new PageDto<>(List.of(), 0, 1, 0)); + when(categoryService.list(0, 100)).thenReturn(new PageDto<>(List.of(), 0, 100, 0)); + + assertSuccess(call(getWith("/api/v1/categories?size=1", token(READ))), 200); + assertSuccess(call(getWith("/api/v1/categories?size=100", token(READ))), 200); + } + + // ---------------------------------------------------------------- GET /categories/{id} + + @Test + void _08_ShouldReturn200_WhenCategoryExists() throws Exception { + when(categoryService.get(2L)).thenReturn(new CategoryDto(2L, "Games")); + + JsonNode json = assertSuccess(call(getWith("/api/v1/categories/2", token(READ))), 200); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(2L); + assertThat(json.get("data").get("categoryName").asText()).isEqualTo("Games"); + } + + @Test + void _09_ShouldReturn404ApiResponse_WhenCategoryDoesNotExist() throws Exception { + when(categoryService.get(99L)).thenThrow(new ResourceNotFoundException("Category not found: 99")); + + JsonNode json = assertError(call(getWith("/api/v1/categories/99", token(READ))), 404); + + assertThat(json.get("message").asText()).isEqualTo("Category not found: 99"); + } + + @Test + void _10_ShouldReturn400_WhenCategoryIdIsNotANumber() throws Exception { + assertError(call(getWith("/api/v1/categories/abc", token(READ))), 400); + verifyNoInteractions(categoryService); + } + + // ---------------------------------------------------------------- POST /categories + + @Test + void _11_ShouldReturn201WithCreatedCategory_WhenBodyIsValid() throws Exception { + when(categoryService.create(any(CategoryDto.class))).thenReturn(new CategoryDto(7L, "Books")); + + JsonNode json = assertSuccess(call(postJson("/api/v1/categories", token(WRITE), VALID_BODY)), 201); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(7L); + assertThat(json.get("message").asText()).isEqualTo("Category created"); + ArgumentCaptor sent = ArgumentCaptor.forClass(CategoryDto.class); + verify(categoryService).create(sent.capture()); + assertThat(sent.getValue().getCategoryName()).isEqualTo("Books"); + } + + @Test + void _12_ShouldReturn400NamingTheField_WhenNameIsBlank() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/categories", token(WRITE), "{\"categoryName\":\" \"}")), 400); + + assertThat(json.get("message").asText()).contains("categoryName: "); + verifyNoInteractions(categoryService); + } + + @Test + void _13_ShouldReturn400NamingTheField_WhenNameIsMissingOrTooLong() throws Exception { + JsonNode missing = assertError(call(postJson("/api/v1/categories", token(WRITE), "{}")), 400); + JsonNode tooLong = assertError(call(postJson("/api/v1/categories", token(WRITE), + "{\"categoryName\":\"" + "x".repeat(101) + "\"}")), 400); + + assertThat(missing.get("message").asText()).contains("categoryName: "); + assertThat(tooLong.get("message").asText()).contains("categoryName: "); + verifyNoInteractions(categoryService); + } + + @Test + void _14_ShouldReturn400_WhenBodyIsMalformedJson() throws Exception { + assertError(call(postJson("/api/v1/categories", token(WRITE), "{\"categoryName\":")), 400); + verifyNoInteractions(categoryService); + } + + @Test + void _15_ShouldReturn422_WhenServiceRefusesADuplicateName() throws Exception { + when(categoryService.create(any(CategoryDto.class))) + .thenThrow(new BusinessRuleException("Category name already exists")); + + JsonNode json = assertError(call(postJson("/api/v1/categories", token(WRITE), VALID_BODY)), 422); + + assertThat(json.get("message").asText()).isEqualTo("Category name already exists"); + } + + @Test + void _16_ShouldReturn415_WhenContentTypeIsNotJson() throws Exception { + assertError(call(MockMvcRequestBuilders.post("/api/v1/categories") + .header("Authorization", bearer(token(WRITE))).contentType(MediaType.TEXT_PLAIN).content("Books")), 415); + verifyNoInteractions(categoryService); + } + + // ---------------------------------------------------------------- PUT /categories/{id} + + @Test + void _17_ShouldReturn200WithUpdatedCategory_WhenBodyIsValid() throws Exception { + when(categoryService.update(eq(2L), any(CategoryDto.class))).thenReturn(new CategoryDto(2L, "Books")); + + JsonNode json = assertSuccess(call(putJson("/api/v1/categories/2", token(WRITE), VALID_BODY)), 200); + + assertThat(json.get("data").get("categoryName").asText()).isEqualTo("Books"); + assertThat(json.get("message").asText()).isEqualTo("Category updated"); + } + + @Test + void _18_ShouldReturn404ApiResponse_WhenUpdatingAnUnknownCategory() throws Exception { + when(categoryService.update(eq(99L), any(CategoryDto.class))) + .thenThrow(new ResourceNotFoundException("Category not found: 99")); + + assertError(call(putJson("/api/v1/categories/99", token(WRITE), VALID_BODY)), 404); + } + + @Test + void _19_ShouldReturn422_WhenRenamingToAnExistingName() throws Exception { + when(categoryService.update(eq(2L), any(CategoryDto.class))) + .thenThrow(new BusinessRuleException("Category name already exists")); + + assertError(call(putJson("/api/v1/categories/2", token(WRITE), VALID_BODY)), 422); + } + + @Test + void _20_ShouldReturn400_WhenUpdatedNameIsBlank() throws Exception { + JsonNode json = assertError(call(putJson("/api/v1/categories/2", token(WRITE), "{\"categoryName\":\"\"}")), 400); + + assertThat(json.get("message").asText()).contains("categoryName: "); + verifyNoInteractions(categoryService); + } + + // ---------------------------------------------------------------- DELETE /categories/{id} + + @Test + void _21_ShouldReturn200WithoutData_WhenCategoryIsDeleted() throws Exception { + JsonNode json = assertSuccess(call(deleteWith("/api/v1/categories/2", token(WRITE))), 200); + + assertThat(json.get("message").asText()).isEqualTo("Category deleted"); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + verify(categoryService).delete(2L); + } + + @Test + void _22_ShouldReturn404ApiResponse_WhenDeletingAnUnknownCategory() throws Exception { + doThrow(new ResourceNotFoundException("Category not found: 99")) + .when(categoryService).delete(99L); + + assertError(call(deleteWith("/api/v1/categories/99", token(WRITE))), 404); + } + + @Test + void _23_ShouldReturn422_WhenCategoryStillHasProducts() throws Exception { + doThrow(new BusinessRuleException("Category still has products")) + .when(categoryService).delete(2L); + + JsonNode json = assertError(call(deleteWith("/api/v1/categories/2", token(WRITE))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Category still has products"); + } + + // ---------------------------------------------------------------- unsupported method + + @Test + void _24_ShouldReturn405ApiResponse_WhenPatchIsUsedOnACategory() throws Exception { + assertError(call(patchWith("/api/v1/categories/2", token(WRITE))), 405); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/ProductControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/ProductControllerTest.java new file mode 100644 index 0000000..9ef2a61 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/ProductControllerTest.java @@ -0,0 +1,317 @@ +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.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.catalog.dto.ProductDto; +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.fasterxml.jackson.databind.JsonNode; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * MockMvc tests of ProductController with ProductService mocked but the real security and validation: statuses, + * ApiResponse shape, page/size bounds, the categoryId filter, per-field body validation (price rules included) + * and the 404/422 refusals. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class ProductControllerTest extends AbstractCatalogMvcTest { + + private static final String READ = "PRODUCT:READ"; + private static final String WRITE = "PRODUCT:WRITE"; + private static final String VALID_BODY = "{\"categoryId\":2,\"productName\":\"Chess\",\"unitPrice\":25.99}"; + + private static ProductDto chess() { + return new ProductDto(3L, 2L, "Chess", new BigDecimal("25.99")); + } + + private static String body(String categoryId, String name, String price) { + return "{\"categoryId\":" + categoryId + ",\"productName\":" + name + ",\"unitPrice\":" + price + "}"; + } + + // ---------------------------------------------------------------- GET /products + + @Test + void _01_ShouldReturn200WithPageAndDefaults_WhenTokenHoldsProductRead() throws Exception { + when(productService.list(0, 20, null)).thenReturn(new PageDto<>(List.of(chess()), 0, 20, 1)); + + JsonNode data = assertSuccess(call(getWith("/api/v1/products", token(READ))), 200).get("data"); + + JsonNode first = data.get("content").get(0); + assertThat(first.get("id").asLong()).isEqualTo(3L); + assertThat(first.get("categoryId").asLong()).isEqualTo(2L); + assertThat(first.get("productName").asText()).isEqualTo("Chess"); + assertThat(first.get("unitPrice").decimalValue()).isEqualByComparingTo("25.99"); + assertThat(data.get("page").asInt()).isZero(); + assertThat(data.get("size").asInt()).isEqualTo(20); + assertThat(data.get("totalElements").asLong()).isEqualTo(1L); + } + + @Test + void _02_ShouldPassTheCategoryIdFilterToTheService_WhenItIsGiven() throws Exception { + when(productService.list(1, 5, 2L)).thenReturn(new PageDto<>(List.of(chess()), 1, 5, 6)); + + assertSuccess(call(getWith("/api/v1/products?page=1&size=5&categoryId=2", token(READ))), 200); + + verify(productService).list(1, 5, 2L); + } + + @Test + void _03_ShouldPassNullFilter_WhenCategoryIdIsAbsent() throws Exception { + when(productService.list(0, 20, null)).thenReturn(new PageDto<>(List.of(), 0, 20, 0)); + + assertSuccess(call(getWith("/api/v1/products", token(READ))), 200); + + verify(productService).list(0, 20, null); + } + + @Test + void _04_ShouldReturn400_WhenCategoryIdIsNotANumber() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/products?categoryId=abc", token(READ))), 400); + + assertThat(json.get("message").asText()).isEqualTo("Invalid value for parameter: categoryId"); + verifyNoInteractions(productService); + } + + @Test + void _05_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + when(productService.list(99, 10, null)).thenReturn(new PageDto<>(List.of(), 99, 10, 4)); + + JsonNode data = assertSuccess(call(getWith("/api/v1/products?page=99&size=10", token(READ))), 200).get("data"); + + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("totalElements").asLong()).isEqualTo(4L); + } + + @Test + void _06_ShouldReturn400NamingPage_WhenPageIsNegative() throws Exception { + JsonNode json = assertError(call(getWith("/api/v1/products?page=-1", token(READ))), 400); + + assertThat(json.get("message").asText()).contains("page: ").doesNotContain("size: "); + verifyNoInteractions(productService); + } + + @Test + void _07_ShouldReturn400NamingSize_WhenSizeIsZeroOrAbove100() throws Exception { + JsonNode zero = assertError(call(getWith("/api/v1/products?size=0", token(READ))), 400); + JsonNode big = assertError(call(getWith("/api/v1/products?size=101", token(READ))), 400); + + assertThat(zero.get("message").asText()).contains("size: ").doesNotContain("page: "); + assertThat(big.get("message").asText()).contains("size: "); + verifyNoInteractions(productService); + } + + @Test + void _08_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() throws Exception { + when(productService.list(0, 1, null)).thenReturn(new PageDto<>(List.of(), 0, 1, 0)); + when(productService.list(0, 100, null)).thenReturn(new PageDto<>(List.of(), 0, 100, 0)); + + assertSuccess(call(getWith("/api/v1/products?size=1", token(READ))), 200); + assertSuccess(call(getWith("/api/v1/products?size=100", token(READ))), 200); + } + + // ---------------------------------------------------------------- GET /products/{id} + + @Test + void _09_ShouldReturn200_WhenProductExists() throws Exception { + when(productService.get(3L)).thenReturn(chess()); + + JsonNode json = assertSuccess(call(getWith("/api/v1/products/3", token(READ))), 200); + + assertThat(json.get("data").get("productName").asText()).isEqualTo("Chess"); + } + + @Test + void _10_ShouldReturn404ApiResponse_WhenProductDoesNotExist() throws Exception { + when(productService.get(99L)).thenThrow(new ResourceNotFoundException("Product not found: 99")); + + JsonNode json = assertError(call(getWith("/api/v1/products/99", token(READ))), 404); + + assertThat(json.get("message").asText()).isEqualTo("Product not found: 99"); + } + + // ---------------------------------------------------------------- POST /products + + @Test + void _11_ShouldReturn201WithCreatedProduct_WhenBodyIsValid() throws Exception { + when(productService.create(any(ProductDto.class))).thenReturn(chess()); + + JsonNode json = assertSuccess(call(postJson("/api/v1/products", token(WRITE), VALID_BODY)), 201); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(3L); + assertThat(json.get("message").asText()).isEqualTo("Product created"); + ArgumentCaptor sent = ArgumentCaptor.forClass(ProductDto.class); + verify(productService).create(sent.capture()); + assertThat(sent.getValue().getCategoryId()).isEqualTo(2L); + assertThat(sent.getValue().getProductName()).isEqualTo("Chess"); + assertThat(sent.getValue().getUnitPrice()).isEqualByComparingTo("25.99"); + } + + @Test + void _12_ShouldReturn400NamingEveryField_WhenBodyIsEmpty() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), "{}")), 400); + + assertThat(json.get("message").asText()).contains("categoryId: ").contains("productName: ") + .contains("unitPrice: "); + verifyNoInteractions(productService); + } + + @Test + void _13_ShouldReturn400NamingOnlyProductName_WhenNameIsBlankOrTooLong() throws Exception { + JsonNode blank = assertError(call(postJson("/api/v1/products", token(WRITE), body("2", "\" \"", "1.00"))), 400); + JsonNode tooLong = assertError(call(postJson("/api/v1/products", token(WRITE), + body("2", "\"" + "x".repeat(151) + "\"", "1.00"))), 400); + + assertThat(blank.get("message").asText()).contains("productName: ").doesNotContain("unitPrice: "); + assertThat(tooLong.get("message").asText()).contains("productName: ").doesNotContain("unitPrice: "); + verifyNoInteractions(productService); + } + + @Test + void _14_ShouldReturn400NamingUnitPrice_WhenPriceIsNegative() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Chess\"", "-0.01"))), 400); + + assertThat(json.get("message").asText()).contains("unitPrice: ").doesNotContain("productName: "); + verifyNoInteractions(productService); + } + + @Test + void _15_ShouldReturn400NamingUnitPrice_WhenPriceHasThreeDecimals() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Chess\"", "1.005"))), 400); + + assertThat(json.get("message").asText()).contains("unitPrice: "); + verifyNoInteractions(productService); + } + + @Test + void _16_ShouldReturn400NamingUnitPrice_WhenPriceHasMoreThanTenIntegerDigits() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), + body("2", "\"Chess\"", "10000000000.00"))), 400); + + assertThat(json.get("message").asText()).contains("unitPrice: "); + verifyNoInteractions(productService); + } + + @Test + void _17_ShouldAcceptTheMaximumPriceAndZero_WhenOnTheBoundaries() throws Exception { + when(productService.create(any(ProductDto.class))).thenReturn(chess()); + + assertSuccess(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Gold\"", "9999999999.99"))), 201); + assertSuccess(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Free\"", "0"))), 201); + } + + @Test + void _18_ShouldReturn400_WhenPriceIsNotANumber() throws Exception { + assertError(call(postJson("/api/v1/products", token(WRITE), body("2", "\"Chess\"", "\"abc\""))), 400); + verifyNoInteractions(productService); + } + + @Test + void _19_ShouldReturn404_WhenServiceReportsAnUnknownCategory() throws Exception { + when(productService.create(any(ProductDto.class))) + .thenThrow(new ResourceNotFoundException("Category not found: 2")); + + JsonNode json = assertError(call(postJson("/api/v1/products", token(WRITE), VALID_BODY)), 404); + + assertThat(json.get("message").asText()).isEqualTo("Category not found: 2"); + } + + @Test + void _20_ShouldReturn415_WhenContentTypeIsNotJson() throws Exception { + assertError(call(MockMvcRequestBuilders.post("/api/v1/products") + .header("Authorization", bearer(token(WRITE))).contentType(MediaType.TEXT_PLAIN).content("Chess")), 415); + verifyNoInteractions(productService); + } + + // ---------------------------------------------------------------- PUT /products/{id} + + @Test + void _21_ShouldReturn200WithUpdatedProduct_WhenBodyIsValid() throws Exception { + when(productService.update(eq(3L), any(ProductDto.class))).thenReturn(chess()); + + JsonNode json = assertSuccess(call(putJson("/api/v1/products/3", token(WRITE), VALID_BODY)), 200); + + assertThat(json.get("message").asText()).isEqualTo("Product updated"); + assertThat(json.get("data").get("unitPrice").decimalValue()).isEqualByComparingTo("25.99"); + } + + @Test + void _22_ShouldReturn404_WhenUpdatingAnUnknownProductOrCategory() throws Exception { + when(productService.update(eq(99L), any(ProductDto.class))) + .thenThrow(new ResourceNotFoundException("Product not found: 99")); + when(productService.update(eq(3L), any(ProductDto.class))) + .thenThrow(new ResourceNotFoundException("Category not found: 2")); + + assertError(call(putJson("/api/v1/products/99", token(WRITE), VALID_BODY)), 404); + assertError(call(putJson("/api/v1/products/3", token(WRITE), VALID_BODY)), 404); + } + + @Test + void _23_ShouldReturn422_WhenServiceRefusesTheUpdate() throws Exception { + when(productService.update(eq(3L), any(ProductDto.class))) + .thenThrow(new BusinessRuleException("Unit price must not be negative")); + + assertError(call(putJson("/api/v1/products/3", token(WRITE), VALID_BODY)), 422); + } + + @Test + void _24_ShouldReturn400_WhenUpdatedBodyIsInvalid() throws Exception { + JsonNode json = assertError(call(putJson("/api/v1/products/3", token(WRITE), body("null", "\"\"", "-1"))), 400); + + assertThat(json.get("message").asText()).contains("categoryId: ").contains("productName: ") + .contains("unitPrice: "); + verifyNoInteractions(productService); + } + + // ---------------------------------------------------------------- DELETE /products/{id} + + @Test + void _25_ShouldReturn200WithoutData_WhenProductIsDeleted() throws Exception { + JsonNode json = assertSuccess(call(deleteWith("/api/v1/products/3", token(WRITE))), 200); + + assertThat(json.get("message").asText()).isEqualTo("Product deleted"); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + verify(productService).delete(3L); + } + + @Test + void _26_ShouldReturn404ApiResponse_WhenDeletingAnUnknownProduct() throws Exception { + doThrow(new ResourceNotFoundException("Product not found: 99")).when(productService).delete(99L); + + assertError(call(deleteWith("/api/v1/products/99", token(WRITE))), 404); + } + + @Test + void _27_ShouldReturn422_WhenAnOrderReferencesTheProduct() throws Exception { + doThrow(new BusinessRuleException("Product is referenced by orders")).when(productService).delete(3L); + + JsonNode json = assertError(call(deleteWith("/api/v1/products/3", token(WRITE))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Product is referenced by orders"); + } + + // ---------------------------------------------------------------- unsupported method + + @Test + void _28_ShouldReturn405ApiResponse_WhenPatchIsUsedOnAProduct() throws Exception { + assertError(call(patchWith("/api/v1/products/3", token(WRITE))), 405); + } +} From 84fb7dd15e9f02ba493877c699e54ff8af18a632 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 106/144] test(ws): check the permission matrix of the 10 catalog endpoints A parameterized test asserts, for every endpoint, that no token gives 401, that a token holding every other permission or none gives 403, that a blacklisted token gives 401, and that a token holding only the required permission succeeds. It proves each route is guarded by its own CATEGORY or PRODUCT permission. --- .../ws/mvc/CatalogEndpointSecurityTest.java | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogEndpointSecurityTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogEndpointSecurityTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogEndpointSecurityTest.java new file mode 100644 index 0000000..e220d54 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CatalogEndpointSecurityTest.java @@ -0,0 +1,135 @@ +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.CategoryDto; +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigDecimal; +import java.util.List; +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 10 catalog endpoints of the README table, checked through the real filter chain and + * method security: no token gives 401, a revoked token gives 401, a token holding every permission EXCEPT the + * required one (or none at all) gives 403, all as ApiResponse errors and without ever reaching the (mocked) + * services; the right permission alone gives 2xx. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CatalogEndpointSecurityTest extends AbstractCatalogMvcTest { + + private static final String CATEGORY_BODY = "{\"categoryName\":\"Books\"}"; + private static final String PRODUCT_BODY = "{\"categoryId\":1,\"productName\":\"Novel\",\"unitPrice\":12.50}"; + + // 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/categories", null, "CATEGORY:READ"), + Arguments.of(HttpMethod.GET, "/api/v1/categories/1", null, "CATEGORY:READ"), + Arguments.of(HttpMethod.POST, "/api/v1/categories", CATEGORY_BODY, "CATEGORY:WRITE"), + Arguments.of(HttpMethod.PUT, "/api/v1/categories/1", CATEGORY_BODY, "CATEGORY:WRITE"), + Arguments.of(HttpMethod.DELETE, "/api/v1/categories/1", null, "CATEGORY:WRITE"), + Arguments.of(HttpMethod.GET, "/api/v1/products", null, "PRODUCT:READ"), + Arguments.of(HttpMethod.GET, "/api/v1/products/1", null, "PRODUCT:READ"), + Arguments.of(HttpMethod.POST, "/api/v1/products", PRODUCT_BODY, "PRODUCT:WRITE"), + Arguments.of(HttpMethod.PUT, "/api/v1/products/1", PRODUCT_BODY, "PRODUCT:WRITE"), + Arguments.of(HttpMethod.DELETE, "/api/v1/products/1", null, "PRODUCT: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(categoryService, productService); + } + + @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(categoryService, productService); + } + + @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(categoryService, productService); + } + + @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(categoryService, productService); + } + + @ParameterizedTest(name = "{0} {1} with {3}") + @MethodSource("endpoints") + void _05_ShouldNotReturn401Or403_WhenTokenHoldsOnlyTheRequiredPermission(HttpMethod method, String url, + String body, String required) + throws Exception { + // Mocks return null for get/create/update, which would make the controller fail on the converter, so + // give the read paths a real answer; the point of this test is only that the permission opens the door. + when(categoryService.list(0, 20)).thenReturn(new PageDto(List.of(), 0, 20, 0)); + when(categoryService.get(1L)).thenReturn(new CategoryDto(1L, "Books")); + when(categoryService.create(any())).thenReturn(new CategoryDto(1L, "Books")); + when(categoryService.update(eq(1L), any())) + .thenReturn(new CategoryDto(1L, "Books")); + ProductDto product = new ProductDto(1L, 1L, "Novel", new BigDecimal("12.50")); + when(productService.list(0, 20, null)).thenReturn(new PageDto(List.of(), 0, 20, 0)); + when(productService.get(1L)).thenReturn(product); + when(productService.create(any())).thenReturn(product); + when(productService.update(eq(1L), any())) + .thenReturn(product); + + MvcResult result = call(request(method, url, body, token(required))); + + assertThat(result.getResponse().getStatus()).isIn(200, 201); + } +} From 1e85f54b7abe8c6c00a19aaead9897a452e7c3c1 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 107/144] test(ws): run the catalog flows on the real stack Real services and a real database prove the CRUD of categories and products, the category filter, the refusal to delete a category with products followed by success once the product is gone, an unknown category giving 404 with nothing stored, duplicate names giving 422 and the exact stored price. Users holding only CATEGORY:WRITE or only PRODUCT:WRITE, a read-only role and a user without permission are checked for 403. Test rows are named with a random suffix and removed afterwards, so the shared database stays clean. --- .../CatalogFlowIntegrationTest.java | 385 ++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CatalogFlowIntegrationTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CatalogFlowIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CatalogFlowIntegrationTest.java new file mode 100644 index 0000000..fb189c8 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CatalogFlowIntegrationTest.java @@ -0,0 +1,385 @@ +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.TestUser; +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +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.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * End-to-end catalog flows through the REAL stack (real WebMvcConfig, real services, real PostgreSQL migrated by + * Flyway, real logins): category and product CRUD, the categoryId filter, referenced deletes, unknown categories, + * duplicate names, price edge cases, pagination bounds and permission enforcement. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as RbacFlowIntegrationTest: the whole application context on the shared Testcontainers database. +// Every category and product a test creates is named it-, and tearDown() removes them (plus the users and +// roles of RbacTestSupport), so the classes sharing the one database never see each other's rows. The catalog +// domain has no last-admin style global rule, so no isolation of other holders is needed here. +@SpringJUnitWebConfig(WebMvcConfig.class) +class CatalogFlowIntegrationTest { + + private static final String PREFIX = "it-"; + + @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); + } + + @AfterEach + void tearDown() { + // Products first: they reference the categories + jdbcTemplate.update("DELETE FROM products WHERE product_name LIKE ?", PREFIX + "%"); + jdbcTemplate.update("DELETE FROM categories WHERE category_name LIKE ?", PREFIX + "%"); + support.close(); + } + + private static String unique() { + return PREFIX + UUID.randomUUID().toString().substring(0, 8); + } + + private static String categoryBody(String name) { + return "{\"categoryName\":\"" + name + "\"}"; + } + + private static String productBody(long categoryId, String name, String price) { + return "{\"categoryId\":" + categoryId + ",\"productName\":\"" + name + "\",\"unitPrice\":" + price + "}"; + } + + private static long idOf(MvcResult result) throws Exception { + return assertSuccess(result, 201).get("data").get("id").asLong(); + } + + private long createCategory(String jwt, String name) throws Exception { + return idOf(support.post(jwt, "/api/v1/categories", categoryBody(name))); + } + + private long createProduct(String jwt, long categoryId, String name, String price) throws Exception { + return idOf(support.post(jwt, "/api/v1/products", productBody(categoryId, name, price))); + } + + private long count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Long.class, args); + } + + private List productNames(JsonNode page) { + List names = new ArrayList<>(); + for (JsonNode product : page.get("data").get("content")) { + names.add(product.get("productName").asText()); + } + return names; + } + + // ---------------------------------------------------------------- categories + + @Test + void _01_ShouldCreateReadUpdateListAndDeleteACategory_WhenAdminRunsTheFullCrud() throws Exception { + String jwt = support.createAdmin().getJwt(); + String name = unique(); + + long id = createCategory(jwt, " " + name + " "); + + // The stored name is trimmed + JsonNode read = assertSuccess(support.get(jwt, "/api/v1/categories/" + id), 200); + assertThat(read.get("data").get("categoryName").asText()).isEqualTo(name); + + String renamed = unique(); + JsonNode updated = assertSuccess(support.put(jwt, "/api/v1/categories/" + id, categoryBody(renamed)), 200); + assertThat(updated.get("data").get("categoryName").asText()).isEqualTo(renamed); + assertThat(count("SELECT COUNT(*) FROM categories WHERE category_name = ?", renamed)).isEqualTo(1L); + + JsonNode list = assertSuccess(support.get(jwt, "/api/v1/categories?size=100"), 200); + boolean listed = false; + for (JsonNode category : list.get("data").get("content")) { + listed |= category.get("id").asLong() == id; + } + assertThat(listed).isTrue(); + + JsonNode deleted = assertSuccess(support.delete(jwt, "/api/v1/categories/" + id), 200); + assertThat(deleted.get("data") == null || deleted.get("data").isNull()).isTrue(); + assertError(support.get(jwt, "/api/v1/categories/" + id), 404); + } + + @Test + void _02_ShouldReturn422_WhenCategoryNameIsDuplicatedOnCreateOrRename() throws Exception { + String jwt = support.createAdmin().getJwt(); + String first = unique(); + long firstId = createCategory(jwt, first); + long secondId = createCategory(jwt, unique()); + + JsonNode onCreate = assertError(support.post(jwt, "/api/v1/categories", categoryBody(first)), 422); + JsonNode onRename = assertError(support.put(jwt, "/api/v1/categories/" + secondId, categoryBody(first)), 422); + + assertThat(onCreate.get("message").asText()).isEqualTo("Category name already exists"); + assertThat(onRename.get("message").asText()).isEqualTo("Category name already exists"); + // Saving a category under its own name is not a duplicate + assertSuccess(support.put(jwt, "/api/v1/categories/" + firstId, categoryBody(first)), 200); + assertThat(count("SELECT COUNT(*) FROM categories WHERE category_name = ?", first)).isEqualTo(1L); + } + + @Test + void _03_ShouldRefuseTheDeleteWith422ThenSucceed_WhenTheCategoryHasProductsUntilTheyAreDeleted() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryId = createCategory(jwt, unique()); + long productId = createProduct(jwt, categoryId, unique(), "5.00"); + + JsonNode refused = assertError(support.delete(jwt, "/api/v1/categories/" + categoryId), 422); + + assertThat(refused.get("message").asText()).isEqualTo("Category still has products"); + assertSuccess(support.get(jwt, "/api/v1/categories/" + categoryId), 200); + + assertSuccess(support.delete(jwt, "/api/v1/products/" + productId), 200); + assertSuccess(support.delete(jwt, "/api/v1/categories/" + categoryId), 200); + assertError(support.get(jwt, "/api/v1/categories/" + categoryId), 404); + } + + @Test + void _04_ShouldReturn404_WhenCategoryDoesNotExist() throws Exception { + String jwt = support.createAdmin().getJwt(); + + assertError(support.get(jwt, "/api/v1/categories/999999999"), 404); + assertError(support.put(jwt, "/api/v1/categories/999999999", categoryBody(unique())), 404); + assertError(support.delete(jwt, "/api/v1/categories/999999999"), 404); + } + + // ---------------------------------------------------------------- products + + @Test + void _05_ShouldCreateReadUpdateAndDeleteAProductWithExactPrice_WhenAdminRunsTheFullCrud() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryId = createCategory(jwt, unique()); + long otherCategoryId = createCategory(jwt, unique()); + String name = unique(); + + MvcResult created = support.post(jwt, "/api/v1/products", productBody(categoryId, " " + name + " ", "12.5")); + long id = idOf(created); + + // The price comes back with two decimals, and the database holds exactly 12.50 + assertThat(created.getResponse().getContentAsString()).contains("\"unitPrice\":12.50"); + assertThat(jdbcTemplate.queryForObject("SELECT unit_price FROM products WHERE id = ?", BigDecimal.class, id)) + .isEqualTo(new BigDecimal("12.50")); + JsonNode read = assertSuccess(support.get(jwt, "/api/v1/products/" + id), 200); + assertThat(read.get("data").get("productName").asText()).isEqualTo(name); + assertThat(read.get("data").get("categoryId").asLong()).isEqualTo(categoryId); + + String renamed = unique(); + JsonNode updated = assertSuccess(support.put(jwt, "/api/v1/products/" + id, + productBody(otherCategoryId, renamed, "1.5")), 200); + assertThat(updated.get("data").get("categoryId").asLong()).isEqualTo(otherCategoryId); + assertThat(updated.get("data").get("productName").asText()).isEqualTo(renamed); + assertThat(updated.get("data").get("unitPrice").decimalValue()).isEqualByComparingTo("1.50"); + + assertSuccess(support.delete(jwt, "/api/v1/products/" + id), 200); + assertError(support.get(jwt, "/api/v1/products/" + id), 404); + } + + @Test + void _06_ShouldReturn404AndStoreNothing_WhenProductTargetsAnUnknownCategory() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryId = createCategory(jwt, unique()); + String name = unique(); + long id = createProduct(jwt, categoryId, unique(), "3.00"); + + JsonNode onCreate = assertError(support.post(jwt, "/api/v1/products", productBody(999999999L, name, "3.00")), 404); + assertError(support.put(jwt, "/api/v1/products/" + id, productBody(999999999L, name, "3.00")), 404); + + assertThat(onCreate.get("message").asText()).contains("Category not found"); + assertThat(count("SELECT COUNT(*) FROM products WHERE product_name = ?", name)).isZero(); + // The failed update left the product on its category + assertThat(count("SELECT category_id FROM products WHERE id = ?", id)).isEqualTo(categoryId); + } + + @Test + void _07_ShouldReturn404_WhenProductDoesNotExist() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryId = createCategory(jwt, unique()); + + assertError(support.get(jwt, "/api/v1/products/999999999"), 404); + assertError(support.put(jwt, "/api/v1/products/999999999", productBody(categoryId, unique(), "1.00")), 404); + assertError(support.delete(jwt, "/api/v1/products/999999999"), 404); + } + + @Test + void _08_ShouldKeepOnlyTheProductsOfTheCategory_WhenFilteringByCategoryId() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryA = createCategory(jwt, unique()); + long categoryB = createCategory(jwt, unique()); + long emptyCategory = createCategory(jwt, unique()); + String a1 = unique(); + String a2 = unique(); + String b1 = unique(); + createProduct(jwt, categoryA, a1, "1.00"); + createProduct(jwt, categoryA, a2, "2.00"); + createProduct(jwt, categoryB, b1, "3.00"); + + JsonNode filteredA = assertSuccess(support.get(jwt, "/api/v1/products?size=100&categoryId=" + categoryA), 200); + JsonNode pagedA = assertSuccess(support.get(jwt, "/api/v1/products?size=1&page=1&categoryId=" + categoryA), 200); + JsonNode filteredB = assertSuccess(support.get(jwt, "/api/v1/products?categoryId=" + categoryB), 200); + JsonNode filteredEmpty = assertSuccess(support.get(jwt, "/api/v1/products?categoryId=" + emptyCategory), 200); + + assertThat(productNames(filteredA)).containsExactly(a1, a2); + assertThat(filteredA.get("data").get("totalElements").asLong()).isEqualTo(2L); + assertThat(productNames(pagedA)).containsExactly(a2); + assertThat(pagedA.get("data").get("totalElements").asLong()).isEqualTo(2L); + assertThat(pagedA.get("data").get("totalPages").asInt()).isEqualTo(2); + assertThat(productNames(filteredB)).containsExactly(b1); + assertThat(filteredEmpty.get("data").get("content")).isEmpty(); + assertThat(filteredEmpty.get("data").get("totalElements").asLong()).isZero(); + } + + @Test + void _09_ShouldRejectBadPricesWith400AndAcceptTheBoundaries_WhenPricesAreSentThroughHttp() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryId = createCategory(jwt, unique()); + String name = unique(); + + JsonNode negative = assertError(support.post(jwt, "/api/v1/products", productBody(categoryId, name, "-0.01")), 400); + JsonNode threeDecimals = assertError(support.post(jwt, "/api/v1/products", productBody(categoryId, name, "1.005")), 400); + JsonNode huge = assertError(support.post(jwt, "/api/v1/products", productBody(categoryId, name, "10000000000.00")), 400); + JsonNode missing = assertError(support.post(jwt, "/api/v1/products", + "{\"categoryId\":" + categoryId + ",\"productName\":\"" + name + "\"}"), 400); + + assertThat(negative.get("message").asText()).contains("unitPrice: "); + assertThat(threeDecimals.get("message").asText()).contains("unitPrice: "); + assertThat(huge.get("message").asText()).contains("unitPrice: "); + assertThat(missing.get("message").asText()).contains("unitPrice: "); + assertThat(count("SELECT COUNT(*) FROM products WHERE product_name = ?", name)).isZero(); + + // The boundaries themselves are valid: zero, and the largest NUMERIC(12, 2) value + long free = createProduct(jwt, categoryId, unique(), "0"); + long gold = createProduct(jwt, categoryId, unique(), "9999999999.99"); + assertThat(jdbcTemplate.queryForObject("SELECT unit_price FROM products WHERE id = ?", BigDecimal.class, free)) + .isEqualByComparingTo("0.00"); + assertThat(jdbcTemplate.queryForObject("SELECT unit_price FROM products WHERE id = ?", BigDecimal.class, gold)) + .isEqualByComparingTo("9999999999.99"); + } + + // ---------------------------------------------------------------- pagination + + @Test + void _10_ShouldReturn400NamingTheParameter_WhenPageOrSizeIsOutOfBounds() throws Exception { + String jwt = support.createAdmin().getJwt(); + + for (String base : List.of("/api/v1/categories", "/api/v1/products")) { + JsonNode page = assertError(support.get(jwt, base + "?page=-1"), 400); + JsonNode zero = assertError(support.get(jwt, base + "?size=0"), 400); + JsonNode big = assertError(support.get(jwt, base + "?size=101"), 400); + + assertThat(page.get("message").asText()).contains("page: "); + assertThat(zero.get("message").asText()).contains("size: "); + assertThat(big.get("message").asText()).contains("size: "); + assertSuccess(support.get(jwt, base + "?size=1"), 200); + assertSuccess(support.get(jwt, base + "?size=100"), 200); + } + } + + @Test + void _11_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + String jwt = support.createAdmin().getJwt(); + long categoryId = createCategory(jwt, unique()); + createProduct(jwt, categoryId, unique(), "1.00"); + + for (String base : List.of("/api/v1/categories", "/api/v1/products")) { + JsonNode data = assertSuccess(support.get(jwt, base + "?page=100000&size=10"), 200).get("data"); + + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("page").asInt()).isEqualTo(100000); + assertThat(data.get("totalElements").asLong()).isGreaterThanOrEqualTo(1L); + } + } + + // ---------------------------------------------------------------- permissions + + @Test + void _12_ShouldReturn401_WhenNoTokenIsSent() throws Exception { + for (String url : List.of("/api/v1/categories", "/api/v1/categories/1", "/api/v1/products", "/api/v1/products/1")) { + assertError(support.get(null, url), 401); + } + assertError(support.post(null, "/api/v1/categories", categoryBody(unique())), 401); + assertError(support.post(null, "/api/v1/products", productBody(1L, unique(), "1.00")), 401); + } + + @Test + void _13_ShouldReturn403AndChangeNothing_WhenUserLacksTheWritePermission() throws Exception { + TestUser simple = support.createSimpleUser(); + TestUser readOnly = support.createUser(true, false, support.createRole("CATEGORY:READ", "PRODUCT:READ")); + String admin = support.createAdmin().getJwt(); + long categoryId = createCategory(admin, unique()); + long productId = createProduct(admin, categoryId, unique(), "1.00"); + String name = unique(); + + for (TestUser user : List.of(simple, readOnly)) { + assertError(support.post(user.getJwt(), "/api/v1/categories", categoryBody(name)), 403); + assertError(support.put(user.getJwt(), "/api/v1/categories/" + categoryId, categoryBody(name)), 403); + assertError(support.delete(user.getJwt(), "/api/v1/categories/" + categoryId), 403); + assertError(support.post(user.getJwt(), "/api/v1/products", productBody(categoryId, name, "1.00")), 403); + assertError(support.put(user.getJwt(), "/api/v1/products/" + productId, productBody(categoryId, name, "1.00")), 403); + assertError(support.delete(user.getJwt(), "/api/v1/products/" + productId), 403); + } + assertThat(count("SELECT COUNT(*) FROM categories WHERE category_name = ?", name)).isZero(); + assertThat(count("SELECT COUNT(*) FROM products WHERE id = ?", productId)).isEqualTo(1L); + // The simple user has no read permission either, the read-only user does + assertError(support.get(simple.getJwt(), "/api/v1/categories"), 403); + assertError(support.get(simple.getJwt(), "/api/v1/products"), 403); + assertSuccess(support.get(readOnly.getJwt(), "/api/v1/categories/" + categoryId), 200); + assertSuccess(support.get(readOnly.getJwt(), "/api/v1/products/" + productId), 200); + } + + @Test + void _14_ShouldAllowOnlyWhatEachPermissionGrants_WhenUsersHoldASingleCatalogPermission() throws Exception { + String admin = support.createAdmin().getJwt(); + long categoryId = createCategory(admin, unique()); + TestUser categoryWriter = support.createUser(true, false, support.createRole("CATEGORY:WRITE")); + TestUser productWriter = support.createUser(true, false, support.createRole("PRODUCT:WRITE")); + + // CATEGORY:WRITE alone creates categories but neither reads them nor touches products + long created = idOf(support.post(categoryWriter.getJwt(), "/api/v1/categories", categoryBody(unique()))); + assertThat(created).isPositive(); + assertError(support.get(categoryWriter.getJwt(), "/api/v1/categories/" + created), 403); + assertError(support.post(categoryWriter.getJwt(), "/api/v1/products", productBody(categoryId, unique(), "1.00")), 403); + // PRODUCT:WRITE alone creates products but cannot create a category + createProduct(productWriter.getJwt(), categoryId, unique(), "1.00"); + assertError(support.post(productWriter.getJwt(), "/api/v1/categories", categoryBody(unique())), 403); + } +} From df70d223b15e9048563f05279258c13633a70d76 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:32:34 +0400 Subject: [PATCH 108/144] docs(readme): tick the feature/catalog tasks The five catalog tasks are checked: the entities, dto classes, RowMappers, mappers, DAOs, services and configuration of core/catalog, the category deletion rule, the controllers and converters in ws, the import in WebMvcConfig, and the tests at all five layers with a dedicated fixture per DAO test class. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2d336a1..7085ec2 100644 --- a/README.md +++ b/README.md @@ -401,11 +401,11 @@ Full CRUD for users, roles, and permissions, still inside `core/auth`. Assignmen ### Tasks -- [ ] `core/catalog`: `Category`, `Product` entity classes (`@Table("categories")`/`@Table("products")`) and `CategoryDto`/`ProductDto`, `CategoryRowMapper`/`ProductRowMapper` (entity) plus `CategoryMapper`/`ProductMapper` (entity ↔ dto), `CategoryDao`/`ProductDao` + impl, `CategoryService`/`ProductService` + impl, `DaoConfig`/`ServiceConfig` -- [ ] Business rule: deleting a category that still has products is rejected (`BusinessRuleException` → 422) -- [ ] `ws`: `CategoryController`, `ProductController`, `CategoryConverter`, `ProductConverter` -- [ ] `WebMvcConfig` updated to import `core/catalog`'s config -- [ ] Tests at all five layers - `CategoryDao`/`ProductDao` each with their own fixture dataset, the category-deletion rejection case, a permission-denied case +- [x] `core/catalog`: `Category`, `Product` entity classes (`@Table("categories")`/`@Table("products")`) and `CategoryDto`/`ProductDto`, `CategoryRowMapper`/`ProductRowMapper` (entity) plus `CategoryMapper`/`ProductMapper` (entity ↔ dto), `CategoryDao`/`ProductDao` + impl, `CategoryService`/`ProductService` + impl, `DaoConfig`/`ServiceConfig` +- [x] Business rule: deleting a category that still has products is rejected (`BusinessRuleException` → 422) +- [x] `ws`: `CategoryController`, `ProductController`, `CategoryConverter`, `ProductConverter` +- [x] `WebMvcConfig` updated to import `core/catalog`'s config +- [x] Tests at all five layers - `CategoryDao`/`ProductDao` each with their own fixture dataset, the category-deletion rejection case, a permission-denied case ## feature/customer From de86bd689ab9dc0d9ddb0ed96f0f66bf465dbd3e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:34:28 +0400 Subject: [PATCH 109/144] fix(catalog): bound the price before scaling and handle a vanished category cleanPrice called setScale before comparing with the maximum, so a value such as 1E+999999999 made BigDecimal build an enormous number before being refused. The bound is now checked first, on the raw value. create and update also checked that the category exists and then wrote, so a category deleted in between made the foreign key raise a DataIntegrityViolationException that became a 500. Both now translate it into the same ResourceNotFoundException as the pre-check. Three unit tests pin these cases. --- .../service/impl/ProductServiceImpl.java | 20 +++++++++---- .../service/impl/ProductServiceImplTest.java | 28 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java index bc664f6..34db39a 100644 --- a/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java +++ b/core/catalog/src/main/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImpl.java @@ -69,7 +69,12 @@ public ProductDto create(ProductDto input) { String name = cleanName(input.getProductName()); BigDecimal price = cleanPrice(input.getUnitPrice()); requireCategory(input.getCategoryId()); - return mapper.toDto(productDao.insert(new Product(null, input.getCategoryId(), name, price))); + try { + return mapper.toDto(productDao.insert(new Product(null, input.getCategoryId(), name, price))); + } catch (DataIntegrityViolationException e) { + // The category was deleted between the check above and the insert: the foreign key decided + throw new ResourceNotFoundException("Category not found: " + input.getCategoryId()); + } } @Override @@ -82,7 +87,12 @@ public ProductDto update(Long id, ProductDto input) { product.setCategoryId(input.getCategoryId()); product.setProductName(name); product.setUnitPrice(price); - productDao.update(product); + try { + productDao.update(product); + } catch (DataIntegrityViolationException e) { + // Same race as in create: the category vanished after the existence check + throw new ResourceNotFoundException("Category not found: " + input.getCategoryId()); + } return mapper.toDto(product); } @@ -131,10 +141,10 @@ private static BigDecimal cleanPrice(BigDecimal raw) { if (raw.stripTrailingZeros().scale() > PRICE_SCALE) { throw new BusinessRuleException("Unit price must have at most " + PRICE_SCALE + " decimals"); } - BigDecimal price = raw.setScale(PRICE_SCALE); - if (price.compareTo(MAX_PRICE) > 0) { + // Bound first: setScale on a value such as 1E+999999999 would build a gigantic number + if (raw.compareTo(MAX_PRICE) > 0) { throw new BusinessRuleException("Unit price must not exceed " + MAX_PRICE.toPlainString()); } - return price; + return raw.setScale(PRICE_SCALE); } } diff --git a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java index 5987705..f21d7f2 100644 --- a/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java +++ b/core/catalog/src/test/java/com/edgareldy/springjdbctutorial/core/catalog/service/impl/ProductServiceImplTest.java @@ -314,4 +314,32 @@ void _28_ShouldTurnDataIntegrityViolationIntoBusinessRuleException_WhenAnOrderRe assertThatThrownBy(() -> service().delete(3L)) .isInstanceOf(BusinessRuleException.class).hasCause(fk); } + + // Non-regression: the category can vanish between the existence check and the write + @Test + void _29_ShouldThrowResourceNotFoundException_WhenTheCategoryIsDeletedBeforeTheInsert() { + categoryExists(2L); + when(productDao.insert(any(Product.class))).thenThrow(new DataIntegrityViolationException("fk_products_category")); + + assertThatThrownBy(() -> service().create(input(2L, "Chess", "25.99"))) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Category not found: 2"); + } + + @Test + void _30_ShouldThrowResourceNotFoundException_WhenTheCategoryIsDeletedBeforeTheUpdate() { + categoryExists(2L); + when(productDao.findById(3L)).thenReturn(Optional.of(new Product(3L, 2L, "Chess", new BigDecimal("25.99")))); + doThrow(new DataIntegrityViolationException("fk_products_category")).when(productDao).update(any(Product.class)); + + assertThatThrownBy(() -> service().update(3L, input(2L, "Chess", "26.00"))) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Category not found: 2"); + } + + // Non-regression: a huge exponent must be refused by the bound before any setScale expansion + @Test + void _31_ShouldThrowBusinessRuleException_WhenThePriceHasAHugeExponent() { + assertThatThrownBy(() -> service().create(input(2L, "Chess", "1E+999999999"))) + .isInstanceOf(BusinessRuleException.class).hasMessageContaining("must not exceed"); + verify(productDao, never()).insert(any()); + } } From 575b81332d4f18e5a3bb2b6e26e4f4d6776bf3bc Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 110/144] feat(customer): add the Customer entity and CustomerDto Customer is the table-shaped class documented with the jakarta.persistence annotations and CustomerDto is the business shape the service exposes. The telephone, email and address are optional, so they are nullable on both sides. The two shapes stay separate classes even though their fields are identical. --- .../core/customer/dto/CustomerDto.java | 117 ++++++++++++++++ .../core/customer/entity/Customer.java | 128 ++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dto/CustomerDto.java create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/entity/Customer.java diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dto/CustomerDto.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dto/CustomerDto.java new file mode 100644 index 0000000..3a87492 --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dto/CustomerDto.java @@ -0,0 +1,117 @@ +package com.edgareldy.springjdbctutorial.core.customer.dto; + +import java.util.Objects; + +/** + * Business shape of a customer. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerDto { + + private Long id; + + private String firstName; + + private String lastName; + + private String telephone; + + private String email; + + private String address; + + public CustomerDto() { + } + + public CustomerDto(Long id, String firstName, String lastName, String telephone, String email, String address) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.telephone = telephone; + this.email = email; + this.address = address; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getTelephone() { + return telephone; + } + + public void setTelephone(String telephone) { + this.telephone = telephone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CustomerDto that = (CustomerDto) o; + return Objects.equals(id, that.id) && Objects.equals(firstName, that.firstName) + && Objects.equals(lastName, that.lastName) && Objects.equals(telephone, that.telephone) + && Objects.equals(email, that.email) && Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, telephone, email, address); + } + + @Override + public String toString() { + return "CustomerDto{" + + "id=" + id + + ", firstName=" + firstName + + ", lastName=" + lastName + + ", telephone=" + telephone + + ", email=" + email + + ", address=" + address + + "}"; + } +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/entity/Customer.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/entity/Customer.java new file mode 100644 index 0000000..0595080 --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/entity/Customer.java @@ -0,0 +1,128 @@ +package com.edgareldy.springjdbctutorial.core.customer.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.Objects; + +/** + * Row of the customers table. The jakarta.persistence annotations are documentation only, nothing reads them at runtime. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "customers") +public class Customer { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "first_name") + private String firstName; + + @Column(name = "last_name") + private String lastName; + + @Column(name = "telephone") + private String telephone; + + @Column(name = "email") + private String email; + + @Column(name = "address") + private String address; + + public Customer() { + } + + public Customer(Long id, String firstName, String lastName, String telephone, String email, String address) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.telephone = telephone; + this.email = email; + this.address = address; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getTelephone() { + return telephone; + } + + public void setTelephone(String telephone) { + this.telephone = telephone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Customer that = (Customer) o; + return Objects.equals(id, that.id) && Objects.equals(firstName, that.firstName) + && Objects.equals(lastName, that.lastName) && Objects.equals(telephone, that.telephone) + && Objects.equals(email, that.email) && Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, telephone, email, address); + } + + @Override + public String toString() { + return "Customer{" + + "id=" + id + + ", firstName=" + firstName + + ", lastName=" + lastName + + ", telephone=" + telephone + + ", email=" + email + + ", address=" + address + + "}"; + } +} From 0431c53f55ecf2aa4b7b1028defd6f45f85d9f00 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 111/144] feat(customer): add the customer RowMapper and entity mapper The RowMapper reads one row into a Customer and keeps the three optional columns as null when the database holds NULL. The entity mapper converts between Customer and CustomerDto in both directions. Neither queries the database or contains a business rule. --- .../core/customer/mapper/CustomerMapper.java | 31 +++++++++++++++++++ .../customer/mapper/CustomerRowMapper.java | 29 +++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapper.java create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapper.java diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapper.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapper.java new file mode 100644 index 0000000..e30a623 --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapper.java @@ -0,0 +1,31 @@ +package com.edgareldy.springjdbctutorial.core.customer.mapper; + +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; + +/** + * Converts between the Customer entity and the CustomerDto. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerMapper { + + public CustomerDto toDto(Customer customer) { + if (customer == null) { + return null; + } + return new CustomerDto(customer.getId(), customer.getFirstName(), customer.getLastName(), + customer.getTelephone(), customer.getEmail(), customer.getAddress()); + } + + public Customer toEntity(CustomerDto dto) { + if (dto == null) { + return null; + } + return new Customer(dto.getId(), dto.getFirstName(), dto.getLastName(), + dto.getTelephone(), dto.getEmail(), dto.getAddress()); + } +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapper.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapper.java new file mode 100644 index 0000000..1a18dc9 --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapper.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.customer.mapper; + +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the customers table into a Customer entity. Telephone, email and address are nullable columns. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerRowMapper implements RowMapper { + + @Override + public Customer mapRow(ResultSet rs, int rowNum) throws SQLException { + return new Customer( + rs.getLong("id"), + rs.getString("first_name"), + rs.getString("last_name"), + rs.getString("telephone"), + rs.getString("email"), + rs.getString("address")); + } +} From 378cef495b1934ca791d1f050bdb7c5268f05c85 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 112/144] feat(customer): add the customer DAO CustomerDaoImpl runs the SQL with JdbcTemplate through AbstractDao: a page ordered by id with LIMIT and OFFSET, a count, a lookup, INSERT ... RETURNING id, an update that returns the number of affected rows and a delete. Returning the affected row count lets the service detect that a row was deleted concurrently. The DAO works in entity types only and has no stereotype annotation. --- .../core/customer/dao/CustomerDao.java | 32 +++++++++ .../customer/dao/impl/CustomerDaoImpl.java | 70 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/CustomerDao.java create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImpl.java diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/CustomerDao.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/CustomerDao.java new file mode 100644 index 0000000..6245a3e --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/CustomerDao.java @@ -0,0 +1,32 @@ +package com.edgareldy.springjdbctutorial.core.customer.dao; + +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for customers. Entity types only. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface CustomerDao { + + /** One page of customers ordered by id. */ + List findPage(int page, int size); + + long countAll(); + + Optional findById(Long id); + + /** Inserts the customer and returns it with its generated id. */ + Customer insert(Customer customer); + + /** Returns the number of rows affected, 0 when the customer no longer exists. */ + int update(Customer customer); + + void delete(Long id); +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImpl.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImpl.java new file mode 100644 index 0000000..957a1ad --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImpl.java @@ -0,0 +1,70 @@ +package com.edgareldy.springjdbctutorial.core.customer.dao.impl; + +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao; +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import com.edgareldy.springjdbctutorial.core.customer.mapper.CustomerRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Optional; + +/** + * JdbcTemplate implementation of CustomerDao. Declared by @Bean in DaoConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerDaoImpl extends AbstractDao implements CustomerDao { + + private static final String COLUMNS = "id, first_name, last_name, telephone, email, address"; + + private final CustomerRowMapper rowMapper = new CustomerRowMapper(); + + public CustomerDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public List findPage(int page, int size) { + return getJdbcTemplate().query( + "SELECT " + COLUMNS + " FROM customers ORDER BY id LIMIT ? OFFSET ?", + rowMapper, size, (long) page * size); + } + + @Override + public long countAll() { + return count("SELECT COUNT(*) FROM customers"); + } + + @Override + public Optional findById(Long id) { + return findOne("SELECT " + COLUMNS + " FROM customers WHERE id = ?", rowMapper, id); + } + + @Override + public Customer insert(Customer customer) { + long id = insertReturningId( + "INSERT INTO customers (first_name, last_name, telephone, email, address) " + + "VALUES (?, ?, ?, ?, ?) RETURNING id", + customer.getFirstName(), customer.getLastName(), customer.getTelephone(), + customer.getEmail(), customer.getAddress()); + customer.setId(id); + return customer; + } + + @Override + public int update(Customer customer) { + return getJdbcTemplate().update( + "UPDATE customers SET first_name = ?, last_name = ?, telephone = ?, email = ?, address = ? WHERE id = ?", + customer.getFirstName(), customer.getLastName(), customer.getTelephone(), + customer.getEmail(), customer.getAddress(), customer.getId()); + } + + @Override + public void delete(Long id) { + getJdbcTemplate().update("DELETE FROM customers WHERE id = ?", id); + } +} From 3c3095edf6961cdb03b5cce9fca2cdf5dd611d80 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 113/144] feat(customer): implement the customer service CustomerServiceImpl trims and bounds the names, turns blank optional fields into null, checks the format of an email when one is given, and reports an update that affected no row as a not-found error. A customer referenced by orders cannot be deleted: the foreign key raises a DataIntegrityViolationException which the service turns into a business refusal, so no order code is needed yet. The service works in dto types, pages through PageDto and is transactional through its interface proxy. --- .../customer/service/CustomerService.java | 29 ++++ .../service/impl/CustomerServiceImpl.java | 148 ++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/CustomerService.java create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImpl.java diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/CustomerService.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/CustomerService.java new file mode 100644 index 0000000..1b531ee --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/CustomerService.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.customer.service; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; + +/** + * Business operations on customers. Dto types only at this boundary. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface CustomerService { + + /** One page of customers. Page must be >= 0 and size in 1..100, else BusinessRuleException. */ + PageDto list(int page, int size); + + /** Throws ResourceNotFoundException when the id is unknown. */ + CustomerDto get(Long id); + + /** Names are required, telephone, email and address are optional (blank becomes null). */ + CustomerDto create(CustomerDto input); + + CustomerDto update(Long id, CustomerDto input); + + /** Refused with BusinessRuleException while the customer is referenced by orders. */ + void delete(Long id); +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImpl.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImpl.java new file mode 100644 index 0000000..2891600 --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImpl.java @@ -0,0 +1,148 @@ +package com.edgareldy.springjdbctutorial.core.customer.service.impl; + +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.core.customer.dao.CustomerDao; +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import com.edgareldy.springjdbctutorial.core.customer.mapper.CustomerMapper; +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +/** + * Implementation of CustomerService. Declared by @Bean in ServiceConfig (no stereotype) and returned as the interface so the @Transactional advice applies through the proxy. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerServiceImpl implements CustomerService { + + private static final int MAX_PAGE_SIZE = 100; + private static final int MAX_NAME_LENGTH = 100; + private static final int MAX_TELEPHONE_LENGTH = 30; + private static final int MAX_EMAIL_LENGTH = 255; + private static final int MAX_ADDRESS_LENGTH = 255; + + private final CustomerDao customerDao; + private final CustomerMapper mapper = new CustomerMapper(); + + public CustomerServiceImpl(CustomerDao customerDao) { + this.customerDao = customerDao; + } + + @Override + @Transactional(readOnly = true) + public PageDto list(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 (Customer customer : customerDao.findPage(page, size)) { + content.add(mapper.toDto(customer)); + } + return new PageDto<>(content, page, size, customerDao.countAll()); + } + + @Override + @Transactional(readOnly = true) + public CustomerDto get(Long id) { + return mapper.toDto(load(id)); + } + + @Override + @Transactional + public CustomerDto create(CustomerDto input) { + return mapper.toDto(customerDao.insert(clean(null, input))); + } + + @Override + @Transactional + public CustomerDto update(Long id, CustomerDto input) { + load(id); + Customer customer = clean(id, input); + if (customerDao.update(customer) == 0) { + // The customer was deleted between the load and the update + throw new ResourceNotFoundException("Customer not found: " + id); + } + return mapper.toDto(customer); + } + + @Override + @Transactional + public void delete(Long id) { + load(id); + try { + customerDao.delete(id); + } catch (DataIntegrityViolationException e) { + // The foreign key from orders refused the delete + throw new BusinessRuleException("Customer is referenced by orders", e); + } + } + + private Customer load(Long id) { + return customerDao.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Customer not found: " + id)); + } + + private static Customer clean(Long id, CustomerDto input) { + String firstName = required(input.getFirstName(), "First name", MAX_NAME_LENGTH); + String lastName = required(input.getLastName(), "Last name", MAX_NAME_LENGTH); + String telephone = optional(input.getTelephone(), "Telephone", MAX_TELEPHONE_LENGTH); + String email = optional(input.getEmail(), "Email", MAX_EMAIL_LENGTH); + if (email != null && !isValidEmail(email)) { + throw new BusinessRuleException("Email is not valid"); + } + String address = optional(input.getAddress(), "Address", MAX_ADDRESS_LENGTH); + return new Customer(id, firstName, lastName, telephone, email, address); + } + + private static String required(String raw, String label, int max) { + String value = raw == null ? "" : raw.trim(); + if (value.isEmpty()) { + throw new BusinessRuleException(label + " must not be blank"); + } + return checkLength(value, label, max); + } + + /** Trims, turns blank into null, then checks the length. */ + private static String optional(String raw, String label, int max) { + String value = raw == null ? "" : raw.trim(); + if (value.isEmpty()) { + return null; + } + return checkLength(value, label, max); + } + + private static String checkLength(String value, String label, int max) { + if (value.length() > max) { + throw new BusinessRuleException(label + " must be at most " + max + " characters"); + } + return value; + } + + /** Basic format: no whitespace, one @, a non-empty local part and a domain containing a dot. */ + private static boolean isValidEmail(String email) { + for (int i = 0; i < email.length(); i++) { + if (Character.isWhitespace(email.charAt(i))) { + return false; + } + } + int at = email.indexOf('@'); + if (at < 1 || at != email.lastIndexOf('@')) { + return false; + } + String domain = email.substring(at + 1); + int dot = domain.indexOf('.'); + return dot > 0 && !domain.endsWith("."); + } +} From 27485bca0462406a3b15c94829a0b1f45a91b407 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 114/144] feat(customer): wire the customer DAO and service explicitly DaoConfig and ServiceConfig declare the beans typed by interface, and ServiceConfig imports DaoConfig so importing it alone is enough. The service bean is proxied for @Transactional through its interface. --- .../core/customer/config/DaoConfig.java | 24 +++++++++++++++++ .../core/customer/config/ServiceConfig.java | 26 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/DaoConfig.java create mode 100644 core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/ServiceConfig.java diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/DaoConfig.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/DaoConfig.java new file mode 100644 index 0000000..93ad4f5 --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/DaoConfig.java @@ -0,0 +1,24 @@ +package com.edgareldy.springjdbctutorial.core.customer.config; + +import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao; +import com.edgareldy.springjdbctutorial.core.customer.dao.impl.CustomerDaoImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Declares the DAO beans of the customer module, typed by interface (no stereotype on the implementations). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +public class DaoConfig { + + @Bean + public CustomerDao customerDao(JdbcTemplate jdbcTemplate) { + return new CustomerDaoImpl(jdbcTemplate); + } +} diff --git a/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/ServiceConfig.java b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/ServiceConfig.java new file mode 100644 index 0000000..96f4f0c --- /dev/null +++ b/core/customer/src/main/java/com/edgareldy/springjdbctutorial/core/customer/config/ServiceConfig.java @@ -0,0 +1,26 @@ +package com.edgareldy.springjdbctutorial.core.customer.config; + +import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao; +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +import com.edgareldy.springjdbctutorial.core.customer.service.impl.CustomerServiceImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * Declares the service beans of the customer module. Imports its DaoConfig so importing this class alone is enough. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +@Import(DaoConfig.class) +public class ServiceConfig { + + @Bean + public CustomerService customerService(CustomerDao customerDao) { + return new CustomerServiceImpl(customerDao); + } +} From f26eeab7fab005930d3f17811d9dc1685f9ead61 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 115/144] feat(ws): add the customer payloads and converter CustomerRequest requires the two names and bounds every field, and accepts an absent or empty optional value while checking the email format when one is given. CustomerConverter maps to and from the core dto and builds the PageResponse. Payload classes stay distinct from core dto classes. --- .../ws/converter/CustomerConverter.java | 47 +++++++ .../ws/payload/customer/CustomerRequest.java | 110 ++++++++++++++++ .../ws/payload/customer/CustomerResponse.java | 117 ++++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CustomerConverter.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerResponse.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CustomerConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CustomerConverter.java new file mode 100644 index 0000000..fcd7fca --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/CustomerConverter.java @@ -0,0 +1,47 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import com.edgareldy.springjdbctutorial.ws.payload.customer.CustomerRequest; +import com.edgareldy.springjdbctutorial.ws.payload.customer.CustomerResponse; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts between the core CustomerDto and the customer HTTP payloads. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public final class CustomerConverter { + + private CustomerConverter() { + } + + public static CustomerDto toDto(CustomerRequest request) { + CustomerDto dto = new CustomerDto(); + dto.setFirstName(request.getFirstName()); + dto.setLastName(request.getLastName()); + dto.setTelephone(request.getTelephone()); + dto.setEmail(request.getEmail()); + dto.setAddress(request.getAddress()); + return dto; + } + + public static CustomerResponse toResponse(CustomerDto dto) { + return new CustomerResponse(dto.getId(), dto.getFirstName(), dto.getLastName(), + dto.getTelephone(), dto.getEmail(), dto.getAddress()); + } + + public static PageResponse toPageResponse(PageDto page) { + List content = new ArrayList<>(); + for (CustomerDto 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/customer/CustomerRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerRequest.java new file mode 100644 index 0000000..2c648ad --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerRequest.java @@ -0,0 +1,110 @@ +package com.edgareldy.springjdbctutorial.ws.payload.customer; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.Objects; + +/** + * Body of the create and update customer calls. Optional fields pass when null or empty, the service turns blank ones into null. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerRequest { + + @NotBlank + @Size(max = 100) + private String firstName; + + @NotBlank + @Size(max = 100) + private String lastName; + + @Size(max = 30) + private String telephone; + + // @Email accepts null and the empty string, so an absent or empty email is not a validation error + @Email + @Size(max = 255) + private String email; + + @Size(max = 255) + private String address; + + public CustomerRequest() { + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getTelephone() { + return telephone; + } + + public void setTelephone(String telephone) { + this.telephone = telephone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CustomerRequest that = (CustomerRequest) o; + return Objects.equals(firstName, that.firstName) && Objects.equals(lastName, that.lastName) + && Objects.equals(telephone, that.telephone) && Objects.equals(email, that.email) + && Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(firstName, lastName, telephone, email, address); + } + + @Override + public String toString() { + return "CustomerRequest{" + + "firstName=" + firstName + + ", lastName=" + lastName + + ", telephone=" + telephone + + ", email=" + email + + ", address=" + address + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerResponse.java new file mode 100644 index 0000000..52192bb --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/customer/CustomerResponse.java @@ -0,0 +1,117 @@ +package com.edgareldy.springjdbctutorial.ws.payload.customer; + +import java.util.Objects; + +/** + * A customer as returned by the API. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class CustomerResponse { + + private Long id; + + private String firstName; + + private String lastName; + + private String telephone; + + private String email; + + private String address; + + public CustomerResponse() { + } + + public CustomerResponse(Long id, String firstName, String lastName, String telephone, String email, String address) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.telephone = telephone; + this.email = email; + this.address = address; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getTelephone() { + return telephone; + } + + public void setTelephone(String telephone) { + this.telephone = telephone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CustomerResponse that = (CustomerResponse) o; + return Objects.equals(id, that.id) && Objects.equals(firstName, that.firstName) + && Objects.equals(lastName, that.lastName) && Objects.equals(telephone, that.telephone) + && Objects.equals(email, that.email) && Objects.equals(address, that.address); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, telephone, email, address); + } + + @Override + public String toString() { + return "CustomerResponse{" + + "id=" + id + + ", firstName=" + firstName + + ", lastName=" + lastName + + ", telephone=" + telephone + + ", email=" + email + + ", address=" + address + + "}"; + } +} From 53a00aa6717f741bcf3cd9fe9b5baf48518de8d7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 116/144] feat(ws): expose the customer endpoints CustomerController follows the README table with declarative @PreAuthorize("hasPermission('CUSTOMER', 'READ'|'WRITE')"), calls only CustomerService and answers with an ApiResponse. The list validates page and size, and a creation returns 201. There is no business logic in the controller. --- .../ws/controller/CustomerController.java | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CustomerController.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CustomerController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CustomerController.java new file mode 100644 index 0000000..3487790 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/CustomerController.java @@ -0,0 +1,77 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +import com.edgareldy.springjdbctutorial.ws.converter.CustomerConverter; +import com.edgareldy.springjdbctutorial.ws.payload.customer.CustomerRequest; +import com.edgareldy.springjdbctutorial.ws.payload.customer.CustomerResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +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.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points to manage customers. No business logic. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@RestController +@RequestMapping("/api/v1/customers") +public class CustomerController { + + private final CustomerService customerService; + + public CustomerController(CustomerService customerService) { + this.customerService = customerService; + } + + @GetMapping + @PreAuthorize("hasPermission('CUSTOMER','READ')") + public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ApiResponse.success(CustomerConverter.toPageResponse(customerService.list(page, size)), "Customers"); + } + + @GetMapping("/{id}") + @PreAuthorize("hasPermission('CUSTOMER','READ')") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.success(CustomerConverter.toResponse(customerService.get(id)), "Customer"); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasPermission('CUSTOMER','WRITE')") + public ApiResponse create(@Valid @RequestBody CustomerRequest request) { + return ApiResponse.success(CustomerConverter.toResponse(customerService.create(CustomerConverter.toDto(request))), + "Customer created"); + } + + @PutMapping("/{id}") + @PreAuthorize("hasPermission('CUSTOMER','WRITE')") + public ApiResponse update(@PathVariable Long id, @Valid @RequestBody CustomerRequest request) { + return ApiResponse.success(CustomerConverter.toResponse(customerService.update(id, CustomerConverter.toDto(request))), + "Customer updated"); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasPermission('CUSTOMER','WRITE')") + public ApiResponse delete(@PathVariable Long id) { + customerService.delete(id); + return ApiResponse.success(null, "Customer deleted"); + } +} From b5f559d713918a00f53ce9f9dfdafffd5e0c8a5f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:39:49 +0400 Subject: [PATCH 117/144] feat(ws): import the customer module into the shared context The customer ServiceConfig is added to the WebMvcConfig import list by fully qualified name, because every core module has a class with that simple name. Its DaoConfig comes along through the ServiceConfig import. --- .../edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index e1185f7..0657ea5 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -38,7 +38,8 @@ @Configuration @EnableWebMvc @Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class, - com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class, SecurityConfig.class}) + com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class, + com.edgareldy.springjdbctutorial.core.customer.config.ServiceConfig.class, SecurityConfig.class}) @ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"}) public class WebMvcConfig implements WebMvcConfigurer { From 6c7c8e8a171339ae0d47d3b4ed1db3e7560a683e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 118/144] build(customer): add Testcontainers test dependencies to the customer module The customer DAO tests run against a real PostgreSQL, so the module declares the Testcontainers JUnit and PostgreSQL artifacts and the common test-jar holding the shared singleton container, all with test scope, like the auth and catalog modules. --- core/customer/pom.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/core/customer/pom.xml b/core/customer/pom.xml index 227da5e..4b46f4f 100644 --- a/core/customer/pom.xml +++ b/core/customer/pom.xml @@ -19,5 +19,22 @@ com.edgareldy.springjdbctutorial common + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + From bebafab43dfb0a78deda375e2bdb61a0efcf25fb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 119/144] test(customer): cover the customer DAO against PostgreSQL The DAO test loads its own dedicated fixture, which truncates the tables it touches and loads customers, one of them with NULL optional columns. It checks CRUD, the NULL round trip, page ordering, offset and an out-of-range page, the count, an update returning one affected row for a known id and zero for an unknown one, and a delete blocked by the orders foreign key. The order that blocks the delete is inserted by SQL in the test, so the constraint is proven before the order module exists. --- .../dao/impl/CustomerDaoImplTest.java | 152 ++++++++++++++++++ .../test/resources/customer-dao-dataset.sql | 19 +++ 2 files changed, 171 insertions(+) create mode 100644 core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImplTest.java create mode 100644 core/customer/src/test/resources/customer-dao-dataset.sql diff --git a/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImplTest.java b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImplTest.java new file mode 100644 index 0000000..a1db80e --- /dev/null +++ b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/dao/impl/CustomerDaoImplTest.java @@ -0,0 +1,152 @@ +package com.edgareldy.springjdbctutorial.core.customer.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.core.customer.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao; +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +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 CustomerDaoImpl against the real PostgreSQL, with its own fixture file customer-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Testcontainers starts a throw-away PostgreSQL in Docker (shared by every test class of the JVM through +// PostgresTestContainer); @DynamicPropertySource injects its url into the Spring Environment before the context +// is built, so DataSourceConfig connects to it and Flyway migrates the REAL schema. @Sql runs this class's own +// fixture file before EACH test so every test starts from the same rows. +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/customer-dao-dataset.sql") +class CustomerDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private CustomerDao customerDao; + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void _01_ShouldReturnEveryColumn_WhenIdExists() { + Customer customer = customerDao.findById(1L).orElseThrow(); + + assertThat(customer).isEqualTo(new Customer(1L, "Alice", "Martin", "+33 1 23 45", "alice@example.com", "1 Main Street")); + } + + @Test + void _02_ShouldReturnEmpty_WhenIdDoesNotExist() { + assertThat(customerDao.findById(999L)).isEmpty(); + } + + @Test + void _03_ShouldReadNullOptionalColumns_WhenTheyAreNullInTheRow() { + Customer customer = customerDao.findById(2L).orElseThrow(); + + assertThat(customer.getFirstName()).isEqualTo("Bob"); + assertThat(customer.getTelephone()).isNull(); + assertThat(customer.getEmail()).isNull(); + assertThat(customer.getAddress()).isNull(); + } + + @Test + void _04_ShouldReturnFirstPageOrderedById_WhenPageIsZero() { + assertThat(customerDao.findPage(0, 3)).extracting(Customer::getId).containsExactly(1L, 2L, 3L); + } + + @Test + void _05_ShouldApplyOffsetOfPageTimesSize_WhenPageIsOne() { + assertThat(customerDao.findPage(1, 3)).extracting(Customer::getId).containsExactly(4L); + } + + @Test + void _06_ShouldReturnEmptyList_WhenPageIsBeyondTheLastOne() { + assertThat(customerDao.findPage(50, 10)).isEmpty(); + } + + @Test + void _07_ShouldCountEveryRow_WhenCountingAll() { + assertThat(customerDao.countAll()).isEqualTo(4L); + } + + @Test + void _08_ShouldGenerateIdAndPersistEveryColumn_WhenCustomerIsInserted() { + Customer created = customerDao.insert(new Customer(null, "Eve", "Adams", "555", "eve@example.com", "5 Road")); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + assertThat(customerDao.findById(created.getId())).contains(created); + assertThat(customerDao.countAll()).isEqualTo(5L); + } + + @Test + void _09_ShouldRoundTripNulls_WhenOptionalColumnsAreNullOnInsert() { + Customer created = customerDao.insert(new Customer(null, "Zed", "Zulu", null, null, null)); + + Customer read = customerDao.findById(created.getId()).orElseThrow(); + + assertThat(read.getTelephone()).isNull(); + assertThat(read.getEmail()).isNull(); + assertThat(read.getAddress()).isNull(); + } + + @Test + void _10_ShouldReturnOneAndChangeEveryColumn_WhenCustomerExists() { + int affected = customerDao.update(new Customer(2L, "Robert", "Stone", "111", "rob@example.com", "2 Road")); + + assertThat(affected).isEqualTo(1); + assertThat(customerDao.findById(2L)).contains(new Customer(2L, "Robert", "Stone", "111", "rob@example.com", "2 Road")); + } + + @Test + void _11_ShouldWriteNulls_WhenUpdatedOptionalColumnsAreNull() { + customerDao.update(new Customer(1L, "Alice", "Martin", null, null, null)); + + Customer read = customerDao.findById(1L).orElseThrow(); + + assertThat(read.getTelephone()).isNull(); + assertThat(read.getEmail()).isNull(); + assertThat(read.getAddress()).isNull(); + } + + @Test + void _12_ShouldReturnZeroAndChangeNothing_WhenUpdatingAnUnknownId() { + int affected = customerDao.update(new Customer(999L, "Ghost", "Nobody", null, null, null)); + + assertThat(affected).isZero(); + assertThat(customerDao.countAll()).isEqualTo(4L); + } + + @Test + void _13_ShouldDeleteCustomer_WhenNoOrderReferencesIt() { + customerDao.delete(4L); + + assertThat(customerDao.findById(4L)).isEmpty(); + assertThat(customerDao.countAll()).isEqualTo(3L); + } + + @Test + void _14_ShouldThrowDataIntegrityViolationException_WhenAnOrderStillReferencesTheCustomer() { + // The order endpoints do not exist yet: the referencing row is inserted straight through SQL + jdbcTemplate.update("INSERT INTO orders (customer_id, product_id, quantity, total) VALUES (3, 1, 2, 25.00)"); + + assertThatThrownBy(() -> customerDao.delete(3L)).isInstanceOf(DataIntegrityViolationException.class); + + assertThat(customerDao.findById(3L)).isPresent(); + } +} diff --git a/core/customer/src/test/resources/customer-dao-dataset.sql b/core/customer/src/test/resources/customer-dao-dataset.sql new file mode 100644 index 0000000..9d4d59a --- /dev/null +++ b/core/customer/src/test/resources/customer-dao-dataset.sql @@ -0,0 +1,19 @@ +-- Fixture of CustomerDaoImplTest only. Wipes the customer and order tables (and the catalog rows an order needs), +-- then loads four customers: Alice (1) and Carol (3) and Dan (4) with every column, Bob (2) with NULL telephone, +-- email and address. One category and one product exist so a test can insert an order by SQL. +TRUNCATE TABLE orders, products, categories, customers RESTART IDENTITY CASCADE; + +INSERT INTO customers (id, first_name, last_name, telephone, email, address) VALUES + (1, 'Alice', 'Martin', '+33 1 23 45', 'alice@example.com', '1 Main Street'), + (2, 'Bob', 'Stone', NULL, NULL, NULL), + (3, 'Carol', 'Diaz', '555-0303', 'carol@example.com', '3 Side Road'), + (4, 'Dan', 'Lee', '555-0404', 'dan@example.com', '4 Long Avenue'); + +INSERT INTO categories (id, category_name) VALUES (1, 'Books'); +INSERT INTO products (id, category_id, product_name, unit_price) VALUES (1, 1, 'Novel', 12.50); + +-- Explicit ids do not advance the sequences: move them high so generated ids never collide +ALTER SEQUENCE customers_id_seq RESTART WITH 100; +ALTER SEQUENCE categories_id_seq RESTART WITH 100; +ALTER SEQUENCE products_id_seq RESTART WITH 100; +ALTER SEQUENCE orders_id_seq RESTART WITH 100; From 47acf9c6540131100ff5cf5f418accba365bfaee Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 120/144] test(customer): test the customer RowMapper and entity mapper The RowMapper is fed a mocked ResultSet, including NULL telephone, email and address, and the entity mapper is checked with hand-built objects and no mocks. --- .../customer/mapper/CustomerMapperTest.java | 53 +++++++++++++++++ .../mapper/CustomerRowMapperTest.java | 58 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapperTest.java create mode 100644 core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapperTest.java diff --git a/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapperTest.java b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapperTest.java new file mode 100644 index 0000000..d9ae8fa --- /dev/null +++ b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerMapperTest.java @@ -0,0 +1,53 @@ +package com.edgareldy.springjdbctutorial.core.customer.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import org.junit.jupiter.api.Test; + +/** + * Tests CustomerMapper (entity to dto and back) on hand-built objects, no mocks. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CustomerMapperTest { + + private final CustomerMapper mapper = new CustomerMapper(); + + @Test + void _01_ShouldCopyEveryField_WhenConvertingEntityToDto() { + CustomerDto dto = mapper.toDto(new Customer(3L, "Alice", "Martin", "555", "a@b.co", "Street")); + + assertThat(dto).isEqualTo(new CustomerDto(3L, "Alice", "Martin", "555", "a@b.co", "Street")); + } + + @Test + void _02_ShouldKeepNulls_WhenOptionalFieldsAreNull() { + CustomerDto dto = mapper.toDto(new Customer(3L, "Bob", "Stone", null, null, null)); + + assertThat(dto.getTelephone()).isNull(); + assertThat(dto.getEmail()).isNull(); + assertThat(dto.getAddress()).isNull(); + } + + @Test + void _03_ShouldReturnNull_WhenEntityIsNull() { + assertThat(mapper.toDto(null)).isNull(); + } + + @Test + void _04_ShouldCopyEveryField_WhenConvertingDtoToEntity() { + Customer customer = mapper.toEntity(new CustomerDto(3L, "Alice", "Martin", "555", "a@b.co", "Street")); + + assertThat(customer).isEqualTo(new Customer(3L, "Alice", "Martin", "555", "a@b.co", "Street")); + } + + @Test + void _05_ShouldReturnNull_WhenDtoIsNull() { + assertThat(mapper.toEntity(null)).isNull(); + } +} diff --git a/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapperTest.java b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapperTest.java new file mode 100644 index 0000000..2a88bff --- /dev/null +++ b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/mapper/CustomerRowMapperTest.java @@ -0,0 +1,58 @@ +package com.edgareldy.springjdbctutorial.core.customer.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.customer.entity.Customer; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** + * Tests CustomerRowMapper against a mocked ResultSet, without any database. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CustomerRowMapperTest { + + private final CustomerRowMapper mapper = new CustomerRowMapper(); + + // 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(7L); + when(rs.getString("first_name")).thenReturn("Alice"); + when(rs.getString("last_name")).thenReturn("Martin"); + when(rs.getString("telephone")).thenReturn("555"); + when(rs.getString("email")).thenReturn("alice@example.com"); + when(rs.getString("address")).thenReturn("1 Main Street"); + + Customer customer = mapper.mapRow(rs, 0); + + assertThat(customer).isEqualTo(new Customer(7L, "Alice", "Martin", "555", "alice@example.com", "1 Main Street")); + } + + @Test + void _02_ShouldKeepNulls_WhenTelephoneEmailAndAddressAreNull() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(8L); + when(rs.getString("first_name")).thenReturn("Bob"); + when(rs.getString("last_name")).thenReturn("Stone"); + when(rs.getString("telephone")).thenReturn(null); + when(rs.getString("email")).thenReturn(null); + when(rs.getString("address")).thenReturn(null); + + Customer customer = mapper.mapRow(rs, 0); + + assertThat(customer.getId()).isEqualTo(8L); + assertThat(customer.getTelephone()).isNull(); + assertThat(customer.getEmail()).isNull(); + assertThat(customer.getAddress()).isNull(); + } +} From 993ac0e8990d4cdd7c07a9a2d4aee3bdfb94964e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 121/144] test(customer): unit test the customer service With a mocked DAO the tests cover the page bounds, the 404 cases, trimming, blank optional fields becoming null, the length limits at their exact boundaries, every email format case, an update that affects no row, and the foreign key race on delete becoming a business refusal. --- .../service/impl/CustomerServiceImplTest.java | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImplTest.java diff --git a/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImplTest.java b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImplTest.java new file mode 100644 index 0000000..3044ac0 --- /dev/null +++ b/core/customer/src/test/java/com/edgareldy/springjdbctutorial/core/customer/service/impl/CustomerServiceImplTest.java @@ -0,0 +1,300 @@ +package com.edgareldy.springjdbctutorial.core.customer.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.Mockito.doThrow; +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.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.edgareldy.springjdbctutorial.core.customer.dao.CustomerDao; +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.core.customer.entity.Customer; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +/** + * Unit tests of CustomerServiceImpl: the DAO is a Mockito mock, assertions are made on the dto objects. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// MockitoExtension builds the @Mock fields before each test and fails a test whose stubbing is never used +// (strict stubs), so no test keeps a stub it does not need. No Spring context and no database here. +@ExtendWith(MockitoExtension.class) +class CustomerServiceImplTest { + + @Mock + private CustomerDao customerDao; + + private CustomerServiceImpl service() { + return new CustomerServiceImpl(customerDao); + } + + private static CustomerDto input(String first, String last, String telephone, String email, String address) { + return new CustomerDto(null, first, last, telephone, email, address); + } + + private static CustomerDto withEmail(String email) { + return input("Alice", "Martin", null, email, null); + } + + private Customer created() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Customer.class); + verify(customerDao).insert(captor.capture()); + return captor.getValue(); + } + + // ---------------------------------------------------------------- list + + @Test + void _01_ShouldReturnPageOfDtos_WhenListing() { + when(customerDao.findPage(1, 2)).thenReturn(List.of(new Customer(3L, "Carol", "Diaz", null, null, null))); + when(customerDao.countAll()).thenReturn(3L); + + PageDto page = service().list(1, 2); + + assertThat(page.getContent()).containsExactly(new CustomerDto(3L, "Carol", "Diaz", null, null, null)); + assertThat(page.getPage()).isEqualTo(1); + assertThat(page.getSize()).isEqualTo(2); + assertThat(page.getTotalElements()).isEqualTo(3L); + } + + @Test + void _02_ShouldReturnEmptyContent_WhenPageIsBeyondTheLastOne() { + when(customerDao.findPage(50, 10)).thenReturn(List.of()); + when(customerDao.countAll()).thenReturn(3L); + + PageDto page = service().list(50, 10); + + assertThat(page.getContent()).isEmpty(); + assertThat(page.getTotalElements()).isEqualTo(3L); + } + + @Test + void _03_ShouldThrowBusinessRuleException_WhenPageIsNegative() { + assertThatThrownBy(() -> service().list(-1, 10)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(customerDao); + } + + @Test + void _04_ShouldThrowBusinessRuleException_WhenSizeIsZero() { + assertThatThrownBy(() -> service().list(0, 0)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(customerDao); + } + + @Test + void _05_ShouldThrowBusinessRuleException_WhenSizeIsAbove100() { + assertThatThrownBy(() -> service().list(0, 101)).isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(customerDao); + } + + @Test + void _06_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() { + when(customerDao.findPage(0, 1)).thenReturn(List.of()); + when(customerDao.findPage(0, 100)).thenReturn(List.of()); + when(customerDao.countAll()).thenReturn(0L); + + assertThat(service().list(0, 1).getSize()).isEqualTo(1); + assertThat(service().list(0, 100).getSize()).isEqualTo(100); + } + + // ---------------------------------------------------------------- get + + @Test + void _07_ShouldReturnDto_WhenCustomerExists() { + when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", "1", "b@s.io", "Rd"))); + + assertThat(service().get(2L)).isEqualTo(new CustomerDto(2L, "Bob", "Stone", "1", "b@s.io", "Rd")); + } + + @Test + void _08_ShouldThrowResourceNotFoundException_WhenCustomerDoesNotExist() { + when(customerDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().get(99L)) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Customer not found: 99"); + } + + // ---------------------------------------------------------------- create + + @Test + void _09_ShouldTrimEveryFieldAndReturnGeneratedId_WhenCreatingACustomer() { + when(customerDao.insert(any(Customer.class))).thenAnswer(call -> { + Customer customer = call.getArgument(0); + customer.setId(7L); + return customer; + }); + + CustomerDto dto = service().create(input(" Alice ", " Martin ", " 555 ", " alice@example.com ", " 1 Main ")); + + assertThat(dto).isEqualTo(new CustomerDto(7L, "Alice", "Martin", "555", "alice@example.com", "1 Main")); + assertThat(created().getId()).isEqualTo(7L); + } + + @Test + void _10_ShouldStoreNull_WhenOptionalFieldsAreBlankOrAbsent() { + when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0)); + + service().create(input("Alice", "Martin", " ", "", null)); + + Customer stored = created(); + assertThat(stored.getTelephone()).isNull(); + assertThat(stored.getEmail()).isNull(); + assertThat(stored.getAddress()).isNull(); + } + + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void _11_ShouldThrowBusinessRuleException_WhenFirstOrLastNameIsBlank(String blank) { + assertThatThrownBy(() -> service().create(input(blank, "Martin", null, null, null))) + .isInstanceOf(BusinessRuleException.class).hasMessage("First name must not be blank"); + assertThatThrownBy(() -> service().create(input("Alice", blank, null, null, null))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Last name must not be blank"); + assertThatThrownBy(() -> service().create(input(null, "Martin", null, null, null))) + .isInstanceOf(BusinessRuleException.class); + verifyNoInteractions(customerDao); + } + + @Test + void _12_ShouldAcceptNamesOf100AndRefuse101_WhenCheckingTheNameLimit() { + when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0)); + + service().create(input("x".repeat(100), "y".repeat(100), null, null, null)); + + assertThatThrownBy(() -> service().create(input("x".repeat(101), "Martin", null, null, null))) + .isInstanceOf(BusinessRuleException.class).hasMessage("First name must be at most 100 characters"); + assertThatThrownBy(() -> service().create(input("Alice", "y".repeat(101), null, null, null))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Last name must be at most 100 characters"); + } + + @Test + void _13_ShouldAcceptTelephoneOf30AndRefuse31_WhenCheckingTheTelephoneLimit() { + when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0)); + + service().create(input("Alice", "Martin", "1".repeat(30), null, null)); + + assertThatThrownBy(() -> service().create(input("Alice", "Martin", "1".repeat(31), null, null))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Telephone must be at most 30 characters"); + } + + @Test + void _14_ShouldAcceptAddressOf255AndRefuse256_WhenCheckingTheAddressLimit() { + when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0)); + + service().create(input("Alice", "Martin", null, null, "a".repeat(255))); + + assertThatThrownBy(() -> service().create(input("Alice", "Martin", null, null, "a".repeat(256)))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Address must be at most 255 characters"); + } + + @ParameterizedTest + @ValueSource(strings = {"alice@example.com", "a.b+c@sub.example.org", "a@b.c"}) + void _15_ShouldAcceptEmail_WhenFormatIsValid(String email) { + when(customerDao.insert(any(Customer.class))).thenAnswer(call -> call.getArgument(0)); + + assertThat(service().create(withEmail(email)).getEmail()).isEqualTo(email); + } + + @ParameterizedTest + @ValueSource(strings = {"alice.example.com", "a@@example.com", "a@b@example.com", "alice@example", "alice@.com", + "alice@example.", "ali ce@example.com", "alice@exa mple.com", "@example.com"}) + void _16_ShouldThrowBusinessRuleException_WhenEmailFormatIsInvalid(String email) { + assertThatThrownBy(() -> service().create(withEmail(email))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Email is not valid"); + verify(customerDao, never()).insert(any()); + } + + @Test + void _17_ShouldThrowBusinessRuleException_WhenEmailIsLongerThan255() { + String email = "a".repeat(250) + "@b.com"; + + assertThatThrownBy(() -> service().create(withEmail(email))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Email must be at most 255 characters"); + } + + // ---------------------------------------------------------------- update + + @Test + void _18_ShouldUpdateAndReturnCleanedDto_WhenCustomerExists() { + when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null))); + when(customerDao.update(any(Customer.class))).thenReturn(1); + + CustomerDto dto = service().update(2L, input(" Robert ", "Stone", " ", "rob@example.com", null)); + + assertThat(dto).isEqualTo(new CustomerDto(2L, "Robert", "Stone", null, "rob@example.com", null)); + ArgumentCaptor sent = ArgumentCaptor.forClass(Customer.class); + verify(customerDao).update(sent.capture()); + assertThat(sent.getValue().getId()).isEqualTo(2L); + } + + @Test + void _19_ShouldThrowResourceNotFoundExceptionWithoutUpdating_WhenCustomerDoesNotExist() { + when(customerDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().update(99L, input("Alice", "Martin", null, null, null))) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Customer not found: 99"); + verify(customerDao, never()).update(any()); + } + + @Test + void _20_ShouldThrowResourceNotFoundException_WhenNoRowIsAffectedByTheUpdate() { + when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null))); + when(customerDao.update(any(Customer.class))).thenReturn(0); + + assertThatThrownBy(() -> service().update(2L, input("Bob", "Stone", null, null, null))) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Customer not found: 2"); + } + + @Test + void _21_ShouldThrowBusinessRuleExceptionWithoutUpdating_WhenUpdatedEmailIsInvalid() { + when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null))); + + assertThatThrownBy(() -> service().update(2L, withEmail("nope"))) + .isInstanceOf(BusinessRuleException.class); + verify(customerDao, never()).update(any()); + } + + // ---------------------------------------------------------------- delete + + @Test + void _22_ShouldDelete_WhenCustomerExistsAndIsNotReferenced() { + when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null))); + + service().delete(2L); + + verify(customerDao).delete(2L); + } + + @Test + void _23_ShouldThrowResourceNotFoundExceptionWithoutDeleting_WhenCustomerDoesNotExist() { + when(customerDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().delete(99L)).isInstanceOf(ResourceNotFoundException.class); + verify(customerDao, never()).delete(any()); + } + + @Test + void _24_ShouldThrowBusinessRuleException_WhenTheForeignKeyOfOrdersRefusesTheDelete() { + when(customerDao.findById(2L)).thenReturn(Optional.of(new Customer(2L, "Bob", "Stone", null, null, null))); + doThrow(new DataIntegrityViolationException("fk_orders_customer")).when(customerDao).delete(2L); + + assertThatThrownBy(() -> service().delete(2L)) + .isInstanceOf(BusinessRuleException.class).hasMessage("Customer is referenced by orders"); + } +} From 2a6ede55141689aebe0771ffc7b9fc9cac785877 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 122/144] test(ws): test the customer controller with MockMvc The real security configuration and a real signed JWT are used with a mocked service. Every endpoint is checked for status and JSON shape, NULL optional fields in the response, optional fields absent or empty, validation errors per field, page and size violations, an out-of-range page keeping content as an empty array, and the 404 and 422 mappings. --- .../ws/mvc/AbstractCustomerMvcTest.java | 92 ++++++ .../ws/mvc/CustomerControllerTest.java | 310 ++++++++++++++++++ .../ws/mvc/CustomerMvcTestConfig.java | 80 +++++ 3 files changed, 482 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCustomerMvcTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerControllerTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerMvcTestConfig.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCustomerMvcTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCustomerMvcTest.java new file mode 100644 index 0000000..310f294 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractCustomerMvcTest.java @@ -0,0 +1,92 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.mockito.Mockito.reset; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +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; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +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; + +/** + * Common base of the customer controller tests: builds MockMvc on the shared CustomerMvcTestConfig context, resets + * the mocked services before each test and offers request helpers (signed tokens, JSON bodies). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// MockMvc + springSecurity() runs the real filter chain in front of the DispatcherServlet (same as +// AbstractCatalogMvcTest): the JWT filter and @PreAuthorize are exercised, only the services are mocked. +@SpringJUnitWebConfig(CustomerMvcTestConfig.class) +abstract class AbstractCustomerMvcTest { + + @Autowired + private WebApplicationContext context; + @Autowired + protected CustomerService customerService; + @Autowired + protected AuthService authService; + + protected MockMvc mockMvc; + + @BeforeEach + void setUpMockMvc() { + reset(customerService, 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 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/CustomerControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerControllerTest.java new file mode 100644 index 0000000..916c05e --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerControllerTest.java @@ -0,0 +1,310 @@ +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.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.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +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; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * MockMvc tests of CustomerController with CustomerService mocked but the real security and validation: statuses, + * ApiResponse shape, page/size bounds, per-field body validation and the 404/422 refusals. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CustomerControllerTest extends AbstractCustomerMvcTest { + + private static final String READ = "CUSTOMER:READ"; + private static final String WRITE = "CUSTOMER:WRITE"; + private static final String URL = "/api/v1/customers"; + private static final String VALID_BODY = "{\"firstName\":\"Alice\",\"lastName\":\"Martin\"," + + "\"telephone\":\"555\",\"email\":\"alice@example.com\",\"address\":\"1 Main Street\"}"; + private static final CustomerDto ALICE = new CustomerDto(1L, "Alice", "Martin", "555", "alice@example.com", "1 Main Street"); + + private static String body(String firstName, String lastName, String email) { + return "{\"firstName\":\"" + firstName + "\",\"lastName\":\"" + lastName + "\",\"email\":\"" + email + "\"}"; + } + + // ---------------------------------------------------------------- GET /customers + + @Test + void _01_ShouldReturn200WithPageAndDefaults_WhenTokenHoldsCustomerRead() throws Exception { + when(customerService.list(0, 20)).thenReturn(new PageDto<>(List.of(ALICE), 0, 20, 1)); + + JsonNode data = assertSuccess(call(getWith(URL, token(READ))), 200).get("data"); + + JsonNode first = data.get("content").get(0); + assertThat(first.get("id").asLong()).isEqualTo(1L); + assertThat(first.get("firstName").asText()).isEqualTo("Alice"); + assertThat(first.get("lastName").asText()).isEqualTo("Martin"); + assertThat(first.get("telephone").asText()).isEqualTo("555"); + assertThat(first.get("email").asText()).isEqualTo("alice@example.com"); + assertThat(first.get("address").asText()).isEqualTo("1 Main Street"); + 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_ShouldPassPageAndSizeToTheService_WhenTheyAreGiven() throws Exception { + when(customerService.list(2, 5)).thenReturn(new PageDto<>(List.of(), 2, 5, 11)); + + JsonNode json = assertSuccess(call(getWith(URL + "?page=2&size=5", token(READ))), 200); + + assertThat(json.get("data").get("page").asInt()).isEqualTo(2); + assertThat(json.get("data").get("size").asInt()).isEqualTo(5); + verify(customerService).list(2, 5); + } + + @Test + void _03_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + when(customerService.list(99, 10)).thenReturn(new PageDto<>(List.of(), 99, 10, 3)); + + JsonNode data = assertSuccess(call(getWith(URL + "?page=99&size=10", token(READ))), 200).get("data"); + + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("totalElements").asLong()).isEqualTo(3L); + } + + @Test + void _04_ShouldReturn400NamingPage_WhenPageIsNegative() throws Exception { + JsonNode json = assertError(call(getWith(URL + "?page=-1", token(READ))), 400); + + assertThat(json.get("message").asText()).contains("page: ").doesNotContain("size: "); + verifyNoInteractions(customerService); + } + + @Test + void _05_ShouldReturn400NamingSize_WhenSizeIsZeroOrAbove100() throws Exception { + JsonNode zero = assertError(call(getWith(URL + "?size=0", token(READ))), 400); + JsonNode big = assertError(call(getWith(URL + "?size=101", token(READ))), 400); + + assertThat(zero.get("message").asText()).contains("size: ").doesNotContain("page: "); + assertThat(big.get("message").asText()).contains("size: "); + verifyNoInteractions(customerService); + } + + @Test + void _06_ShouldReturn400NamingBoth_WhenPageAndSizeAreInvalid() throws Exception { + JsonNode json = assertError(call(getWith(URL + "?page=-5&size=500", token(READ))), 400); + + assertThat(json.get("message").asText()).contains("page: ").contains("size: "); + } + + @Test + void _07_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() throws Exception { + when(customerService.list(0, 1)).thenReturn(new PageDto<>(List.of(), 0, 1, 0)); + when(customerService.list(0, 100)).thenReturn(new PageDto<>(List.of(), 0, 100, 0)); + + assertSuccess(call(getWith(URL + "?size=1", token(READ))), 200); + assertSuccess(call(getWith(URL + "?size=100", token(READ))), 200); + } + + // ---------------------------------------------------------------- GET /customers/{id} + + @Test + void _08_ShouldReturn200WithNullOptionalFields_WhenCustomerHasNoOptionalData() throws Exception { + when(customerService.get(2L)).thenReturn(new CustomerDto(2L, "Bob", "Stone", null, null, null)); + + JsonNode json = assertSuccess(call(getWith(URL + "/2", token(READ))), 200); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(2L); + assertThat(json.get("data").get("firstName").asText()).isEqualTo("Bob"); + assertThat(json.get("data").get("telephone").isNull()).isTrue(); + assertThat(json.get("data").get("email").isNull()).isTrue(); + assertThat(json.get("data").get("address").isNull()).isTrue(); + } + + @Test + void _09_ShouldReturn404ApiResponse_WhenCustomerDoesNotExist() throws Exception { + when(customerService.get(99L)).thenThrow(new ResourceNotFoundException("Customer not found: 99")); + + JsonNode json = assertError(call(getWith(URL + "/99", token(READ))), 404); + + assertThat(json.get("message").asText()).isEqualTo("Customer not found: 99"); + } + + @Test + void _10_ShouldReturn400_WhenCustomerIdIsNotANumber() throws Exception { + assertError(call(getWith(URL + "/abc", token(READ))), 400); + verifyNoInteractions(customerService); + } + + // ---------------------------------------------------------------- POST /customers + + @Test + void _11_ShouldReturn201WithCreatedCustomer_WhenBodyIsValid() throws Exception { + when(customerService.create(any(CustomerDto.class))).thenReturn(ALICE); + + JsonNode json = assertSuccess(call(postJson(URL, token(WRITE), VALID_BODY)), 201); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(1L); + assertThat(json.get("message").asText()).isEqualTo("Customer created"); + ArgumentCaptor sent = ArgumentCaptor.forClass(CustomerDto.class); + verify(customerService).create(sent.capture()); + assertThat(sent.getValue()).isEqualTo(new CustomerDto(null, "Alice", "Martin", "555", "alice@example.com", "1 Main Street")); + } + + @Test + void _12_ShouldReturn201_WhenOptionalFieldsAreAbsentOrEmpty() throws Exception { + when(customerService.create(any(CustomerDto.class))).thenReturn(new CustomerDto(3L, "Bob", "Stone", null, null, null)); + + assertSuccess(call(postJson(URL, token(WRITE), "{\"firstName\":\"Bob\",\"lastName\":\"Stone\"}")), 201); + assertSuccess(call(postJson(URL, token(WRITE), + "{\"firstName\":\"Bob\",\"lastName\":\"Stone\",\"telephone\":\"\",\"email\":\"\",\"address\":\"\"}")), 201); + } + + @Test + void _13_ShouldReturn400NamingTheField_WhenFirstOrLastNameIsBlankOrMissing() throws Exception { + JsonNode first = assertError(call(postJson(URL, token(WRITE), body(" ", "Martin", "a@b.co"))), 400); + JsonNode last = assertError(call(postJson(URL, token(WRITE), body("Alice", "", "a@b.co"))), 400); + JsonNode missing = assertError(call(postJson(URL, token(WRITE), "{}")), 400); + + assertThat(first.get("message").asText()).contains("firstName: ").doesNotContain("lastName: "); + assertThat(last.get("message").asText()).contains("lastName: ").doesNotContain("firstName: "); + assertThat(missing.get("message").asText()).contains("firstName: ").contains("lastName: "); + verifyNoInteractions(customerService); + } + + @Test + void _14_ShouldReturn400NamingTheField_WhenEmailIsMalformed() throws Exception { + JsonNode json = assertError(call(postJson(URL, token(WRITE), body("Alice", "Martin", "not-an-email"))), 400); + + assertThat(json.get("message").asText()).contains("email: "); + verifyNoInteractions(customerService); + } + + @Test + void _15_ShouldReturn400NamingTheField_WhenAFieldIsOversize() throws Exception { + String longName = "x".repeat(101); + JsonNode first = assertError(call(postJson(URL, token(WRITE), body(longName, "Martin", ""))), 400); + JsonNode last = assertError(call(postJson(URL, token(WRITE), body("Alice", longName, ""))), 400); + JsonNode phone = assertError(call(postJson(URL, token(WRITE), + "{\"firstName\":\"A\",\"lastName\":\"B\",\"telephone\":\"" + "1".repeat(31) + "\"}")), 400); + JsonNode address = assertError(call(postJson(URL, token(WRITE), + "{\"firstName\":\"A\",\"lastName\":\"B\",\"address\":\"" + "a".repeat(256) + "\"}")), 400); + JsonNode email = assertError(call(postJson(URL, token(WRITE), + body("Alice", "Martin", "a".repeat(250) + "@b.com"))), 400); + + assertThat(first.get("message").asText()).contains("firstName: "); + assertThat(last.get("message").asText()).contains("lastName: "); + assertThat(phone.get("message").asText()).contains("telephone: "); + assertThat(address.get("message").asText()).contains("address: "); + assertThat(email.get("message").asText()).contains("email: "); + verifyNoInteractions(customerService); + } + + @Test + void _16_ShouldReturn201_WhenFieldsSitExactlyOnTheirLimits() throws Exception { + when(customerService.create(any(CustomerDto.class))).thenReturn(ALICE); + String limits = "{\"firstName\":\"" + "x".repeat(100) + "\",\"lastName\":\"" + "y".repeat(100) + + "\",\"telephone\":\"" + "1".repeat(30) + "\",\"address\":\"" + "a".repeat(255) + "\"}"; + + assertSuccess(call(postJson(URL, token(WRITE), limits)), 201); + } + + @Test + void _17_ShouldReturn400_WhenBodyIsMalformedJson() throws Exception { + assertError(call(postJson(URL, token(WRITE), "{\"firstName\":")), 400); + verifyNoInteractions(customerService); + } + + @Test + void _18_ShouldReturn422_WhenServiceRefusesTheEmailFormat() throws Exception { + when(customerService.create(any(CustomerDto.class))).thenThrow(new BusinessRuleException("Email is not valid")); + + JsonNode json = assertError(call(postJson(URL, token(WRITE), body("Alice", "Martin", "a@b"))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Email is not valid"); + } + + @Test + void _19_ShouldReturn415_WhenContentTypeIsNotJson() throws Exception { + assertError(call(MockMvcRequestBuilders.post(URL) + .header("Authorization", bearer(token(WRITE))).contentType(MediaType.TEXT_PLAIN).content("Alice")), 415); + verifyNoInteractions(customerService); + } + + // ---------------------------------------------------------------- PUT /customers/{id} + + @Test + void _20_ShouldReturn200WithUpdatedCustomer_WhenBodyIsValid() throws Exception { + when(customerService.update(eq(1L), any(CustomerDto.class))).thenReturn(ALICE); + + JsonNode json = assertSuccess(call(putJson(URL + "/1", token(WRITE), VALID_BODY)), 200); + + assertThat(json.get("data").get("firstName").asText()).isEqualTo("Alice"); + assertThat(json.get("message").asText()).isEqualTo("Customer updated"); + } + + @Test + void _21_ShouldReturn404ApiResponse_WhenUpdatingAnUnknownCustomer() throws Exception { + when(customerService.update(eq(99L), any(CustomerDto.class))) + .thenThrow(new ResourceNotFoundException("Customer not found: 99")); + + assertError(call(putJson(URL + "/99", token(WRITE), VALID_BODY)), 404); + } + + @Test + void _22_ShouldReturn400_WhenUpdatedBodyIsInvalid() throws Exception { + JsonNode json = assertError(call(putJson(URL + "/1", token(WRITE), body("", "Martin", "bad"))), 400); + + assertThat(json.get("message").asText()).contains("firstName: ").contains("email: "); + verifyNoInteractions(customerService); + } + + // ---------------------------------------------------------------- DELETE /customers/{id} + + @Test + void _23_ShouldReturn200WithoutData_WhenCustomerIsDeleted() throws Exception { + JsonNode json = assertSuccess(call(deleteWith(URL + "/2", token(WRITE))), 200); + + assertThat(json.get("message").asText()).isEqualTo("Customer deleted"); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + verify(customerService).delete(2L); + } + + @Test + void _24_ShouldReturn404ApiResponse_WhenDeletingAnUnknownCustomer() throws Exception { + doThrow(new ResourceNotFoundException("Customer not found: 99")).when(customerService).delete(99L); + + assertError(call(deleteWith(URL + "/99", token(WRITE))), 404); + } + + @Test + void _25_ShouldReturn422_WhenCustomerIsReferencedByOrders() throws Exception { + doThrow(new BusinessRuleException("Customer is referenced by orders")).when(customerService).delete(2L); + + JsonNode json = assertError(call(deleteWith(URL + "/2", token(WRITE))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Customer is referenced by orders"); + } + + // ---------------------------------------------------------------- unsupported method + + @Test + void _26_ShouldReturn405ApiResponse_WhenPatchIsUsedOnACustomer() throws Exception { + assertError(call(patchWith(URL + "/2", token(WRITE))), 405); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerMvcTestConfig.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerMvcTestConfig.java new file mode 100644 index 0000000..f27f0ef --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerMvcTestConfig.java @@ -0,0 +1,80 @@ +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.customer.service.CustomerService; +import com.edgareldy.springjdbctutorial.ws.config.SecurityConfig; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.controller.CustomerController; +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 customer controller tests: MVC, the REAL SecurityConfig (JWT filter, method security + * with CustomPermissionEvaluator, 401/403 handlers), the real controller and advice, and a Mockito mock in place + * of CustomerService (no database). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as CatalogMvcTestConfig, kept in ws.mvc and never in ws.controller so WebMvcConfig's component scan +// cannot pick it up. +@Configuration +@EnableWebMvc +@Import(SecurityConfig.class) +public class CustomerMvcTestConfig implements WebMvcConfigurer { + + private final WebMvcConfig real = new WebMvcConfig(); + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + + @Bean + ObjectMapper objectMapper() { + return real.objectMapper(); + } + + @Bean + AuthService authService() { + return mock(AuthService.class); + } + + @Bean + CustomerService customerService() { + return mock(CustomerService.class); + } + + @Bean + CustomerController customerController(CustomerService customerService) { + return new CustomerController(customerService); + } + + @Bean + GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Override + public void extendMessageConverters(List> converters) { + real.extendMessageConverters(converters); + } + + @Override + public Validator getValidator() { + return real.getValidator(); + } +} From 61688796c19de1db9a48a7a0f99acdbad5a9c0fa Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 123/144] test(ws): check the permission matrix of the customer endpoints A parameterized test asserts, for each of the five endpoints, that no token or a revoked one gives 401, that a token holding every other permission or none gives 403, and that a token holding only the required CUSTOMER permission succeeds. --- .../ws/mvc/CustomerEndpointSecurityTest.java | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerEndpointSecurityTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerEndpointSecurityTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerEndpointSecurityTest.java new file mode 100644 index 0000000..c041293 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/CustomerEndpointSecurityTest.java @@ -0,0 +1,117 @@ +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +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 5 customer endpoints of the README table, checked through the real filter chain and + * method security: no token gives 401, a revoked token gives 401, a token holding every permission EXCEPT the + * required one (or none at all) gives 403, all as ApiResponse errors and without ever reaching the (mocked) + * service; the right permission alone gives 2xx. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class CustomerEndpointSecurityTest extends AbstractCustomerMvcTest { + + private static final String BODY = "{\"firstName\":\"Alice\",\"lastName\":\"Martin\"}"; + + // 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/customers", null, "CUSTOMER:READ"), + Arguments.of(HttpMethod.GET, "/api/v1/customers/1", null, "CUSTOMER:READ"), + Arguments.of(HttpMethod.POST, "/api/v1/customers", BODY, "CUSTOMER:WRITE"), + Arguments.of(HttpMethod.PUT, "/api/v1/customers/1", BODY, "CUSTOMER:WRITE"), + Arguments.of(HttpMethod.DELETE, "/api/v1/customers/1", null, "CUSTOMER: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 { + assertError(call(request(method, url, body, null)), 401); + verifyNoInteractions(customerService); + } + + @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(customerService); + } + + @ParameterizedTest(name = "{0} {1} with an empty token") + @MethodSource("endpoints") + void _03_ShouldReturn403ApiResponse_WhenTokenHoldsNoPermissionAtAll(HttpMethod method, String url, String body, + String required) throws Exception { + assertError(call(request(method, url, body, token())), 403); + verifyNoInteractions(customerService); + } + + @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(customerService); + } + + @ParameterizedTest(name = "{0} {1} with {3}") + @MethodSource("endpoints") + void _05_ShouldNotReturn401Or403_WhenTokenHoldsOnlyTheRequiredPermission(HttpMethod method, String url, + String body, String required) + throws Exception { + // Mocks return null, which would make the controller fail on the converter, so give the paths a real + // answer; the point of this test is only that the permission opens the door. + CustomerDto customer = new CustomerDto(1L, "Alice", "Martin", null, null, null); + when(customerService.list(0, 20)).thenReturn(new PageDto(List.of(), 0, 20, 0)); + when(customerService.get(1L)).thenReturn(customer); + when(customerService.create(any())).thenReturn(customer); + when(customerService.update(eq(1L), any())).thenReturn(customer); + + MvcResult result = call(request(method, url, body, token(required))); + + assertThat(result.getResponse().getStatus()).isIn(200, 201); + } +} From 34f99c411369be16a7a657d1b849cc470da3fa72 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 124/144] test(ws): run the customer flows on the real stack Real services and a real database prove the CRUD, NULL optional fields on create and update, the two email failure modes (400 from Bean Validation, 422 from the service), the 404 cases and the pagination bounds. A customer with an order, inserted by SQL, cannot be deleted: the answer is 422 "Customer is referenced by orders" and the customer stays, then the delete succeeds once the order is gone. Permission-less and write-only users get 403. --- .../CustomerFlowIntegrationTest.java | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CustomerFlowIntegrationTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CustomerFlowIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CustomerFlowIntegrationTest.java new file mode 100644 index 0000000..c25ddee --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/CustomerFlowIntegrationTest.java @@ -0,0 +1,303 @@ +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.TestUser; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.UUID; +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.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * End-to-end customer flows through the REAL stack (real WebMvcConfig, real service, real PostgreSQL migrated by + * Flyway, real logins): CRUD, optional fields, the email format rules at both levels, the delete refused while + * orders reference the customer, pagination bounds and permission enforcement. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as CatalogFlowIntegrationTest: the whole application context on the shared Testcontainers database. +// Every customer a test creates has a it- first name, and tearDown() removes them (with the orders, product +// and category some tests insert by SQL, and the users and roles of RbacTestSupport). +@SpringJUnitWebConfig(WebMvcConfig.class) +class CustomerFlowIntegrationTest { + + private static final String PREFIX = "it-"; + private static final String URL = "/api/v1/customers"; + + @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); + } + + @AfterEach + void tearDown() { + // Orders first: they reference the customers and the products + jdbcTemplate.update("DELETE FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE first_name LIKE ?)", + PREFIX + "%"); + jdbcTemplate.update("DELETE FROM customers WHERE first_name LIKE ?", PREFIX + "%"); + jdbcTemplate.update("DELETE FROM products WHERE product_name LIKE ?", PREFIX + "%"); + jdbcTemplate.update("DELETE FROM categories WHERE category_name LIKE ?", PREFIX + "%"); + support.close(); + } + + private static String unique() { + return PREFIX + UUID.randomUUID().toString().substring(0, 8); + } + + private static String body(String firstName, String lastName, String telephone, String email, String address) { + return "{\"firstName\":\"" + firstName + "\",\"lastName\":\"" + lastName + "\",\"telephone\":" + quote(telephone) + + ",\"email\":" + quote(email) + ",\"address\":" + quote(address) + "}"; + } + + private static String quote(String value) { + return value == null ? "null" : "\"" + value + "\""; + } + + private static String minimalBody(String firstName) { + return "{\"firstName\":\"" + firstName + "\",\"lastName\":\"Martin\"}"; + } + + private static long idOf(MvcResult result) throws Exception { + return assertSuccess(result, 201).get("data").get("id").asLong(); + } + + private long count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Long.class, args); + } + + @Test + void _01_ShouldCreateReadUpdateListAndDeleteACustomer_WhenAdminRunsTheFullCrud() throws Exception { + String jwt = support.createAdmin().getJwt(); + String name = unique(); + + MvcResult created = support.post(jwt, URL, body(" " + name + " ", " Martin ", " 555 ", "a@example.com", " 1 Main ")); + long id = idOf(created); + + // Every text is stored trimmed (the email is sent without spaces: @Email rejects surrounding spaces with a 400) + JsonNode read = assertSuccess(support.get(jwt, URL + "/" + id), 200).get("data"); + assertThat(read.get("firstName").asText()).isEqualTo(name); + assertThat(read.get("lastName").asText()).isEqualTo("Martin"); + assertThat(read.get("telephone").asText()).isEqualTo("555"); + assertThat(read.get("email").asText()).isEqualTo("a@example.com"); + assertThat(read.get("address").asText()).isEqualTo("1 Main"); + + String renamed = unique(); + JsonNode updated = assertSuccess(support.put(jwt, URL + "/" + id, + body(renamed, "Stone", "777", "b@example.com", "2 Road")), 200); + assertThat(updated.get("data").get("firstName").asText()).isEqualTo(renamed); + assertThat(count("SELECT COUNT(*) FROM customers WHERE first_name = ? AND last_name = 'Stone' AND telephone = '777'", + renamed)).isEqualTo(1L); + + JsonNode list = assertSuccess(support.get(jwt, URL + "?size=100"), 200); + boolean listed = false; + for (JsonNode customer : list.get("data").get("content")) { + listed |= customer.get("id").asLong() == id; + } + assertThat(listed).isTrue(); + + JsonNode deleted = assertSuccess(support.delete(jwt, URL + "/" + id), 200); + assertThat(deleted.get("data") == null || deleted.get("data").isNull()).isTrue(); + assertError(support.get(jwt, URL + "/" + id), 404); + } + + @Test + void _02_ShouldStoreNullOptionalFields_WhenTheyAreAbsentEmptyOrBlank() throws Exception { + String jwt = support.createAdmin().getJwt(); + + long absent = idOf(support.post(jwt, URL, minimalBody(unique()))); + long blank = idOf(support.post(jwt, URL, body(unique(), "Martin", " ", "", " "))); + + for (long id : List.of(absent, blank)) { + JsonNode data = assertSuccess(support.get(jwt, URL + "/" + id), 200).get("data"); + assertThat(data.get("telephone").isNull()).isTrue(); + assertThat(data.get("email").isNull()).isTrue(); + assertThat(data.get("address").isNull()).isTrue(); + assertThat(count("SELECT COUNT(*) FROM customers WHERE id = ? AND telephone IS NULL AND email IS NULL " + + "AND address IS NULL", id)).isEqualTo(1L); + } + } + + @Test + void _03_ShouldClearOptionalFields_WhenUpdatedWithoutThem() throws Exception { + String jwt = support.createAdmin().getJwt(); + String name = unique(); + long id = idOf(support.post(jwt, URL, body(name, "Martin", "555", "a@example.com", "1 Main"))); + + assertSuccess(support.put(jwt, URL + "/" + id, minimalBody(name)), 200); + + assertThat(count("SELECT COUNT(*) FROM customers WHERE id = ? AND telephone IS NULL AND email IS NULL " + + "AND address IS NULL", id)).isEqualTo(1L); + } + + @Test + void _04_ShouldReturn400OrHandOverTo422_WhenEmailIsMalformed() throws Exception { + String jwt = support.createAdmin().getJwt(); + String name = unique(); + + // Refused by Bean Validation (@Email) before the service runs: 400 naming the field + for (String bad : List.of("no-at-sign", "a@@b.com", "a b@c.com", "@c.com")) { + JsonNode json = assertError(support.post(jwt, URL, body(name, "Martin", null, bad, null)), 400); + assertThat(json.get("message").asText()).contains("email: "); + } + // The Bean Validation rule accepts a domain without a dot, the service rule then refuses it: 422 + JsonNode noDot = assertError(support.post(jwt, URL, body(name, "Martin", null, "alice@example", null)), 422); + assertThat(noDot.get("message").asText()).isEqualTo("Email is not valid"); + JsonNode onUpdate = assertError(support.put(jwt, URL + "/" + idOf(support.post(jwt, URL, minimalBody(name))), + body(name, "Martin", null, "alice@example", null)), 422); + assertThat(onUpdate.get("message").asText()).isEqualTo("Email is not valid"); + assertThat(count("SELECT COUNT(*) FROM customers WHERE first_name = ? AND email IS NOT NULL", name)).isZero(); + } + + @Test + void _05_ShouldReturn400NamingTheFieldAndStoreNothing_WhenBodyIsInvalid() throws Exception { + String jwt = support.createAdmin().getJwt(); + String name = unique(); + + JsonNode blank = assertError(support.post(jwt, URL, body(" ", "Martin", null, null, null)), 400); + JsonNode oversize = assertError(support.post(jwt, URL, body(name, "y".repeat(101), null, null, null)), 400); + JsonNode phone = assertError(support.post(jwt, URL, body(name, "Martin", "1".repeat(31), null, null)), 400); + + assertThat(blank.get("message").asText()).contains("firstName: "); + assertThat(oversize.get("message").asText()).contains("lastName: "); + assertThat(phone.get("message").asText()).contains("telephone: "); + assertThat(count("SELECT COUNT(*) FROM customers WHERE first_name = ?", name)).isZero(); + } + + @Test + void _06_ShouldReturn404_WhenCustomerDoesNotExist() throws Exception { + String jwt = support.createAdmin().getJwt(); + + assertError(support.get(jwt, URL + "/999999999"), 404); + assertError(support.put(jwt, URL + "/999999999", minimalBody(unique())), 404); + assertError(support.delete(jwt, URL + "/999999999"), 404); + } + + @Test + void _07_ShouldRefuseTheDeleteWith422AndKeepTheCustomer_WhenAnOrderReferencesIt() throws Exception { + String jwt = support.createAdmin().getJwt(); + long id = idOf(support.post(jwt, URL, minimalBody(unique()))); + // The order endpoints do not exist yet: a category, a product and the order are inserted through SQL + long categoryId = jdbcTemplate.queryForObject( + "INSERT INTO categories (category_name) VALUES (?) RETURNING id", Long.class, unique()); + long productId = jdbcTemplate.queryForObject( + "INSERT INTO products (category_id, product_name, unit_price) VALUES (?, ?, 5.00) RETURNING id", + Long.class, categoryId, unique()); + jdbcTemplate.update("INSERT INTO orders (customer_id, product_id, quantity, total) VALUES (?, ?, 2, 10.00)", + id, productId); + + JsonNode refused = assertError(support.delete(jwt, URL + "/" + id), 422); + + assertThat(refused.get("message").asText()).isEqualTo("Customer is referenced by orders"); + assertSuccess(support.get(jwt, URL + "/" + id), 200); + assertThat(count("SELECT COUNT(*) FROM customers WHERE id = ?", id)).isEqualTo(1L); + + // Once the order is gone the same delete goes through + jdbcTemplate.update("DELETE FROM orders WHERE customer_id = ?", id); + assertSuccess(support.delete(jwt, URL + "/" + id), 200); + assertError(support.get(jwt, URL + "/" + id), 404); + } + + @Test + void _08_ShouldReturn400NamingTheParameter_WhenPageOrSizeIsOutOfBounds() throws Exception { + String jwt = support.createAdmin().getJwt(); + + JsonNode page = assertError(support.get(jwt, URL + "?page=-1"), 400); + JsonNode zero = assertError(support.get(jwt, URL + "?size=0"), 400); + JsonNode big = assertError(support.get(jwt, URL + "?size=101"), 400); + + assertThat(page.get("message").asText()).contains("page: "); + assertThat(zero.get("message").asText()).contains("size: "); + assertThat(big.get("message").asText()).contains("size: "); + assertSuccess(support.get(jwt, URL + "?size=1"), 200); + assertSuccess(support.get(jwt, URL + "?size=100"), 200); + } + + @Test + void _09_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + String jwt = support.createAdmin().getJwt(); + idOf(support.post(jwt, URL, minimalBody(unique()))); + + JsonNode data = assertSuccess(support.get(jwt, URL + "?page=100000&size=10"), 200).get("data"); + + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("page").asInt()).isEqualTo(100000); + assertThat(data.get("totalElements").asLong()).isGreaterThanOrEqualTo(1L); + } + + @Test + void _10_ShouldReturn401_WhenNoTokenIsSent() throws Exception { + for (String url : List.of(URL, URL + "/1")) { + assertError(support.get(null, url), 401); + } + assertError(support.post(null, URL, minimalBody(unique())), 401); + assertError(support.put(null, URL + "/1", minimalBody(unique())), 401); + assertError(support.delete(null, URL + "/1"), 401); + } + + @Test + void _11_ShouldReturn403AndChangeNothing_WhenUserLacksTheWritePermission() throws Exception { + TestUser simple = support.createSimpleUser(); + TestUser readOnly = support.createUser(true, false, support.createRole("CUSTOMER:READ")); + String admin = support.createAdmin().getJwt(); + String existing = unique(); + long id = idOf(support.post(admin, URL, minimalBody(existing))); + String name = unique(); + + for (TestUser user : List.of(simple, readOnly)) { + assertError(support.post(user.getJwt(), URL, minimalBody(name)), 403); + assertError(support.put(user.getJwt(), URL + "/" + id, minimalBody(name)), 403); + assertError(support.delete(user.getJwt(), URL + "/" + id), 403); + } + assertThat(count("SELECT COUNT(*) FROM customers WHERE first_name = ?", name)).isZero(); + assertThat(count("SELECT COUNT(*) FROM customers WHERE id = ? AND first_name = ?", id, existing)).isEqualTo(1L); + // The simple user has no read permission either, the read-only user does + assertError(support.get(simple.getJwt(), URL), 403); + assertSuccess(support.get(readOnly.getJwt(), URL + "/" + id), 200); + } + + @Test + void _12_ShouldAllowOnlyWhatEachPermissionGrants_WhenUsersHoldASingleCustomerPermission() throws Exception { + TestUser writer = support.createUser(true, false, support.createRole("CUSTOMER:WRITE")); + + // CUSTOMER:WRITE alone creates customers but neither lists nor reads them + long id = idOf(support.post(writer.getJwt(), URL, minimalBody(unique()))); + assertError(support.get(writer.getJwt(), URL + "/" + id), 403); + assertError(support.get(writer.getJwt(), URL), 403); + } +} From 4792b5e5a46cc412a61ebc6da9c0c45d02bc08e8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 00:54:22 +0400 Subject: [PATCH 125/144] docs(readme): tick the feature/customer tasks The three customer tasks are checked: the entity, dto, RowMapper, mapper, DAO, service and configuration of core/customer, the controller and converter in ws with the WebMvcConfig import, and the tests at all five layers with a dedicated fixture for the DAO test class. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7085ec2..6cd8409 100644 --- a/README.md +++ b/README.md @@ -421,10 +421,10 @@ Full CRUD for users, roles, and permissions, still inside `core/auth`. Assignmen ### Tasks -- [ ] `core/customer`: `Customer` entity (`@Table("customers")`) and `CustomerDto`, `CustomerRowMapper` (entity) plus `CustomerMapper` (entity ↔ dto), `CustomerDao` + impl, `CustomerService` + impl, `DaoConfig`/`ServiceConfig` -- [ ] `ws`: `CustomerController`, `CustomerConverter` -- [ ] `WebMvcConfig` updated to import `core/customer`'s config -- [ ] Tests at all five layers, `CustomerDao` with its own fixture dataset +- [x] `core/customer`: `Customer` entity (`@Table("customers")`) and `CustomerDto`, `CustomerRowMapper` (entity) plus `CustomerMapper` (entity ↔ dto), `CustomerDao` + impl, `CustomerService` + impl, `DaoConfig`/`ServiceConfig` +- [x] `ws`: `CustomerController`, `CustomerConverter` +- [x] `WebMvcConfig` updated to import `core/customer`'s config +- [x] Tests at all five layers, `CustomerDao` with its own fixture dataset ## feature/order From fc9a2362f0b61a18c6bb7d612dd87c708f52b436 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 126/144] feat(order): add the Order entity and OrderDto Order is the table-shaped class for the orders table (the class name is Order, the table is orders because ORDER is an SQL keyword) and OrderDto is the business shape the service exposes. The quantity is an int and the total a BigDecimal, never a double, because it is money. The two shapes stay separate classes. --- .../core/order/dto/OrderDto.java | 105 ++++++++++++++++ .../core/order/entity/Order.java | 116 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dto/OrderDto.java create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/entity/Order.java diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dto/OrderDto.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dto/OrderDto.java new file mode 100644 index 0000000..a56053c --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dto/OrderDto.java @@ -0,0 +1,105 @@ +package com.edgareldy.springjdbctutorial.core.order.dto; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Business shape of an order. The total is computed by the service, never taken from the caller. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderDto { + + private Long id; + + private Long customerId; + + private Long productId; + + private int quantity; + + private BigDecimal total; + + public OrderDto() { + } + + public OrderDto(Long id, Long customerId, Long productId, int quantity, BigDecimal total) { + this.id = id; + this.customerId = customerId; + this.productId = productId; + this.quantity = quantity; + this.total = total; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public int getQuantity() { + return quantity; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } + + public BigDecimal getTotal() { + return total; + } + + public void setTotal(BigDecimal total) { + this.total = total; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + OrderDto that = (OrderDto) o; + return quantity == that.quantity && Objects.equals(id, that.id) && Objects.equals(customerId, that.customerId) + && Objects.equals(productId, that.productId) && Objects.equals(total, that.total); + } + + @Override + public int hashCode() { + return Objects.hash(id, customerId, productId, quantity, total); + } + + @Override + public String toString() { + return "OrderDto{" + + "id=" + id + + ", customerId=" + customerId + + ", productId=" + productId + + ", quantity=" + quantity + + ", total=" + total + + "}"; + } +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/entity/Order.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/entity/Order.java new file mode 100644 index 0000000..e81c9ef --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/entity/Order.java @@ -0,0 +1,116 @@ +package com.edgareldy.springjdbctutorial.core.order.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Row of the orders table. Both references are plain ids: the foreign keys are enforced by the database only. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Table(name = "orders") +public class Order { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "customer_id") + private Long customerId; + + @Column(name = "product_id") + private Long productId; + + @Column(name = "quantity") + private int quantity; + + @Column(name = "total") + private BigDecimal total; + + public Order() { + } + + public Order(Long id, Long customerId, Long productId, int quantity, BigDecimal total) { + this.id = id; + this.customerId = customerId; + this.productId = productId; + this.quantity = quantity; + this.total = total; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public int getQuantity() { + return quantity; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } + + public BigDecimal getTotal() { + return total; + } + + public void setTotal(BigDecimal total) { + this.total = total; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Order that = (Order) o; + return quantity == that.quantity && Objects.equals(id, that.id) && Objects.equals(customerId, that.customerId) + && Objects.equals(productId, that.productId) && Objects.equals(total, that.total); + } + + @Override + public int hashCode() { + return Objects.hash(id, customerId, productId, quantity, total); + } + + @Override + public String toString() { + return "Order{" + + "id=" + id + + ", customerId=" + customerId + + ", productId=" + productId + + ", quantity=" + quantity + + ", total=" + total + + "}"; + } +} From 1b265455cd6dcc0419cc12f719ecad7a25bd6756 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 127/144] feat(order): add the order RowMapper and entity mapper The RowMapper reads one row into an Order with getInt for the quantity and getBigDecimal for the total, so the exact decimal survives the round trip. The entity mapper converts between Order and OrderDto. Neither queries the database or contains a business rule. --- .../core/order/mapper/OrderMapper.java | 31 +++++++++++++++++++ .../core/order/mapper/OrderRowMapper.java | 28 +++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapper.java create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapper.java diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapper.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapper.java new file mode 100644 index 0000000..899a9bf --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapper.java @@ -0,0 +1,31 @@ +package com.edgareldy.springjdbctutorial.core.order.mapper; + +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.edgareldy.springjdbctutorial.core.order.entity.Order; + +/** + * Converts between the Order entity and the OrderDto. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderMapper { + + public OrderDto toDto(Order order) { + if (order == null) { + return null; + } + return new OrderDto(order.getId(), order.getCustomerId(), order.getProductId(), + order.getQuantity(), order.getTotal()); + } + + public Order toEntity(OrderDto dto) { + if (dto == null) { + return null; + } + return new Order(dto.getId(), dto.getCustomerId(), dto.getProductId(), + dto.getQuantity(), dto.getTotal()); + } +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapper.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapper.java new file mode 100644 index 0000000..f30cc7d --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapper.java @@ -0,0 +1,28 @@ +package com.edgareldy.springjdbctutorial.core.order.mapper; + +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the orders table into an Order entity. Every column is NOT NULL. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderRowMapper implements RowMapper { + + @Override + public Order mapRow(ResultSet rs, int rowNum) throws SQLException { + return new Order( + rs.getLong("id"), + rs.getLong("customer_id"), + rs.getLong("product_id"), + rs.getInt("quantity"), + rs.getBigDecimal("total")); + } +} From 8a626fc282ccf5b467113c47c9474690b6bd29a9 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 128/144] feat(order): add the order DAO with optional filters OrderDaoImpl reads a page filtered by an optional customer and an optional product, counts with the same filters, finds by id and inserts with INSERT ... RETURNING id. The WHERE clause is built from the filters that are present: only fixed column names are concatenated and every value is a bound parameter, added in placeholder order. There is no update or delete, because orders are immutable in this API. --- .../core/order/dao/OrderDao.java | 28 +++++++ .../core/order/dao/impl/OrderDaoImpl.java | 78 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/OrderDao.java create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImpl.java diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/OrderDao.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/OrderDao.java new file mode 100644 index 0000000..857b863 --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/OrderDao.java @@ -0,0 +1,28 @@ +package com.edgareldy.springjdbctutorial.core.order.dao; + +import com.edgareldy.springjdbctutorial.core.order.entity.Order; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for orders. Entity types only. Orders are immutable: there is no update and no delete. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface OrderDao { + + /** One page of orders ordered by id, each filter is applied only when not null. */ + List findPage(int page, int size, Long customerId, Long productId); + + /** Number of orders matching the same optional filters. */ + long count(Long customerId, Long productId); + + Optional findById(Long id); + + /** Inserts the order and returns it with its generated id. */ + Order insert(Order order); +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImpl.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImpl.java new file mode 100644 index 0000000..115d176 --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImpl.java @@ -0,0 +1,78 @@ +package com.edgareldy.springjdbctutorial.core.order.dao.impl; + +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao; +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import com.edgareldy.springjdbctutorial.core.order.mapper.OrderRowMapper; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * JdbcTemplate implementation of OrderDao. Declared by @Bean in DaoConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderDaoImpl extends AbstractDao implements OrderDao { + + private static final String SELECT = "SELECT id, customer_id, product_id, quantity, total FROM orders"; + + private final OrderRowMapper rowMapper = new OrderRowMapper(); + + public OrderDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public List findPage(int page, int size, Long customerId, Long productId) { + List args = new ArrayList<>(); + String where = where(customerId, productId, args); + args.add(size); + args.add((long) page * size); + return getJdbcTemplate().query(SELECT + where + " ORDER BY id LIMIT ? OFFSET ?", rowMapper, args.toArray()); + } + + @Override + public long count(Long customerId, Long productId) { + List args = new ArrayList<>(); + String where = where(customerId, productId, args); + Long total = getJdbcTemplate().queryForObject("SELECT COUNT(*) FROM orders" + where, Long.class, args.toArray()); + return total == null ? 0L : total; + } + + @Override + public Optional findById(Long id) { + return findOne(SELECT + " WHERE id = ?", rowMapper, id); + } + + @Override + public Order insert(Order order) { + long id = insertReturningId( + "INSERT INTO orders (customer_id, product_id, quantity, total) VALUES (?, ?, ?, ?) RETURNING id", + order.getCustomerId(), order.getProductId(), order.getQuantity(), order.getTotal()); + order.setId(id); + return order; + } + + /** + * Builds the WHERE clause from the non-null filters. Only fixed column names are concatenated, + * the values are always bound as parameters and appended to args in placeholder order. + */ + private static String where(Long customerId, Long productId, List args) { + StringBuilder where = new StringBuilder(); + if (customerId != null) { + where.append(" WHERE customer_id = ?"); + args.add(customerId); + } + if (productId != null) { + where.append(customerId == null ? " WHERE" : " AND").append(" product_id = ?"); + args.add(productId); + } + return where.toString(); + } +} From c1e3e2a3ad11f1c72e990ae3af4125ed2440fccc Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 129/144] feat(order): implement the order service on top of the catalog and customer services OrderServiceImpl reaches customers and products only through their service interfaces, never their DAOs. The injected beans are transactional proxies, so their calls join the transaction of create, and their ResourceNotFoundException propagates as a 404. The total is the product price times the quantity, computed exactly and compared with the column limit before any scaling. A quantity outside 1 to 1000000 is refused, and a customer or product deleted between the checks and the insert is reported as not found instead of a 500. The service returns dto types and pages through PageDto. --- .../core/order/service/OrderService.java | 24 ++++ .../order/service/impl/OrderServiceImpl.java | 109 ++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/OrderService.java create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/OrderService.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/OrderService.java new file mode 100644 index 0000000..d940766 --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/OrderService.java @@ -0,0 +1,24 @@ +package com.edgareldy.springjdbctutorial.core.order.service; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; + +/** + * Business operations on orders. Dto types only at this boundary. Orders are immutable. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public interface OrderService { + + /** One page of orders, optionally restricted to a customer and/or a product (filters may be null). */ + PageDto list(int page, int size, Long customerId, Long productId); + + /** Throws ResourceNotFoundException when the id is unknown. */ + OrderDto get(Long id); + + /** The customer and the product must exist. The total is computed as unit price times quantity. */ + OrderDto create(OrderDto input); +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java new file mode 100644 index 0000000..07df08e --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java @@ -0,0 +1,109 @@ +package com.edgareldy.springjdbctutorial.core.order.service.impl; + +import com.edgareldy.springjdbctutorial.core.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +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.core.customer.service.CustomerService; +import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao; +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import com.edgareldy.springjdbctutorial.core.order.mapper.OrderMapper; +import com.edgareldy.springjdbctutorial.core.order.service.OrderService; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +/** + * Implementation of OrderService. Declared by @Bean in ServiceConfig (no stereotype) and returned as the interface so the @Transactional advice applies through the proxy. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderServiceImpl implements OrderService { + + private static final int MAX_PAGE_SIZE = 100; + private static final int MIN_QUANTITY = 1; + private static final int MAX_QUANTITY = 1_000_000; + /** Largest value of the NUMERIC(14,2) total column. */ + private static final BigDecimal MAX_TOTAL = new BigDecimal("999999999999.99"); + + private final OrderDao orderDao; + private final ProductService productService; + private final CustomerService customerService; + private final OrderMapper mapper = new OrderMapper(); + + public OrderServiceImpl(OrderDao orderDao, ProductService productService, CustomerService customerService) { + this.orderDao = orderDao; + this.productService = productService; + this.customerService = customerService; + } + + @Override + @Transactional(readOnly = true) + public PageDto list(int page, int size, Long customerId, Long productId) { + 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 (Order order : orderDao.findPage(page, size, customerId, productId)) { + content.add(mapper.toDto(order)); + } + return new PageDto<>(content, page, size, orderDao.count(customerId, productId)); + } + + @Override + @Transactional(readOnly = true) + public OrderDto get(Long id) { + return mapper.toDto(orderDao.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Order not found: " + id))); + } + + @Override + @Transactional + public OrderDto create(OrderDto input) { + if (input.getCustomerId() == null) { + throw new BusinessRuleException("Customer id must not be null"); + } + if (input.getProductId() == null) { + throw new BusinessRuleException("Product id must not be null"); + } + int quantity = input.getQuantity(); + if (quantity < MIN_QUANTITY || quantity > MAX_QUANTITY) { + throw new BusinessRuleException("Quantity must be between " + MIN_QUANTITY + " and " + MAX_QUANTITY); + } + // Cross-module dependency: the order module reaches customers and products only through their + // SERVICE interfaces (never their DAOs). The beans are the transactional proxies, so these calls + // join this transaction, and their ResourceNotFoundException propagates as a 404. + customerService.get(input.getCustomerId()); + ProductDto product = productService.get(input.getProductId()); + + // The exact product is compared with the column limit before any scaling + BigDecimal exact = product.getUnitPrice().multiply(BigDecimal.valueOf(quantity)); + if (exact.compareTo(MAX_TOTAL) > 0) { + throw new BusinessRuleException("Order total must not exceed " + MAX_TOTAL.toPlainString()); + } + BigDecimal total = exact.setScale(2, RoundingMode.HALF_UP); + if (total.compareTo(MAX_TOTAL) > 0) { + throw new BusinessRuleException("Order total must not exceed " + MAX_TOTAL.toPlainString()); + } + + Order order = new Order(null, input.getCustomerId(), input.getProductId(), quantity, total); + try { + return mapper.toDto(orderDao.insert(order)); + } catch (DataIntegrityViolationException e) { + // The customer or the product was deleted between the checks and the insert (foreign key) + throw new ResourceNotFoundException("Customer or product no longer exists"); + } + } +} From 927bac41f2a0924535234c531309edc440e7ac34 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 130/144] feat(order): wire the order DAO and service explicitly DaoConfig declares the DAO, and ServiceConfig imports it together with the catalog and customer service configurations, by fully qualified name since each module has a class with that simple name. The order module is therefore self-sufficient: the ProductService and CustomerService it injects come from those imported configs. The order service bean is typed by interface so its @Transactional proxy applies. --- .../core/order/config/DaoConfig.java | 24 ++++++++++++++ .../core/order/config/ServiceConfig.java | 31 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/DaoConfig.java create mode 100644 core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/ServiceConfig.java diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/DaoConfig.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/DaoConfig.java new file mode 100644 index 0000000..8ec6e9e --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/DaoConfig.java @@ -0,0 +1,24 @@ +package com.edgareldy.springjdbctutorial.core.order.config; + +import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao; +import com.edgareldy.springjdbctutorial.core.order.dao.impl.OrderDaoImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Declares the DAO beans of the order module, typed by interface (no stereotype on the implementations). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +public class DaoConfig { + + @Bean + public OrderDao orderDao(JdbcTemplate jdbcTemplate) { + return new OrderDaoImpl(jdbcTemplate); + } +} diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/ServiceConfig.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/ServiceConfig.java new file mode 100644 index 0000000..41d2033 --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/config/ServiceConfig.java @@ -0,0 +1,31 @@ +package com.edgareldy.springjdbctutorial.core.order.config; + +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao; +import com.edgareldy.springjdbctutorial.core.order.service.OrderService; +import com.edgareldy.springjdbctutorial.core.order.service.impl.OrderServiceImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * Declares the service beans of the order module. Imports its DaoConfig and the catalog and customer + * ServiceConfig (fully qualified, every module has a class of that name) so importing this class alone is enough. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +@Import({DaoConfig.class, + com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class, + com.edgareldy.springjdbctutorial.core.customer.config.ServiceConfig.class}) +public class ServiceConfig { + + @Bean + public OrderService orderService(OrderDao orderDao, ProductService productService, CustomerService customerService) { + return new OrderServiceImpl(orderDao, productService, customerService); + } +} From f7e97bd801e0a82edc139618569d437ecd43d127 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 131/144] feat(ws): add the order payloads and converter OrderRequest requires the customer, the product and a quantity between 1 and 1000000, so a missing or absurd value is a 400 before it reaches the service. OrderResponse exposes the computed total, and OrderConverter maps to and from the core dto and builds the PageResponse. Payload classes stay distinct from core dto classes. --- .../ws/converter/OrderConverter.java | 45 ++++++++ .../ws/payload/order/OrderRequest.java | 83 ++++++++++++++ .../ws/payload/order/OrderResponse.java | 105 ++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/OrderConverter.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderResponse.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/OrderConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/OrderConverter.java new file mode 100644 index 0000000..6e1d4d6 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/OrderConverter.java @@ -0,0 +1,45 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import com.edgareldy.springjdbctutorial.ws.payload.order.OrderRequest; +import com.edgareldy.springjdbctutorial.ws.payload.order.OrderResponse; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts between the core OrderDto and the order HTTP payloads. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public final class OrderConverter { + + private OrderConverter() { + } + + public static OrderDto toDto(OrderRequest request) { + OrderDto dto = new OrderDto(); + dto.setCustomerId(request.getCustomerId()); + dto.setProductId(request.getProductId()); + dto.setQuantity(request.getQuantity()); + return dto; + } + + public static OrderResponse toResponse(OrderDto dto) { + return new OrderResponse(dto.getId(), dto.getCustomerId(), dto.getProductId(), + dto.getQuantity(), dto.getTotal()); + } + + public static PageResponse toPageResponse(PageDto page) { + List content = new ArrayList<>(); + for (OrderDto 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/order/OrderRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderRequest.java new file mode 100644 index 0000000..858a46e --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderRequest.java @@ -0,0 +1,83 @@ +package com.edgareldy.springjdbctutorial.ws.payload.order; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.util.Objects; + +/** + * Body of the create order call. The total is never sent, the server computes it. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderRequest { + + @NotNull + private Long customerId; + + @NotNull + private Long productId; + + @NotNull + @Min(1) + @Max(1000000) + private Integer quantity; + + public OrderRequest() { + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + OrderRequest that = (OrderRequest) o; + return Objects.equals(customerId, that.customerId) && Objects.equals(productId, that.productId) + && Objects.equals(quantity, that.quantity); + } + + @Override + public int hashCode() { + return Objects.hash(customerId, productId, quantity); + } + + @Override + public String toString() { + return "OrderRequest{" + + "customerId=" + customerId + + ", productId=" + productId + + ", quantity=" + quantity + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderResponse.java new file mode 100644 index 0000000..a122289 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/order/OrderResponse.java @@ -0,0 +1,105 @@ +package com.edgareldy.springjdbctutorial.ws.payload.order; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * An order as returned by the API. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +public class OrderResponse { + + private Long id; + + private Long customerId; + + private Long productId; + + private int quantity; + + private BigDecimal total; + + public OrderResponse() { + } + + public OrderResponse(Long id, Long customerId, Long productId, int quantity, BigDecimal total) { + this.id = id; + this.customerId = customerId; + this.productId = productId; + this.quantity = quantity; + this.total = total; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public int getQuantity() { + return quantity; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } + + public BigDecimal getTotal() { + return total; + } + + public void setTotal(BigDecimal total) { + this.total = total; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + OrderResponse that = (OrderResponse) o; + return quantity == that.quantity && Objects.equals(id, that.id) && Objects.equals(customerId, that.customerId) + && Objects.equals(productId, that.productId) && Objects.equals(total, that.total); + } + + @Override + public int hashCode() { + return Objects.hash(id, customerId, productId, quantity, total); + } + + @Override + public String toString() { + return "OrderResponse{" + + "id=" + id + + ", customerId=" + customerId + + ", productId=" + productId + + ", quantity=" + quantity + + ", total=" + total + + "}"; + } +} From 9a1a4d85982186409c28368d65b22a53a40558b0 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 132/144] feat(ws): expose the order endpoints OrderController offers the paginated list filterable by customerId and productId, the detail and the creation, guarded by @PreAuthorize("hasPermission('ORDER', 'READ'|'WRITE')"). It only calls OrderService and answers with an ApiResponse, and a creation returns 201. There is no update or delete route, so those methods answer 405. --- .../ws/controller/OrderController.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/OrderController.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/OrderController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/OrderController.java new file mode 100644 index 0000000..9756abe --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/OrderController.java @@ -0,0 +1,64 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.order.service.OrderService; +import com.edgareldy.springjdbctutorial.ws.converter.OrderConverter; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.PageResponse; +import com.edgareldy.springjdbctutorial.ws.payload.order.OrderRequest; +import com.edgareldy.springjdbctutorial.ws.payload.order.OrderResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points to read and place orders. No business logic. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +@RestController +@RequestMapping("/api/v1/orders") +public class OrderController { + + private final OrderService orderService; + + public OrderController(OrderService orderService) { + this.orderService = orderService; + } + + @GetMapping + @PreAuthorize("hasPermission('ORDER','READ')") + public ApiResponse> list(@RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, + @RequestParam(required = false) Long customerId, + @RequestParam(required = false) Long productId) { + return ApiResponse.success( + OrderConverter.toPageResponse(orderService.list(page, size, customerId, productId)), "Orders"); + } + + @GetMapping("/{id}") + @PreAuthorize("hasPermission('ORDER','READ')") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.success(OrderConverter.toResponse(orderService.get(id)), "Order"); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasPermission('ORDER','WRITE')") + public ApiResponse create(@Valid @RequestBody OrderRequest request) { + return ApiResponse.success(OrderConverter.toResponse(orderService.create(OrderConverter.toDto(request))), + "Order created"); + } +} From bd27d9a84a4425f9be4d9a7e5fcbb760019da16d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:00:18 +0400 Subject: [PATCH 133/144] feat(ws): import the order module into the shared context The order ServiceConfig is added to the WebMvcConfig import list by fully qualified name. It brings the DAO, and through its own imports the catalog and customer services it depends on. With this import every core module is now wired into the single application context. --- .../edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index 0657ea5..a3e7e60 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -39,7 +39,8 @@ @EnableWebMvc @Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class, com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.class, - com.edgareldy.springjdbctutorial.core.customer.config.ServiceConfig.class, SecurityConfig.class}) + com.edgareldy.springjdbctutorial.core.customer.config.ServiceConfig.class, + com.edgareldy.springjdbctutorial.core.order.config.ServiceConfig.class, SecurityConfig.class}) @ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"}) public class WebMvcConfig implements WebMvcConfigurer { From c5fb3cf916206c0f84209a70fb89aceb4a2c26ab Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:09 +0400 Subject: [PATCH 134/144] build(order): add Testcontainers test dependencies to the order module The order DAO tests run against a real PostgreSQL, so the module declares the Testcontainers JUnit and PostgreSQL artifacts and the common test-jar holding the shared singleton container, all with test scope, like the other business modules. --- core/order/pom.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/core/order/pom.xml b/core/order/pom.xml index a3ac330..35456e2 100644 --- a/core/order/pom.xml +++ b/core/order/pom.xml @@ -23,5 +23,22 @@ com.edgareldy.springjdbctutorial customer + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + From 1b33ba1f08d6b9c8c38566c252169960cb1b896a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:09 +0400 Subject: [PATCH 135/144] test(order): cover the order DAO against PostgreSQL The DAO test loads its own dedicated fixture with customers, products and orders. It checks page ordering, offset and an out-of-range page, each filter alone and both together, counts with the same filters, lookups, the exact NUMERIC total round trip including the column maximum, and the constraints owned by the schema: foreign keys to the customer and the product and the CHECK on the quantity. --- .../core/order/dao/impl/OrderDaoImplTest.java | 148 ++++++++++++++++++ .../src/test/resources/order-dao-dataset.sql | 22 +++ 2 files changed, 170 insertions(+) create mode 100644 core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImplTest.java create mode 100644 core/order/src/test/resources/order-dao-dataset.sql diff --git a/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImplTest.java b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImplTest.java new file mode 100644 index 0000000..74737d3 --- /dev/null +++ b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/dao/impl/OrderDaoImplTest.java @@ -0,0 +1,148 @@ +package com.edgareldy.springjdbctutorial.core.order.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.core.order.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao; +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +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 OrderDaoImpl against the real PostgreSQL, with its own fixture file order-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Testcontainers starts a throw-away PostgreSQL in Docker (shared by every test class of the JVM through +// PostgresTestContainer); @DynamicPropertySource injects its url into the Spring Environment before the context +// is built, so DataSourceConfig connects to it and Flyway migrates the REAL schema. @Sql runs this class's own +// fixture file before EACH test so every test starts from the same rows. +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/order-dao-dataset.sql") +class OrderDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private OrderDao orderDao; + + @Test + void _01_ShouldReturnFirstPageOrderedById_WhenNoFilterIsGiven() { + assertThat(orderDao.findPage(0, 3, null, null)).extracting(Order::getId).containsExactly(1L, 2L, 3L); + } + + @Test + void _02_ShouldApplyOffsetOfPageTimesSize_WhenPageIsOne() { + assertThat(orderDao.findPage(1, 3, null, null)).extracting(Order::getId).containsExactly(4L, 5L); + } + + @Test + void _03_ShouldReturnEmptyList_WhenPageIsBeyondTheLastOne() { + assertThat(orderDao.findPage(50, 10, null, null)).isEmpty(); + } + + @Test + void _04_ShouldReturnOnlyThatCustomersOrders_WhenFilteringByCustomerId() { + assertThat(orderDao.findPage(0, 10, 2L, null)).extracting(Order::getId).containsExactly(3L, 4L); + } + + @Test + void _05_ShouldReturnOnlyThatProductsOrders_WhenFilteringByProductId() { + assertThat(orderDao.findPage(0, 10, null, 2L)).extracting(Order::getId).containsExactly(2L, 4L); + } + + @Test + void _06_ShouldCombineBothFilters_WhenCustomerIdAndProductIdAreGiven() { + assertThat(orderDao.findPage(0, 10, 1L, 1L)).extracting(Order::getId).containsExactly(1L, 5L); + } + + @Test + void _07_ShouldPaginateInsideTheFilter_WhenFilterAndPageAreCombined() { + assertThat(orderDao.findPage(1, 2, 1L, null)).extracting(Order::getId).containsExactly(5L); + } + + @Test + void _08_ShouldReturnEmptyList_WhenNoOrderMatchesTheFilters() { + assertThat(orderDao.findPage(0, 10, 999L, null)).isEmpty(); + assertThat(orderDao.findPage(0, 10, null, 999L)).isEmpty(); + assertThat(orderDao.findPage(0, 10, 2L, 999L)).isEmpty(); + } + + @Test + void _09_ShouldCountWithTheSameFilters_WhenCounting() { + assertThat(orderDao.count(null, null)).isEqualTo(5L); + assertThat(orderDao.count(1L, null)).isEqualTo(3L); + assertThat(orderDao.count(null, 2L)).isEqualTo(2L); + assertThat(orderDao.count(1L, 1L)).isEqualTo(2L); + assertThat(orderDao.count(2L, 999L)).isZero(); + } + + @Test + void _10_ShouldReturnEveryColumn_WhenIdExists() { + Order order = orderDao.findById(2L).orElseThrow(); + + assertThat(order).isEqualTo(new Order(2L, 1L, 2L, 3, new BigDecimal("59.97"))); + } + + @Test + void _11_ShouldReturnEmpty_WhenIdDoesNotExist() { + assertThat(orderDao.findById(999L)).isEmpty(); + } + + @Test + void _12_ShouldGenerateIdAndKeepTheExactTotal_WhenOrderIsInserted() { + Order created = orderDao.insert(new Order(null, 2L, 2L, 7, new BigDecimal("139.93"))); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + Order read = orderDao.findById(created.getId()).orElseThrow(); + assertThat(read).isEqualTo(created); + assertThat(read.getTotal()).isEqualByComparingTo("139.93"); + assertThat(orderDao.count(null, null)).isEqualTo(6L); + } + + @Test + void _13_ShouldRoundTripTheLargestTotal_WhenTotalIsTheColumnMaximum() { + Order created = orderDao.insert(new Order(null, 1L, 1L, 1, new BigDecimal("999999999999.99"))); + + assertThat(orderDao.findById(created.getId()).orElseThrow().getTotal()).isEqualByComparingTo("999999999999.99"); + } + + @Test + void _14_ShouldThrowDataIntegrityViolationException_WhenCustomerDoesNotExist() { + assertThatThrownBy(() -> orderDao.insert(new Order(null, 999L, 1L, 1, new BigDecimal("10.00")))) + .isInstanceOf(DataIntegrityViolationException.class); + + assertThat(orderDao.count(null, null)).isEqualTo(5L); + } + + @Test + void _15_ShouldThrowDataIntegrityViolationException_WhenProductDoesNotExist() { + assertThatThrownBy(() -> orderDao.insert(new Order(null, 1L, 999L, 1, new BigDecimal("10.00")))) + .isInstanceOf(DataIntegrityViolationException.class); + + assertThat(orderDao.count(null, null)).isEqualTo(5L); + } + + @Test + void _16_ShouldThrowDataIntegrityViolationException_WhenQuantityIsNotPositive() { + assertThatThrownBy(() -> orderDao.insert(new Order(null, 1L, 1L, 0, new BigDecimal("0.00")))) + .isInstanceOf(DataIntegrityViolationException.class); + + assertThat(orderDao.count(null, null)).isEqualTo(5L); + } +} diff --git a/core/order/src/test/resources/order-dao-dataset.sql b/core/order/src/test/resources/order-dao-dataset.sql new file mode 100644 index 0000000..2526ca0 --- /dev/null +++ b/core/order/src/test/resources/order-dao-dataset.sql @@ -0,0 +1,22 @@ +-- Fixture of OrderDaoImplTest only. Wipes the order, catalog and customer tables, then loads two customers (1, 2), +-- one category, two products (1 at 10.00, 2 at 19.99) and five orders: 1 (customer 1, product 1), 2 (customer 1, +-- product 2), 3 (customer 2, product 1), 4 (customer 2, product 2), 5 (customer 1, product 1). +TRUNCATE TABLE orders, products, categories, customers RESTART IDENTITY CASCADE; + +INSERT INTO customers (id, first_name, last_name) VALUES (1, 'Alice', 'Martin'), (2, 'Bob', 'Stone'); + +INSERT INTO categories (id, category_name) VALUES (1, 'Books'); +INSERT INTO products (id, category_id, product_name, unit_price) VALUES (1, 1, 'Novel', 10.00), (2, 1, 'Atlas', 19.99); + +INSERT INTO orders (id, customer_id, product_id, quantity, total) VALUES + (1, 1, 1, 2, 20.00), + (2, 1, 2, 3, 59.97), + (3, 2, 1, 1, 10.00), + (4, 2, 2, 5, 99.95), + (5, 1, 1, 4, 40.00); + +-- Explicit ids do not advance the sequences: move them high so generated ids never collide +ALTER SEQUENCE customers_id_seq RESTART WITH 100; +ALTER SEQUENCE categories_id_seq RESTART WITH 100; +ALTER SEQUENCE products_id_seq RESTART WITH 100; +ALTER SEQUENCE orders_id_seq RESTART WITH 100; From 97ade2067b9df61531c9e26b8c0b8186a2d84fc4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:09 +0400 Subject: [PATCH 136/144] test(order): test the order RowMapper and entity mapper The RowMapper is fed a mocked ResultSet with an int quantity and a BigDecimal total, and the entity mapper is checked with hand-built objects and no mocks. --- .../core/order/mapper/OrderMapperTest.java | 41 ++++++++++++++ .../core/order/mapper/OrderRowMapperTest.java | 55 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapperTest.java create mode 100644 core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapperTest.java diff --git a/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapperTest.java b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapperTest.java new file mode 100644 index 0000000..43e6744 --- /dev/null +++ b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderMapperTest.java @@ -0,0 +1,41 @@ +package com.edgareldy.springjdbctutorial.core.order.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +/** + * Tests OrderMapper: entity to dto and dto to entity, with hand-built objects and no mock. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class OrderMapperTest { + + private final OrderMapper mapper = new OrderMapper(); + + @Test + void _01_ShouldCopyEveryField_WhenEntityBecomesDto() { + OrderDto dto = mapper.toDto(new Order(1L, 2L, 3L, 4, new BigDecimal("79.96"))); + + assertThat(dto).isEqualTo(new OrderDto(1L, 2L, 3L, 4, new BigDecimal("79.96"))); + } + + @Test + void _02_ShouldCopyEveryField_WhenDtoBecomesEntity() { + Order order = mapper.toEntity(new OrderDto(null, 2L, 3L, 4, new BigDecimal("79.96"))); + + assertThat(order).isEqualTo(new Order(null, 2L, 3L, 4, new BigDecimal("79.96"))); + } + + @Test + void _03_ShouldReturnNull_WhenTheInputIsNull() { + assertThat(mapper.toDto(null)).isNull(); + assertThat(mapper.toEntity(null)).isNull(); + } +} diff --git a/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapperTest.java b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapperTest.java new file mode 100644 index 0000000..ba66040 --- /dev/null +++ b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/mapper/OrderRowMapperTest.java @@ -0,0 +1,55 @@ +package com.edgareldy.springjdbctutorial.core.order.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.order.entity.Order; +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** + * Tests OrderRowMapper against a mocked ResultSet, without any database. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class OrderRowMapperTest { + + private final OrderRowMapper mapper = new OrderRowMapper(); + + // 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(7L); + when(rs.getLong("customer_id")).thenReturn(3L); + when(rs.getLong("product_id")).thenReturn(4L); + when(rs.getInt("quantity")).thenReturn(3); + when(rs.getBigDecimal("total")).thenReturn(new BigDecimal("59.97")); + + Order order = mapper.mapRow(rs, 0); + + assertThat(order).isEqualTo(new Order(7L, 3L, 4L, 3, new BigDecimal("59.97"))); + } + + @Test + void _02_ShouldKeepTheExactDecimalScale_WhenTotalIsZero() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(8L); + when(rs.getLong("customer_id")).thenReturn(1L); + when(rs.getLong("product_id")).thenReturn(1L); + when(rs.getInt("quantity")).thenReturn(1); + when(rs.getBigDecimal("total")).thenReturn(new BigDecimal("0.00")); + + Order order = mapper.mapRow(rs, 0); + + assertThat(order.getTotal()).isEqualTo(new BigDecimal("0.00")); + assertThat(order.getQuantity()).isEqualTo(1); + } +} From fe066f368503d602dbf31fc83bdfac3234d7348a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:10 +0400 Subject: [PATCH 137/144] test(order): unit test the order service with mocked customer and product services The order module only sees service interfaces, so the tests mock CustomerService and ProductService. They cover exact totals (3 x 19.99 = 59.97, a zero price, half-up rounding), a total above the column limit refused before any insert, the quantity boundaries, null ids, and the order of the checks: an unknown customer or product propagates its 404 and nothing is inserted. A foreign key violation at insert becomes a not-found error, and the page bounds and filters are checked too. --- .../service/impl/OrderServiceImplTest.java | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImplTest.java diff --git a/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImplTest.java b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImplTest.java new file mode 100644 index 0000000..162b8a9 --- /dev/null +++ b/core/order/src/test/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImplTest.java @@ -0,0 +1,287 @@ +package com.edgareldy.springjdbctutorial.core.order.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.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.catalog.dto.ProductDto; +import com.edgareldy.springjdbctutorial.core.catalog.service.ProductService; +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.core.customer.dto.CustomerDto; +import com.edgareldy.springjdbctutorial.core.customer.service.CustomerService; +import com.edgareldy.springjdbctutorial.core.order.dao.OrderDao; +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.edgareldy.springjdbctutorial.core.order.entity.Order; +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +/** + * Unit tests of OrderServiceImpl: the DAO and the customer and product SERVICES are Mockito mocks (the order module only sees service interfaces), assertions are made on the dto objects. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// MockitoExtension builds the @Mock fields before each test and fails a test whose stubbing is never used +// (strict stubs), so no test keeps a stub it does not need. No Spring context and no database here. +@ExtendWith(MockitoExtension.class) +class OrderServiceImplTest { + + @Mock + private OrderDao orderDao; + @Mock + private ProductService productService; + @Mock + private CustomerService customerService; + + private OrderServiceImpl service() { + return new OrderServiceImpl(orderDao, productService, customerService); + } + + private static OrderDto input(Long customerId, Long productId, int quantity) { + return new OrderDto(null, customerId, productId, quantity, null); + } + + private void productAt(String unitPrice) { + when(productService.get(2L)).thenReturn(new ProductDto(2L, 1L, "Atlas", new BigDecimal(unitPrice))); + } + + private void insertEchoesWithId() { + when(orderDao.insert(any(Order.class))).thenAnswer(call -> { + Order order = call.getArgument(0); + order.setId(50L); + return order; + }); + } + + private Order inserted() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Order.class); + verify(orderDao).insert(captor.capture()); + return captor.getValue(); + } + + // ---------------------------------------------------------------- create: total + + @Test + void _01_ShouldComputeTheExactTotal_WhenQuantityIsThreeAndPriceIs1999() { + when(customerService.get(1L)).thenReturn(new CustomerDto(1L, "Alice", "Martin", null, null, null)); + productAt("19.99"); + insertEchoesWithId(); + + OrderDto created = service().create(input(1L, 2L, 3)); + + assertThat(created).isEqualTo(new OrderDto(50L, 1L, 2L, 3, new BigDecimal("59.97"))); + assertThat(inserted().getTotal()).isEqualTo(new BigDecimal("59.97")); + } + + @Test + void _02_ShouldStoreTotalZeroWithTwoDecimals_WhenProductIsFree() { + productAt("0.00"); + insertEchoesWithId(); + + service().create(input(1L, 2L, 1)); + + assertThat(inserted().getTotal()).isEqualTo(new BigDecimal("0.00")); + } + + @Test + void _03_ShouldAcceptTheLargestTotal_WhenMillionUnitsAtTheHighestSafePrice() { + productAt("999999.99"); + insertEchoesWithId(); + + service().create(input(1L, 2L, 1_000_000)); + + assertThat(inserted().getTotal()).isEqualTo(new BigDecimal("999999990000.00")); + } + + @Test + void _04_ShouldRefuseAndInsertNothing_WhenTotalExceedsTheColumnLimit() { + productAt("1000000.00"); + + assertThatThrownBy(() -> service().create(input(1L, 2L, 1_000_000))) + .isInstanceOf(BusinessRuleException.class) + .hasMessage("Order total must not exceed 999999999999.99"); + + verify(orderDao, never()).insert(any()); + } + + @Test + void _05_ShouldRefuseAndInsertNothing_WhenTotalIsJustAboveTheLimit() { + productAt("1000000000000.00"); + + assertThatThrownBy(() -> service().create(input(1L, 2L, 1))).isInstanceOf(BusinessRuleException.class); + + verify(orderDao, never()).insert(any()); + } + + @Test + void _06_ShouldRoundHalfUpToTwoDecimals_WhenProductPriceHasMoreDecimals() { + productAt("0.005"); + insertEchoesWithId(); + + service().create(input(1L, 2L, 1)); + + assertThat(inserted().getTotal()).isEqualTo(new BigDecimal("0.01")); + } + + // ---------------------------------------------------------------- create: quantity and ids + + @ParameterizedTest + @ValueSource(ints = {0, -1, 1_000_001}) + void _07_ShouldRefuseAndTouchNothing_WhenQuantityIsOutOfRange(int quantity) { + assertThatThrownBy(() -> service().create(input(1L, 2L, quantity))) + .isInstanceOf(BusinessRuleException.class) + .hasMessage("Quantity must be between 1 and 1000000"); + + verifyNoInteractions(orderDao, productService, customerService); + } + + @ParameterizedTest + @ValueSource(ints = {1, 1_000_000}) + void _08_ShouldAcceptTheBoundary_WhenQuantityIsOneOrAMillion(int quantity) { + productAt("0.50"); + insertEchoesWithId(); + + service().create(input(1L, 2L, quantity)); + + assertThat(inserted().getQuantity()).isEqualTo(quantity); + } + + @Test + void _09_ShouldRefuseAndTouchNothing_WhenCustomerIdIsNull() { + assertThatThrownBy(() -> service().create(input(null, 2L, 1))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Customer id must not be null"); + + verifyNoInteractions(orderDao, productService, customerService); + } + + @Test + void _10_ShouldRefuseAndTouchNothing_WhenProductIdIsNull() { + assertThatThrownBy(() -> service().create(input(1L, null, 1))) + .isInstanceOf(BusinessRuleException.class).hasMessage("Product id must not be null"); + + verifyNoInteractions(orderDao, productService, customerService); + } + + // ---------------------------------------------------------------- create: other modules + + @Test + void _11_ShouldPropagateNotFoundAndNotCallTheProductService_WhenCustomerIsUnknown() { + when(customerService.get(9L)).thenThrow(new ResourceNotFoundException("Customer not found: 9")); + + assertThatThrownBy(() -> service().create(input(9L, 2L, 1))) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Customer not found: 9"); + + verifyNoInteractions(productService, orderDao); + } + + @Test + void _12_ShouldPropagateNotFoundAndInsertNothing_WhenProductIsUnknown() { + when(customerService.get(1L)).thenReturn(new CustomerDto(1L, "Alice", "Martin", null, null, null)); + when(productService.get(9L)).thenThrow(new ResourceNotFoundException("Product not found: 9")); + + assertThatThrownBy(() -> service().create(input(1L, 9L, 1))) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Product not found: 9"); + + verifyNoInteractions(orderDao); + } + + @Test + void _13_ShouldTurnIntoNotFound_WhenTheForeignKeyFailsAtInsert() { + productAt("10.00"); + when(orderDao.insert(any(Order.class))).thenThrow(new DataIntegrityViolationException("fk")); + + assertThatThrownBy(() -> service().create(input(1L, 2L, 1))) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessage("Customer or product no longer exists"); + } + + // ---------------------------------------------------------------- list and get + + @Test + void _14_ShouldReturnDtosAndPassFiltersToTheDao_WhenListingWithFilters() { + when(orderDao.findPage(1, 5, 3L, 4L)).thenReturn(List.of(new Order(9L, 3L, 4L, 2, new BigDecimal("5.00")))); + when(orderDao.count(3L, 4L)).thenReturn(6L); + + PageDto page = service().list(1, 5, 3L, 4L); + + assertThat(page.getContent()).containsExactly(new OrderDto(9L, 3L, 4L, 2, new BigDecimal("5.00"))); + assertThat(page.getPage()).isEqualTo(1); + assertThat(page.getSize()).isEqualTo(5); + assertThat(page.getTotalElements()).isEqualTo(6L); + } + + @Test + void _15_ShouldPassNullFiltersToTheDao_WhenListingWithoutFilters() { + when(orderDao.findPage(0, 20, null, null)).thenReturn(List.of()); + when(orderDao.count(null, null)).thenReturn(0L); + + assertThat(service().list(0, 20, null, null).getContent()).isEmpty(); + } + + @Test + void _16_ShouldKeepEmptyContentAndTotal_WhenPageIsBeyondTheLastOne() { + when(orderDao.findPage(99, 10, null, null)).thenReturn(List.of()); + when(orderDao.count(null, null)).thenReturn(3L); + + PageDto page = service().list(99, 10, null, null); + + assertThat(page.getContent()).isEmpty(); + assertThat(page.getTotalElements()).isEqualTo(3L); + } + + @Test + void _17_ShouldRefuseAndNotQuery_WhenPageIsNegative() { + assertThatThrownBy(() -> service().list(-1, 10, null, null)).isInstanceOf(BusinessRuleException.class); + + verifyNoInteractions(orderDao); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1, 101}) + void _18_ShouldRefuseAndNotQuery_WhenSizeIsOutOfBounds(int size) { + assertThatThrownBy(() -> service().list(0, size, null, null)).isInstanceOf(BusinessRuleException.class); + + verifyNoInteractions(orderDao); + } + + @ParameterizedTest + @ValueSource(ints = {1, 100}) + void _19_ShouldAcceptTheBoundarySizes_WhenSizeIsOneOrOneHundred(int size) { + when(orderDao.findPage(0, size, null, null)).thenReturn(List.of()); + when(orderDao.count(null, null)).thenReturn(0L); + + assertThat(service().list(0, size, null, null).getSize()).isEqualTo(size); + } + + @Test + void _20_ShouldReturnTheDto_WhenOrderExists() { + when(orderDao.findById(9L)).thenReturn(Optional.of(new Order(9L, 3L, 4L, 2, new BigDecimal("5.00")))); + + assertThat(service().get(9L)).isEqualTo(new OrderDto(9L, 3L, 4L, 2, new BigDecimal("5.00"))); + } + + @Test + void _21_ShouldThrowNotFound_WhenOrderDoesNotExist() { + when(orderDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().get(99L)) + .isInstanceOf(ResourceNotFoundException.class).hasMessage("Order not found: 99"); + } +} From 205e770dd43b7934ca7d62a54e42158626745de7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:10 +0400 Subject: [PATCH 138/144] test(ws): test the order controller with MockMvc The real security configuration and a real signed JWT are used with a mocked service. The tests check status and shape of the three endpoints, validation errors per field, the customer and product filters passed to the service, page and size violations, an out-of-range page keeping content as an empty array, the 404 and 422 mappings, and 405 for PUT, DELETE and PATCH. A client-supplied total or id is shown to be ignored. --- .../ws/mvc/AbstractOrderMvcTest.java | 92 ++++++ .../ws/mvc/OrderControllerTest.java | 279 ++++++++++++++++++ .../ws/mvc/OrderMvcTestConfig.java | 80 +++++ 3 files changed, 451 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractOrderMvcTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderControllerTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderMvcTestConfig.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractOrderMvcTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractOrderMvcTest.java new file mode 100644 index 0000000..92b7396 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AbstractOrderMvcTest.java @@ -0,0 +1,92 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.mockito.Mockito.reset; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +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; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.order.service.OrderService; +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; + +/** + * Common base of the order controller tests: builds MockMvc on the shared OrderMvcTestConfig context, resets + * the mocked services before each test and offers request helpers (signed tokens, JSON bodies). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// MockMvc + springSecurity() runs the real filter chain in front of the DispatcherServlet (same as +// AbstractCatalogMvcTest): the JWT filter and @PreAuthorize are exercised, only the services are mocked. +@SpringJUnitWebConfig(OrderMvcTestConfig.class) +abstract class AbstractOrderMvcTest { + + @Autowired + private WebApplicationContext context; + @Autowired + protected OrderService orderService; + @Autowired + protected AuthService authService; + + protected MockMvc mockMvc; + + @BeforeEach + void setUpMockMvc() { + reset(orderService, 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 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/OrderControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderControllerTest.java new file mode 100644 index 0000000..61030c9 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderControllerTest.java @@ -0,0 +1,279 @@ +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.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +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.core.order.dto.OrderDto; +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * MockMvc tests of OrderController with OrderService mocked but the real security and validation: statuses, ApiResponse shape, filters, page/size bounds, per-field body validation, the 404/422 refusals and the missing PUT/DELETE. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class OrderControllerTest extends AbstractOrderMvcTest { + + private static final String READ = "ORDER:READ"; + private static final String WRITE = "ORDER:WRITE"; + private static final String URL = "/api/v1/orders"; + private static final OrderDto ORDER = new OrderDto(1L, 2L, 3L, 3, new BigDecimal("59.97")); + + private static String body(String customerId, String productId, String quantity) { + return "{\"customerId\":" + customerId + ",\"productId\":" + productId + ",\"quantity\":" + quantity + "}"; + } + + // ---------------------------------------------------------------- GET /orders + + @Test + void _01_ShouldReturn200WithPageAndDefaults_WhenTokenHoldsOrderRead() throws Exception { + when(orderService.list(0, 20, null, null)).thenReturn(new PageDto<>(List.of(ORDER), 0, 20, 1)); + + JsonNode data = assertSuccess(call(getWith(URL, token(READ))), 200).get("data"); + + JsonNode first = data.get("content").get(0); + assertThat(first.get("id").asLong()).isEqualTo(1L); + assertThat(first.get("customerId").asLong()).isEqualTo(2L); + assertThat(first.get("productId").asLong()).isEqualTo(3L); + assertThat(first.get("quantity").asInt()).isEqualTo(3); + assertThat(first.get("total").decimalValue()).isEqualByComparingTo("59.97"); + 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_ShouldPassPageSizeAndBothFiltersToTheService_WhenTheyAreGiven() throws Exception { + when(orderService.list(2, 5, 7L, 8L)).thenReturn(new PageDto<>(List.of(), 2, 5, 11)); + + JsonNode data = assertSuccess(call(getWith(URL + "?page=2&size=5&customerId=7&productId=8", token(READ))), 200) + .get("data"); + + assertThat(data.get("page").asInt()).isEqualTo(2); + verify(orderService).list(2, 5, 7L, 8L); + } + + @Test + void _03_ShouldPassOnlyTheCustomerFilter_WhenProductIdIsAbsent() throws Exception { + when(orderService.list(0, 20, 7L, null)).thenReturn(new PageDto<>(List.of(ORDER), 0, 20, 1)); + + assertSuccess(call(getWith(URL + "?customerId=7", token(READ))), 200); + + verify(orderService).list(0, 20, 7L, null); + } + + @Test + void _04_ShouldPassOnlyTheProductFilter_WhenCustomerIdIsAbsent() throws Exception { + when(orderService.list(0, 20, null, 8L)).thenReturn(new PageDto<>(List.of(ORDER), 0, 20, 1)); + + assertSuccess(call(getWith(URL + "?productId=8", token(READ))), 200); + + verify(orderService).list(0, 20, null, 8L); + } + + @Test + void _05_ShouldReturn400_WhenAFilterIsNotANumber() throws Exception { + assertError(call(getWith(URL + "?customerId=abc", token(READ))), 400); + assertError(call(getWith(URL + "?productId=abc", token(READ))), 400); + verifyNoInteractions(orderService); + } + + @Test + void _06_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + when(orderService.list(99, 10, null, null)).thenReturn(new PageDto<>(List.of(), 99, 10, 3)); + + JsonNode data = assertSuccess(call(getWith(URL + "?page=99&size=10", token(READ))), 200).get("data"); + + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("totalElements").asLong()).isEqualTo(3L); + } + + @Test + void _07_ShouldReturn400NamingPage_WhenPageIsNegative() throws Exception { + JsonNode json = assertError(call(getWith(URL + "?page=-1", token(READ))), 400); + + assertThat(json.get("message").asText()).contains("page: ").doesNotContain("size: "); + verifyNoInteractions(orderService); + } + + @Test + void _08_ShouldReturn400NamingSize_WhenSizeIsZeroOrAbove100() throws Exception { + JsonNode zero = assertError(call(getWith(URL + "?size=0", token(READ))), 400); + JsonNode big = assertError(call(getWith(URL + "?size=101", token(READ))), 400); + + assertThat(zero.get("message").asText()).contains("size: ").doesNotContain("page: "); + assertThat(big.get("message").asText()).contains("size: "); + verifyNoInteractions(orderService); + } + + @Test + void _09_ShouldAcceptBoundarySizes_WhenSizeIs1Or100() throws Exception { + when(orderService.list(0, 1, null, null)).thenReturn(new PageDto<>(List.of(), 0, 1, 0)); + when(orderService.list(0, 100, null, null)).thenReturn(new PageDto<>(List.of(), 0, 100, 0)); + + assertSuccess(call(getWith(URL + "?size=1", token(READ))), 200); + assertSuccess(call(getWith(URL + "?size=100", token(READ))), 200); + } + + // ---------------------------------------------------------------- GET /orders/{id} + + @Test + void _10_ShouldReturn200WithTheOrder_WhenOrderExists() throws Exception { + when(orderService.get(1L)).thenReturn(ORDER); + + JsonNode json = assertSuccess(call(getWith(URL + "/1", token(READ))), 200); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(1L); + assertThat(json.get("data").get("total").decimalValue()).isEqualByComparingTo("59.97"); + assertThat(json.get("message").asText()).isEqualTo("Order"); + } + + @Test + void _11_ShouldReturn404ApiResponse_WhenOrderDoesNotExist() throws Exception { + when(orderService.get(99L)).thenThrow(new ResourceNotFoundException("Order not found: 99")); + + JsonNode json = assertError(call(getWith(URL + "/99", token(READ))), 404); + + assertThat(json.get("message").asText()).isEqualTo("Order not found: 99"); + } + + @Test + void _12_ShouldReturn400_WhenOrderIdIsNotANumber() throws Exception { + assertError(call(getWith(URL + "/abc", token(READ))), 400); + verifyNoInteractions(orderService); + } + + // ---------------------------------------------------------------- POST /orders + + @Test + void _13_ShouldReturn201WithCreatedOrder_WhenBodyIsValid() throws Exception { + when(orderService.create(any(OrderDto.class))).thenReturn(ORDER); + + JsonNode json = assertSuccess(call(postJson(URL, token(WRITE), body("2", "3", "3"))), 201); + + assertThat(json.get("data").get("id").asLong()).isEqualTo(1L); + assertThat(json.get("data").get("total").decimalValue()).isEqualByComparingTo("59.97"); + assertThat(json.get("message").asText()).isEqualTo("Order created"); + ArgumentCaptor sent = ArgumentCaptor.forClass(OrderDto.class); + verify(orderService).create(sent.capture()); + assertThat(sent.getValue()).isEqualTo(new OrderDto(null, 2L, 3L, 3, null)); + } + + @Test + void _14_ShouldIgnoreAClientSuppliedTotal_WhenBodyCarriesOne() throws Exception { + when(orderService.create(any(OrderDto.class))).thenReturn(ORDER); + + assertSuccess(call(postJson(URL, token(WRITE), + "{\"customerId\":2,\"productId\":3,\"quantity\":3,\"total\":0.01,\"id\":77}")), 201); + + ArgumentCaptor sent = ArgumentCaptor.forClass(OrderDto.class); + verify(orderService).create(sent.capture()); + assertThat(sent.getValue().getTotal()).isNull(); + assertThat(sent.getValue().getId()).isNull(); + } + + @Test + void _15_ShouldReturn400NamingTheField_WhenCustomerIdOrProductIdIsNull() throws Exception { + JsonNode customer = assertError(call(postJson(URL, token(WRITE), body("null", "3", "1"))), 400); + JsonNode product = assertError(call(postJson(URL, token(WRITE), body("2", "null", "1"))), 400); + JsonNode missing = assertError(call(postJson(URL, token(WRITE), "{\"quantity\":1}")), 400); + + assertThat(customer.get("message").asText()).contains("customerId: ").doesNotContain("productId: "); + assertThat(product.get("message").asText()).contains("productId: ").doesNotContain("customerId: "); + assertThat(missing.get("message").asText()).contains("customerId: ").contains("productId: "); + verifyNoInteractions(orderService); + } + + @Test + void _16_ShouldReturn400NamingQuantity_WhenQuantityIsMissingZeroOrTooLarge() throws Exception { + JsonNode missing = assertError(call(postJson(URL, token(WRITE), "{\"customerId\":2,\"productId\":3}")), 400); + JsonNode zero = assertError(call(postJson(URL, token(WRITE), body("2", "3", "0"))), 400); + JsonNode negative = assertError(call(postJson(URL, token(WRITE), body("2", "3", "-1"))), 400); + JsonNode big = assertError(call(postJson(URL, token(WRITE), body("2", "3", "1000001"))), 400); + + for (JsonNode json : List.of(missing, zero, negative, big)) { + assertThat(json.get("message").asText()).contains("quantity: "); + } + verifyNoInteractions(orderService); + } + + @Test + void _17_ShouldReturn201_WhenQuantityIsOnItsLimits() throws Exception { + when(orderService.create(any(OrderDto.class))).thenReturn(ORDER); + + assertSuccess(call(postJson(URL, token(WRITE), body("2", "3", "1"))), 201); + assertSuccess(call(postJson(URL, token(WRITE), body("2", "3", "1000000"))), 201); + } + + @Test + void _18_ShouldReturn400_WhenBodyIsMalformedJsonOrHasTheWrongTypes() throws Exception { + assertError(call(postJson(URL, token(WRITE), "{\"customerId\":")), 400); + assertError(call(postJson(URL, token(WRITE), body("\"abc\"", "3", "1"))), 400); + verifyNoInteractions(orderService); + } + + @Test + void _19_ShouldReturn404_WhenTheServiceReportsAnUnknownCustomer() throws Exception { + when(orderService.create(any(OrderDto.class))).thenThrow(new ResourceNotFoundException("Customer not found: 2")); + + JsonNode json = assertError(call(postJson(URL, token(WRITE), body("2", "3", "1"))), 404); + + assertThat(json.get("message").asText()).isEqualTo("Customer not found: 2"); + } + + @Test + void _20_ShouldReturn404_WhenTheServiceReportsAnUnknownProduct() throws Exception { + when(orderService.create(any(OrderDto.class))).thenThrow(new ResourceNotFoundException("Product not found: 3")); + + JsonNode json = assertError(call(postJson(URL, token(WRITE), body("2", "3", "1"))), 404); + + assertThat(json.get("message").asText()).isEqualTo("Product not found: 3"); + } + + @Test + void _21_ShouldReturn422_WhenTheServiceRefusesTheTotal() throws Exception { + when(orderService.create(any(OrderDto.class))) + .thenThrow(new BusinessRuleException("Order total must not exceed 999999999999.99")); + + JsonNode json = assertError(call(postJson(URL, token(WRITE), body("2", "3", "1000000"))), 422); + + assertThat(json.get("message").asText()).isEqualTo("Order total must not exceed 999999999999.99"); + } + + @Test + void _22_ShouldReturn415_WhenContentTypeIsNotJson() throws Exception { + assertError(call(MockMvcRequestBuilders.post(URL) + .header("Authorization", bearer(token(WRITE))).contentType(MediaType.TEXT_PLAIN).content("x")), 415); + verifyNoInteractions(orderService); + } + + // ---------------------------------------------------------------- orders are immutable + + @Test + void _23_ShouldReturn405ApiResponse_WhenPutDeleteOrPatchIsUsed() throws Exception { + assertError(call(putJson(URL + "/1", token(WRITE), body("2", "3", "1"))), 405); + assertError(call(deleteWith(URL + "/1", token(WRITE))), 405); + assertError(call(patchWith(URL + "/1", token(WRITE))), 405); + assertError(call(deleteWith(URL, token(WRITE))), 405); + verifyNoInteractions(orderService); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderMvcTestConfig.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderMvcTestConfig.java new file mode 100644 index 0000000..db175c3 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderMvcTestConfig.java @@ -0,0 +1,80 @@ +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.order.service.OrderService; +import com.edgareldy.springjdbctutorial.ws.config.SecurityConfig; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.controller.OrderController; +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 order controller tests: MVC, the REAL SecurityConfig (JWT filter, method security + * with CustomPermissionEvaluator, 401/403 handlers), the real controller and advice, and a Mockito mock in place + * of OrderService (no database). + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as CatalogMvcTestConfig, kept in ws.mvc and never in ws.controller so WebMvcConfig's component scan +// cannot pick it up. +@Configuration +@EnableWebMvc +@Import(SecurityConfig.class) +public class OrderMvcTestConfig implements WebMvcConfigurer { + + private final WebMvcConfig real = new WebMvcConfig(); + + @Bean + Clock clock() { + return Clock.systemUTC(); + } + + @Bean + ObjectMapper objectMapper() { + return real.objectMapper(); + } + + @Bean + AuthService authService() { + return mock(AuthService.class); + } + + @Bean + OrderService orderService() { + return mock(OrderService.class); + } + + @Bean + OrderController orderController(OrderService orderService) { + return new OrderController(orderService); + } + + @Bean + GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Override + public void extendMessageConverters(List> converters) { + real.extendMessageConverters(converters); + } + + @Override + public Validator getValidator() { + return real.getValidator(); + } +} From 6df29193344cc8bd0a960995149473ae0979bf05 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:10 +0400 Subject: [PATCH 139/144] test(ws): check the permission matrix of the order endpoints A parameterized test asserts, for each endpoint, that no token or a revoked one gives 401, that a token holding every other permission or none gives 403, and that a token holding only the required ORDER permission succeeds. --- .../ws/mvc/OrderEndpointSecurityTest.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderEndpointSecurityTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderEndpointSecurityTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderEndpointSecurityTest.java new file mode 100644 index 0000000..5a28639 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/OrderEndpointSecurityTest.java @@ -0,0 +1,111 @@ +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.ArgumentMatchers.any; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; +import com.edgareldy.springjdbctutorial.core.order.dto.OrderDto; +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigDecimal; +import java.util.List; +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 3 order endpoints of the README table, checked through the real filter chain and method security: no token or a revoked token gives 401, a token holding every permission EXCEPT the required one (or none at all) gives 403, all as ApiResponse errors and without reaching the (mocked) service; the right permission alone gives 2xx. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +class OrderEndpointSecurityTest extends AbstractOrderMvcTest { + + private static final String BODY = "{\"customerId\":2,\"productId\":3,\"quantity\":1}"; + + // 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/orders", null, "ORDER:READ"), + Arguments.of(HttpMethod.GET, "/api/v1/orders/1", null, "ORDER:READ"), + Arguments.of(HttpMethod.POST, "/api/v1/orders", BODY, "ORDER: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 { + assertError(call(request(method, url, body, null)), 401); + verifyNoInteractions(orderService); + } + + @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(orderService); + } + + @ParameterizedTest(name = "{0} {1} with an empty token") + @MethodSource("endpoints") + void _03_ShouldReturn403ApiResponse_WhenTokenHoldsNoPermissionAtAll(HttpMethod method, String url, String body, + String required) throws Exception { + assertError(call(request(method, url, body, token())), 403); + verifyNoInteractions(orderService); + } + + @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(orderService); + } + + @ParameterizedTest(name = "{0} {1} with {3}") + @MethodSource("endpoints") + void _05_ShouldNotReturn401Or403_WhenTokenHoldsOnlyTheRequiredPermission(HttpMethod method, String url, + String body, String required) + throws Exception { + // Mocks return null, which would make the controller fail on the converter, so give the paths a real + // answer; the point of this test is only that the permission opens the door. + OrderDto order = new OrderDto(1L, 2L, 3L, 1, new BigDecimal("10.00")); + when(orderService.list(0, 20, null, null)).thenReturn(new PageDto(List.of(), 0, 20, 0)); + when(orderService.get(1L)).thenReturn(order); + when(orderService.create(any())).thenReturn(order); + + MvcResult result = call(request(method, url, body, token(required))); + + assertThat(result.getResponse().getStatus()).isIn(200, 201); + } +} From eaf6676cc7ea901e14fe588fbc2d971536360a24 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:10 +0400 Subject: [PATCH 140/144] test(ws): run the order flows on the real stack Customers, categories and products are created through their real endpoints, then the tests prove that the total is computed from the product price at creation and stays after the price changes, the filters and pagination, unknown customers and products giving 404 with nothing stored, and invalid quantities giving 400. Deleting a customer or a product referenced by an order gives 422 with the exact message, then succeeds once the order is removed. A decimal quantity such as 1.5 is refused with 400 and stores nothing, which pins the Jackson fix. --- .../integration/OrderFlowIntegrationTest.java | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/OrderFlowIntegrationTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/OrderFlowIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/OrderFlowIntegrationTest.java new file mode 100644 index 0000000..a63ed35 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/OrderFlowIntegrationTest.java @@ -0,0 +1,368 @@ +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.TestUser; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +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.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * End-to-end order flows through the REAL stack (real WebMvcConfig, real services, real PostgreSQL migrated by + * Flyway, real logins): the total computed from the product price at creation, the filters and pagination, the + * 404 and 422 refusals, the deletes of a customer or a product refused while an order references them, and the + * permission enforcement. + *

+ * Created edgar.muhamyangabo on 9/20/26 + * Author : edgar.muhamyangabo + * Date : 9/20/26 + * Project : spring-jdbc-tutorial + */ +// Same recipe as CustomerFlowIntegrationTest: the whole application context on the shared Testcontainers database. +// Customers, categories and products are created through their own REAL endpoints with an admin, named with an +// it- prefix; tearDown() removes the orders first (they reference the others), then the rest, then the +// users and roles of RbacTestSupport. +@SpringJUnitWebConfig(WebMvcConfig.class) +class OrderFlowIntegrationTest { + + private static final String PREFIX = "it-"; + private static final String URL = "/api/v1/orders"; + + @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); + } + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE first_name LIKE ?) " + + "OR product_id IN (SELECT id FROM products WHERE product_name LIKE ?)", PREFIX + "%", PREFIX + "%"); + jdbcTemplate.update("DELETE FROM customers WHERE first_name LIKE ?", PREFIX + "%"); + jdbcTemplate.update("DELETE FROM products WHERE product_name LIKE ?", PREFIX + "%"); + jdbcTemplate.update("DELETE FROM categories WHERE category_name LIKE ?", PREFIX + "%"); + support.close(); + } + + private static String unique() { + return PREFIX + UUID.randomUUID().toString().substring(0, 8); + } + + private static long idOf(MvcResult result) throws Exception { + return assertSuccess(result, 201).get("data").get("id").asLong(); + } + + private long customer(String jwt) throws Exception { + return idOf(support.post(jwt, "/api/v1/customers", "{\"firstName\":\"" + unique() + "\",\"lastName\":\"Martin\"}")); + } + + private long category(String jwt) throws Exception { + return idOf(support.post(jwt, "/api/v1/categories", "{\"categoryName\":\"" + unique() + "\"}")); + } + + private long product(String jwt, long categoryId, String unitPrice) throws Exception { + return idOf(support.post(jwt, "/api/v1/products", "{\"categoryId\":" + categoryId + ",\"productName\":\"" + + unique() + "\",\"unitPrice\":" + unitPrice + "}")); + } + + private static String orderBody(Object customerId, Object productId, Object quantity) { + return "{\"customerId\":" + customerId + ",\"productId\":" + productId + ",\"quantity\":" + quantity + "}"; + } + + private long order(String jwt, long customerId, long productId, int quantity) throws Exception { + return idOf(support.post(jwt, URL, orderBody(customerId, productId, quantity))); + } + + private long count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Long.class, args); + } + + private static List ids(JsonNode page) { + List ids = new ArrayList<>(); + for (JsonNode order : page.get("data").get("content")) { + ids.add(order.get("id").asLong()); + } + return ids; + } + + @Test + void _01_ShouldComputeTheTotalFromTheProductPrice_WhenAdminCreatesAnOrder() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "19.99"); + + JsonNode created = assertSuccess(support.post(jwt, URL, orderBody(customerId, productId, 3)), 201); + + assertThat(created.get("message").asText()).isEqualTo("Order created"); + assertThat(created.get("data").get("customerId").asLong()).isEqualTo(customerId); + assertThat(created.get("data").get("productId").asLong()).isEqualTo(productId); + assertThat(created.get("data").get("quantity").asInt()).isEqualTo(3); + assertThat(created.get("data").get("total").decimalValue()).isEqualByComparingTo("59.97"); + long id = created.get("data").get("id").asLong(); + JsonNode read = assertSuccess(support.get(jwt, URL + "/" + id), 200).get("data"); + assertThat(read.get("total").decimalValue()).isEqualByComparingTo("59.97"); + assertThat(read.get("quantity").asInt()).isEqualTo(3); + assertThat(count("SELECT COUNT(*) FROM orders WHERE id = ? AND total = 59.97", id)).isEqualTo(1L); + } + + @Test + void _02_ShouldKeepTheTotal_WhenTheProductPriceIsUpdatedLater() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long categoryId = category(jwt); + long productId = product(jwt, categoryId, "10.00"); + long id = order(jwt, customerId, productId, 2); + + assertSuccess(support.put(jwt, "/api/v1/products/" + productId, + "{\"categoryId\":" + categoryId + ",\"productName\":\"" + unique() + "\",\"unitPrice\":99.00}"), 200); + + JsonNode read = assertSuccess(support.get(jwt, URL + "/" + id), 200).get("data"); + assertThat(read.get("total").decimalValue()).isEqualByComparingTo("20.00"); + long next = order(jwt, customerId, productId, 2); + assertThat(assertSuccess(support.get(jwt, URL + "/" + next), 200).get("data").get("total").decimalValue()) + .isEqualByComparingTo("198.00"); + } + + @Test + void _03_ShouldFilterAndPaginate_WhenListingWithCustomerIdAndProductId() throws Exception { + String jwt = support.createAdmin().getJwt(); + long c1 = customer(jwt); + long c2 = customer(jwt); + long categoryId = category(jwt); + long p1 = product(jwt, categoryId, "1.00"); + long p2 = product(jwt, categoryId, "2.00"); + long o1 = order(jwt, c1, p1, 1); + long o2 = order(jwt, c1, p2, 1); + long o3 = order(jwt, c2, p1, 1); + long o4 = order(jwt, c1, p1, 2); + + assertThat(ids(assertSuccess(support.get(jwt, URL + "?customerId=" + c1), 200))).containsExactly(o1, o2, o4); + assertThat(ids(assertSuccess(support.get(jwt, URL + "?productId=" + p1), 200))).containsExactly(o1, o3, o4); + assertThat(ids(assertSuccess(support.get(jwt, URL + "?customerId=" + c1 + "&productId=" + p1), 200))) + .containsExactly(o1, o4); + assertThat(ids(assertSuccess(support.get(jwt, URL + "?customerId=" + c2 + "&productId=" + p2), 200))).isEmpty(); + + JsonNode second = assertSuccess(support.get(jwt, URL + "?customerId=" + c1 + "&size=2&page=1"), 200); + assertThat(ids(second)).containsExactly(o4); + assertThat(second.get("data").get("totalElements").asLong()).isEqualTo(3L); + assertThat(second.get("data").get("totalPages").asInt()).isEqualTo(2); + } + + @Test + void _04_ShouldReturn404AndStoreNothing_WhenCustomerOrProductDoesNotExist() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "5.00"); + + JsonNode noCustomer = assertError(support.post(jwt, URL, orderBody(999999999L, productId, 1)), 404); + JsonNode noProduct = assertError(support.post(jwt, URL, orderBody(customerId, 999999999L, 1)), 404); + + assertThat(noCustomer.get("message").asText()).isEqualTo("Customer not found: 999999999"); + assertThat(noProduct.get("message").asText()).isEqualTo("Product not found: 999999999"); + assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ? OR product_id = ?", customerId, productId)) + .isZero(); + } + + @Test + void _05_ShouldReturn400AndStoreNothing_WhenBodyIsInvalid() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "5.00"); + + for (Object quantity : List.of(0, -1, 1000001)) { + JsonNode json = assertError(support.post(jwt, URL, orderBody(customerId, productId, quantity)), 400); + assertThat(json.get("message").asText()).contains("quantity: "); + } + JsonNode missing = assertError(support.post(jwt, URL, "{\"customerId\":" + customerId + ",\"productId\":" + + productId + "}"), 400); + JsonNode noIds = assertError(support.post(jwt, URL, "{\"quantity\":1}"), 400); + + assertThat(missing.get("message").asText()).contains("quantity: "); + assertThat(noIds.get("message").asText()).contains("customerId: ").contains("productId: "); + assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ?", customerId)).isZero(); + } + + @Test + void _06_ShouldReturn422AndStoreNothing_WhenTheTotalExceedsTheColumnLimit() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "9999999999.99"); + + JsonNode json = assertError(support.post(jwt, URL, orderBody(customerId, productId, 1000000)), 422); + + assertThat(json.get("message").asText()).isEqualTo("Order total must not exceed 999999999999.99"); + assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ?", customerId)).isZero(); + // 99 x 9999999999.99 is still below the limit and goes through + assertSuccess(support.post(jwt, URL, orderBody(customerId, productId, 99)), 201); + } + + @Test + void _07_ShouldRefuseTheCustomerDeleteWith422UntilTheOrderIsRemoved_WhenAnOrderReferencesTheCustomer() + throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "5.00"); + long orderId = order(jwt, customerId, productId, 1); + + JsonNode refused = assertError(support.delete(jwt, "/api/v1/customers/" + customerId), 422); + + assertThat(refused.get("message").asText()).isEqualTo("Customer is referenced by orders"); + assertSuccess(support.get(jwt, "/api/v1/customers/" + customerId), 200); + jdbcTemplate.update("DELETE FROM orders WHERE id = ?", orderId); + assertSuccess(support.delete(jwt, "/api/v1/customers/" + customerId), 200); + assertError(support.get(jwt, "/api/v1/customers/" + customerId), 404); + } + + @Test + void _08_ShouldRefuseTheProductDeleteWith422UntilTheOrderIsRemoved_WhenAnOrderReferencesTheProduct() + throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "5.00"); + long orderId = order(jwt, customerId, productId, 1); + + JsonNode refused = assertError(support.delete(jwt, "/api/v1/products/" + productId), 422); + + assertThat(refused.get("message").asText()).isEqualTo("Product is referenced by orders"); + assertSuccess(support.get(jwt, "/api/v1/products/" + productId), 200); + jdbcTemplate.update("DELETE FROM orders WHERE id = ?", orderId); + assertSuccess(support.delete(jwt, "/api/v1/products/" + productId), 200); + assertError(support.get(jwt, "/api/v1/products/" + productId), 404); + } + + @Test + void _09_ShouldReturn404_WhenOrderDoesNotExist() throws Exception { + String jwt = support.createAdmin().getJwt(); + + JsonNode json = assertError(support.get(jwt, URL + "/999999999"), 404); + + assertThat(json.get("message").asText()).isEqualTo("Order not found: 999999999"); + } + + @Test + void _10_ShouldReturn400NamingTheParameter_WhenPageOrSizeIsOutOfBounds() throws Exception { + String jwt = support.createAdmin().getJwt(); + + JsonNode page = assertError(support.get(jwt, URL + "?page=-1"), 400); + JsonNode zero = assertError(support.get(jwt, URL + "?size=0"), 400); + JsonNode big = assertError(support.get(jwt, URL + "?size=101"), 400); + + assertThat(page.get("message").asText()).contains("page: "); + assertThat(zero.get("message").asText()).contains("size: "); + assertThat(big.get("message").asText()).contains("size: "); + assertSuccess(support.get(jwt, URL + "?size=1"), 200); + assertSuccess(support.get(jwt, URL + "?size=100"), 200); + } + + @Test + void _11_ShouldKeepEmptyContentInTheJson_WhenPageIsBeyondTheLastOne() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + order(jwt, customerId, product(jwt, category(jwt), "1.00"), 1); + + JsonNode data = assertSuccess(support.get(jwt, URL + "?customerId=" + customerId + "&page=100000&size=10"), 200) + .get("data"); + + assertThat(data.has("content")).isTrue(); + assertThat(data.get("content").isArray()).isTrue(); + assertThat(data.get("content")).isEmpty(); + assertThat(data.get("page").asInt()).isEqualTo(100000); + assertThat(data.get("totalElements").asLong()).isEqualTo(1L); + } + + @Test + void _12_ShouldReturn401_WhenNoTokenIsSent() throws Exception { + assertError(support.get(null, URL), 401); + assertError(support.get(null, URL + "/1"), 401); + assertError(support.post(null, URL, orderBody(1, 1, 1)), 401); + } + + @Test + void _13_ShouldReturn403AndStoreNothing_WhenUserLacksTheWritePermission() throws Exception { + TestUser simple = support.createSimpleUser(); + TestUser readOnly = support.createUser(true, false, support.createRole("ORDER:READ")); + String admin = support.createAdmin().getJwt(); + long customerId = customer(admin); + long productId = product(admin, category(admin), "5.00"); + long id = order(admin, customerId, productId, 1); + + for (TestUser user : List.of(simple, readOnly)) { + assertError(support.post(user.getJwt(), URL, orderBody(customerId, productId, 1)), 403); + } + + assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ?", customerId)).isEqualTo(1L); + // The simple user has no read permission either, the read-only user does + assertError(support.get(simple.getJwt(), URL), 403); + assertError(support.get(simple.getJwt(), URL + "/" + id), 403); + assertSuccess(support.get(readOnly.getJwt(), URL + "/" + id), 200); + assertSuccess(support.get(readOnly.getJwt(), URL + "?customerId=" + customerId), 200); + } + + @Test + void _14_ShouldCreateButNotRead_WhenUserHoldsOnlyOrderWrite() throws Exception { + String admin = support.createAdmin().getJwt(); + long customerId = customer(admin); + long productId = product(admin, category(admin), "5.00"); + TestUser writer = support.createUser(true, false, support.createRole("ORDER:WRITE")); + + long id = order(writer.getJwt(), customerId, productId, 2); + + assertError(support.get(writer.getJwt(), URL + "/" + id), 403); + assertError(support.get(writer.getJwt(), URL), 403); + } + + @Test + void _15_ShouldReturn405_WhenAnOrderIsUpdatedOrDeleted() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long id = order(jwt, customerId, product(jwt, category(jwt), "5.00"), 1); + + assertError(support.put(jwt, URL + "/" + id, orderBody(customerId, 1, 1)), 405); + assertError(support.delete(jwt, URL + "/" + id), 405); + assertSuccess(support.get(jwt, URL + "/" + id), 200); + } + + // Non-regression: Jackson used to truncate a decimal quantity such as 1.5 to 1 and create the order + @Test + void _16_ShouldReturn400AndStoreNothing_WhenQuantityIsADecimal() throws Exception { + String jwt = support.createAdmin().getJwt(); + long customerId = customer(jwt); + long productId = product(jwt, category(jwt), "5.00"); + + assertError(support.post(jwt, URL, orderBody(customerId, productId, "1.5")), 400); + + assertThat(count("SELECT COUNT(*) FROM orders WHERE customer_id = ?", customerId)).isZero(); + } +} From 79f133c21b3aa06c6bc9bbfd19f91e8cff2f65c4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:10 +0400 Subject: [PATCH 141/144] fix(ws): refuse a decimal where an integer is expected By default Jackson accepts a floating point JSON number for an int field and silently truncates it, so a body with a quantity of 1.5 created an order for one unit instead of being refused. The shared ObjectMapper now disables DeserializationFeature.ACCEPT_FLOAT_AS_INT, so such a body is a malformed request and answers 400 through the existing handler. An integration test on the order endpoint pins the behaviour. --- .../springjdbctutorial/ws/config/WebMvcConfig.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index a3e7e60..2f3ecc7 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -1,5 +1,6 @@ package com.edgareldy.springjdbctutorial.ws.config; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.edgareldy.springjdbctutorial.core.common.config.CommonConfig; @@ -51,7 +52,10 @@ public class WebMvcConfig implements WebMvcConfigurer { @Bean public ObjectMapper objectMapper() { return Jackson2ObjectMapperBuilder.json() - .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, + // Jackson would silently truncate 1.5 to 1 for an int field, i.e. order one unit + // instead of refusing the body: a decimal is never a valid integer here + DeserializationFeature.ACCEPT_FLOAT_AS_INT) .build(); } From f8cd73f37e4c67c269be73d0f55a8601b35b2933 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:16:10 +0400 Subject: [PATCH 142/144] docs(readme): tick the feature/order tasks and correct the order dependencies The three order tasks are checked: the entity, dto, RowMapper, mapper, DAO, service and configuration of core/order, the controller and converter in ws with the WebMvcConfig import, and the tests at all five layers with a dedicated fixture for the DAO tests. The project tree said the order service depends on CategoryService as well, but computing a total and checking the references only needs ProductService and CustomerService, so the README now says so. --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6cd8409..df312b7 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ spring-jdbc-tutorial/ │ ├── dto/ (OrderDto.java) │ ├── mapper/ (OrderRowMapper.java, OrderMapper.java) │ ├── dao/ (OrderDao.java + impl/) -│ ├── service/ (OrderService.java + impl/, depends on CategoryService/ProductService/CustomerService) +│ ├── service/ (OrderService.java + impl/, depends on ProductService/CustomerService only: the category is never needed) │ └── config/ (DaoConfig.java, ServiceConfig.java) └── ws/ ├── pom.xml (depends on core/auth, core/catalog, core/customer, core/order) @@ -438,11 +438,11 @@ Full CRUD for users, roles, and permissions, still inside `core/auth`. Assignmen ### Tasks -- [ ] `core/order`: `Order` entity (`@Table("orders")`) and `OrderDto`, `OrderRowMapper` (entity) plus `OrderMapper` (entity ↔ dto), `OrderDao` + impl, `OrderService` + impl (depends on `catalog`'s `ProductService` and `customer`'s `CustomerService` - their service interfaces, never their DAOs directly): computes `total = quantity * product.unitPrice`, checks the referenced customer/product exist -- [ ] `core/order`'s `DaoConfig`/`ServiceConfig` (the latter injecting `ProductService`/`CustomerService` beans from the other modules' contexts) -- [ ] `ws`: `OrderController`, `OrderConverter` -- [ ] `WebMvcConfig` updated to import `core/order`'s config -- [ ] Tests at all five layers, `OrderDao` with its own fixture dataset, the total computation, and the not-found cases for a bad `customerId`/`productId` +- [x] `core/order`: `Order` entity (`@Table("orders")`) and `OrderDto`, `OrderRowMapper` (entity) plus `OrderMapper` (entity ↔ dto), `OrderDao` + impl, `OrderService` + impl (depends on `catalog`'s `ProductService` and `customer`'s `CustomerService` - their service interfaces, never their DAOs directly): computes `total = quantity * product.unitPrice`, checks the referenced customer/product exist +- [x] `core/order`'s `DaoConfig`/`ServiceConfig` (the latter injecting `ProductService`/`CustomerService` beans from the other modules' contexts) +- [x] `ws`: `OrderController`, `OrderConverter` +- [x] `WebMvcConfig` updated to import `core/order`'s config +- [x] Tests at all five layers, `OrderDao` with its own fixture dataset, the total computation, and the not-found cases for a bad `customerId`/`productId` ## Order of work From 87694e43f32cdb5d800a78ba4ed0a1597ae895b5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:17:23 +0400 Subject: [PATCH 143/144] refactor(order): drop an unreachable second total check The exact product is already compared with the column maximum, which has two decimals, so rounding a value at or below it to two decimals can never exceed it. The second comparison after setScale and its duplicated message could never fire. The rounding itself stays, and the unit tests that pin the totals still pass. --- .../core/order/service/impl/OrderServiceImpl.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java index 07df08e..e13eea9 100644 --- a/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java @@ -93,10 +93,8 @@ public OrderDto create(OrderDto input) { if (exact.compareTo(MAX_TOTAL) > 0) { throw new BusinessRuleException("Order total must not exceed " + MAX_TOTAL.toPlainString()); } + // MAX_TOTAL has two decimals, so rounding a value at or below it to two decimals cannot exceed it BigDecimal total = exact.setScale(2, RoundingMode.HALF_UP); - if (total.compareTo(MAX_TOTAL) > 0) { - throw new BusinessRuleException("Order total must not exceed " + MAX_TOTAL.toPlainString()); - } Order order = new Order(null, input.getCustomerId(), input.getProductId(), quantity, total); try { From e482c508a2f997fb7f5d0550f41cc8f481395422 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sun, 20 Sep 2026 01:23:05 +0400 Subject: [PATCH 144/144] docs(readme): correct the run instructions to match the packaged setup The last section told the reader to run docker-compose and then deploy the WAR by hand, or to use a tomcat10:deploy goal that was never configured. docker compose now builds the WAR and deploys it itself, needs a mandatory database password and a mounted RSA key pair, and publishes the port given by APP_PORT. The section now lists how to run the tests (Docker is required for Testcontainers), how to start the application, and where the development admin bootstrap is documented. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index df312b7..d905a42 100644 --- a/README.md +++ b/README.md @@ -489,5 +489,6 @@ Full CRUD for users, roles, and permissions, still inside `core/auth`. Assignmen 1. Clone the repository and check out `develop` 2. Follow the branches in order: `feature/core-architecture` → `feature/auth` → `feature/rbac` → `feature/catalog` → `feature/customer` → `feature/order` -3. Run `docker-compose up`, then deploy the WAR (`mvn package` at the root, then deploy `ws/target/ws.war` to Tomcat 10.1, or configure `mvn tomcat10:deploy`) -4. Access the API at `http://localhost:8080/spring-jdbc-tutorial/api/v1/...` +3. Run the tests with `./mvnw verify` (needs a Docker daemon: the DAO tests start PostgreSQL through Testcontainers) +4. Run the packaged application: `cp .env.example .env` and set `DB_PASSWORD`, generate an RSA key pair in `./keys` (the commands are at the top of `docker-compose.yml`), then `docker compose up --build`. The image builds `ws/target/ws.war` and deploys it on Tomcat 10.1 as `spring-jdbc-tutorial.war`; there is no `tomcat10:deploy` configuration +5. Access the API at `http://localhost:${APP_PORT}/spring-jdbc-tutorial/api/v1/...` (`APP_PORT` defaults to 8080). `GET /api/v1/health` is public; to get a first administrator in development, see `dev-keys/README.md`