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/.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/.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 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 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 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"] diff --git a/README.md b/README.md index 3175301..d905a42 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) @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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` diff --git a/core/auth/pom.xml b/core/auth/pom.xml new file mode 100644 index 0000000..1469698 --- /dev/null +++ b/core/auth/pom.xml @@ -0,0 +1,46 @@ + + + 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 + + + + org.springframework.security + spring-security-crypto + + + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + + + 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..9185d24 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/DaoConfig.java @@ -0,0 +1,66 @@ +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.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; +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); + } + + @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 new file mode 100644 index 0000000..4f11cd5 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/ServiceConfig.java @@ -0,0 +1,99 @@ +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. 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 + * 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); + } + + // AuditLogger and RbacService are returned as their interfaces so the @Transactional advice (including + // the REQUIRES_NEW of logRejected) is applied by the proxy. RbacServiceImpl receives the AuditLogger + // PROXY here, which is why its refusal rows really commit in an independent transaction. + // The ActorProvider parameter is a bean the web layer declares: core only knows the interface. + @Bean + public AuditLogger auditLogger(AuditLogDao auditLogDao, ActorProvider actorProvider, Clock clock) { + return new AuditLoggerImpl(auditLogDao, actorProvider, clock); + } + + @Bean + public RbacService rbacService(UserDao userDao, RoleDao roleDao, PermissionDao permissionDao, + AuditLogger auditLogger) { + return new RbacServiceImpl(userDao, roleDao, permissionDao, auditLogger); + } + + // initMethod "run" makes Spring call AdminBootstrap.run() once the bean is wired, after Flyway (the DAOs + // depend on it). The account is created only when BOTH app.bootstrap.* values are configured (development + // only: never set them in test or production); absent values make the bean do nothing. The Environment + // is read leniently (getProperty, no default), unlike the required datasource settings. + @Bean(initMethod = "run") + public AdminBootstrap adminBootstrap(UserDao userDao, RoleDao roleDao, AuditLogger auditLogger, + PasswordEncoder passwordEncoder, PlatformTransactionManager transactionManager, + Environment environment) { + return new AdminBootstrap(userDao, roleDao, auditLogger, passwordEncoder, + new TransactionTemplate(transactionManager), + environment.getProperty("app.bootstrap.admin-email"), + environment.getProperty("app.bootstrap.admin-password")); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/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/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/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/PermissionDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PermissionDao.java new file mode 100644 index 0000000..4c4a454 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PermissionDao.java @@ -0,0 +1,34 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao; + +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for permissions. Entity types only. + *

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

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface RoleDao { + + /** All roles ordered by name. */ + List findAll(); + + Optional findById(Long id); + + Optional findByRoleName(String roleName); + + boolean existsByRoleName(String roleName); + + /** Inserts the role and returns it with its generated id. */ + Role insert(Role role); + + void updateName(Long id, String roleName); + + /** Deletes the role (its role_permission rows go with it by cascade). */ + void delete(Long id); + + /** Number of users currently assigned this role. */ + long countUsersWithRole(Long roleId); + + /** Permissions of the role ordered by resource then action. */ + List findPermissionsByRoleId(Long roleId); + + void addPermission(Long roleId, Long permissionId); + + /** Returns the number of rows removed (0 when the permission was not assigned). */ + int removePermission(Long roleId, Long permissionId); + + boolean hasPermission(Long roleId, Long permissionId); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java new file mode 100644 index 0000000..8afac0d --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java @@ -0,0 +1,61 @@ +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); + + /** 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/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/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/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); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PermissionDaoImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PermissionDaoImpl.java new file mode 100644 index 0000000..f909e51 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PermissionDaoImpl.java @@ -0,0 +1,68 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.PermissionDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.Permission; +import com.edgareldy.springjdbctutorial.core.auth.mapper.PermissionRowMapper; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Optional; + +/** + * JdbcTemplate implementation of PermissionDao. Declared by @Bean in DaoConfig, no stereotype. + *

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

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

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

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class RoleDto { + + private Long id; + + private String roleName; + + private List permissions; + + public RoleDto() { + } + + public RoleDto(Long id, String roleName, List permissions) { + this.id = id; + this.roleName = roleName; + this.permissions = permissions; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RoleDto that = (RoleDto) o; + return Objects.equals(id, that.id) && Objects.equals(roleName, that.roleName) + && Objects.equals(permissions, that.permissions); + } + + @Override + public int hashCode() { + return Objects.hash(id, roleName, permissions); + } + + @Override + public String toString() { + return "RoleDto{" + + "id=" + id + + ", roleName=" + roleName + + ", permissions=" + permissions + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/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 + + "}"; + } +} 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/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/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/Permission.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/Permission.java new file mode 100644 index 0000000..658d655 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/Permission.java @@ -0,0 +1,88 @@ +package com.edgareldy.springjdbctutorial.core.auth.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.Objects; + +/** + * Row of the permissions table: an action (READ, WRITE) allowed on a resource (USER, ROLE...). Annotations are documentation only. + *

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

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

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

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

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

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

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

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class RoleRowMapper implements RowMapper { + + @Override + public Role mapRow(ResultSet rs, int rowNum) throws SQLException { + return new Role( + rs.getLong("id"), + rs.getString("role_name")); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/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/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()); + } +} 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")); + } +} 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/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/RbacService.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/RbacService.java new file mode 100644 index 0000000..daffef1 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/RbacService.java @@ -0,0 +1,55 @@ +package com.edgareldy.springjdbctutorial.core.auth.service; + +import com.edgareldy.springjdbctutorial.core.auth.dto.PermissionDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.RoleDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.common.dto.PageDto; + +import java.util.List; + +/** + * Users, roles and permissions administration (RBAC). Dto types at the boundary. Missing resources throw ResourceNotFoundException (not audited), refusals BusinessRuleException (audited as REJECTED_*). Every successful mutation is audited. + *

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

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

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

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

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

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

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

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

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

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

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

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

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PageDto { + + private final List content; + + private final int page; + + private final int size; + + private final long totalElements; + + public PageDto(List content, int page, int size, long totalElements) { + this.content = content; + this.page = page; + this.size = size; + this.totalElements = totalElements; + } + + public List getContent() { + return content; + } + + public int getPage() { + return page; + } + + public int getSize() { + return size; + } + + public long getTotalElements() { + return totalElements; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PageDto that = (PageDto) o; + return page == that.page && size == that.size && totalElements == that.totalElements + && Objects.equals(content, that.content); + } + + @Override + public int hashCode() { + return Objects.hash(content, page, size, totalElements); + } + + @Override + public String toString() { + return "PageDto{" + + "content=" + content + + ", page=" + page + + ", size=" + size + + ", totalElements=" + totalElements + + "}"; + } +} diff --git a/core/common/src/main/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); + } +} 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..8d4cb34 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java @@ -0,0 +1,42 @@ +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.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +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 { + + // 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) { + 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. + // Logged so an operator sees the cause (bad credentials, network, exhausted pool). + LOG.warn("Database health check failed", e); + } + return new HealthDto("DOWN", "DOWN"); + } +} 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); diff --git a/core/common/src/main/resources/db/migration/V2__seed_permissions_and_admin_role.sql b/core/common/src/main/resources/db/migration/V2__seed_permissions_and_admin_role.sql new file mode 100644 index 0000000..3966a08 --- /dev/null +++ b/core/common/src/main/resources/db/migration/V2__seed_permissions_and_admin_role.sql @@ -0,0 +1,22 @@ +-- Baseline RBAC seed: the permissions every resource of this project defines and an ADMIN role holding all of them. +-- Chicken and egg: creating a role or granting a permission requires ROLE:WRITE, so without a seeded role that +-- already carries it, nobody could ever obtain ROLE:WRITE and the RBAC endpoints would be unreachable. +-- No user is seeded here: an administrator account is created only by the optional, configuration driven bootstrap. +-- Idempotent on purpose: every statement can run against a database that already holds part of this data. + +INSERT INTO permissions (resource, action) +SELECT r.resource, a.action +FROM (VALUES ('USER'), ('ROLE'), ('PERMISSION'), ('CATEGORY'), ('PRODUCT'), ('CUSTOMER'), ('ORDER')) AS r (resource) +CROSS JOIN (VALUES ('READ'), ('WRITE')) AS a (action) +ON CONFLICT (resource, action) DO NOTHING; + +INSERT INTO roles (role_name) +VALUES ('ADMIN') +ON CONFLICT (role_name) DO NOTHING; + +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +CROSS JOIN permissions p +WHERE r.role_name = 'ADMIN' +ON CONFLICT (role_id, permission_id) DO NOTHING; diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/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="); + } +} 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..078a83c --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/FlywayMigrationTest.java @@ -0,0 +1,109 @@ +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, 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 + * 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); + } + } + + @Test + void _04_ShouldSeedExactlyFourteenPermissions_WhenV2HasRun() { + List codes = jdbcTemplate.queryForList( + "SELECT resource || ':' || action FROM permissions ORDER BY resource, action", String.class); + + // Seven resources times READ and WRITE, and nothing else + assertThat(codes).containsExactly( + "CATEGORY:READ", "CATEGORY:WRITE", "CUSTOMER:READ", "CUSTOMER:WRITE", "ORDER:READ", "ORDER:WRITE", + "PERMISSION:READ", "PERMISSION:WRITE", "PRODUCT:READ", "PRODUCT:WRITE", "ROLE:READ", "ROLE:WRITE", + "USER:READ", "USER:WRITE"); + } + + @Test + void _05_ShouldSeedOneAdminRoleHoldingAllFourteenPermissions_WhenV2HasRun() { + List roles = jdbcTemplate.queryForList("SELECT role_name FROM roles", String.class); + Long granted = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM role_permission rp JOIN roles r ON r.id = rp.role_id " + + "WHERE r.role_name = 'ADMIN'", Long.class); + Long total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM role_permission", Long.class); + + assertThat(roles).containsExactly("ADMIN"); + assertThat(granted).isEqualTo(14L); + assertThat(total).isEqualTo(14L); + } + + @Test + void _06_ShouldNotSeedAnyUser_WhenV2HasRun() { + Long users = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users", Long.class); + Long assignments = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM role_user", Long.class); + + assertThat(users).isZero(); + assertThat(assignments).isZero(); + } +} diff --git a/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/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")); + } +} 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()); + } +} 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; diff --git a/core/customer/pom.xml b/core/customer/pom.xml new file mode 100644 index 0000000..4b46f4f --- /dev/null +++ b/core/customer/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + + com.edgareldy.springjdbctutorial + core + 0.1.0-SNAPSHOT + + + customer + customer + E-commerce customers + + + + com.edgareldy.springjdbctutorial + common + + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + + + 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); + } +} 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); + } +} 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 + + "}"; + } +} 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")); + } +} 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("."); + } +} 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/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(); + } +} 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"); + } +} 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; diff --git a/core/order/pom.xml b/core/order/pom.xml new file mode 100644 index 0000000..35456e2 --- /dev/null +++ b/core/order/pom.xml @@ -0,0 +1,44 @@ + + + 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 + + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + + + 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); + } +} 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(); + } +} 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 + + "}"; + } +} 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")); + } +} 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..e13eea9 --- /dev/null +++ b/core/order/src/main/java/com/edgareldy/springjdbctutorial/core/order/service/impl/OrderServiceImpl.java @@ -0,0 +1,107 @@ +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()); + } + // 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); + + 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"); + } + } +} 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/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); + } +} 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"); + } +} 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; 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 diff --git a/dev-keys/README.md b/dev-keys/README.md new file mode 100644 index 0000000..b3b40c8 --- /dev/null +++ b/dev-keys/README.md @@ -0,0 +1,26 @@ +# 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`. + +## Development admin bootstrap (optional) + +There is no seeded user. To get a first administrator locally, export these +variables BEFORE starting Tomcat (dummy values, development only, never set them +in a test or production environment): + + export APP_BOOTSTRAP_ADMIN_EMAIL=admin@example.test + export APP_BOOTSTRAP_ADMIN_PASSWORD=dev-only-Admin-1 + +When both are present and no user has that email, the account is created enabled +with the seeded ADMIN role. When absent, nothing happens. A failure here never +stops the application from starting. diff --git a/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----- diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a845601 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +# 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. 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} + # 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" + +volumes: + db-data: 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 + + + diff --git a/ws/pom.xml b/ws/pom.xml index e2cda26..0e6e161 100644 --- a/ws/pom.xml +++ b/ws/pom.xml @@ -20,12 +20,122 @@ 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 + + + + 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 + + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.springframework.security + spring-security-test + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + 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 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..1073b14 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/SecurityConfig.java @@ -0,0 +1,121 @@ +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; +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. +// @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 + // 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(); + } + + @Bean + public CustomPermissionEvaluator customPermissionEvaluator() { + return new CustomPermissionEvaluator(); + } + + // The expression handler is what evaluates hasPermission(...) inside @PreAuthorize: registering the + // evaluator on it is how hasPermission('ROLE','WRITE') reaches CustomPermissionEvaluator. The bean + // method is static because the method security infrastructure (a BeanPostProcessor) needs it very + // early: a static @Bean can be created without instantiating this configuration class first, which + // avoids the "not eligible for auto-proxying" warning and early-initialisation problems. + @Bean + public static MethodSecurityExpressionHandler methodSecurityExpressionHandler( + CustomPermissionEvaluator customPermissionEvaluator) { + DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); + handler.setPermissionEvaluator(customPermissionEvaluator); + return handler; + } + + @Bean + public ActorProvider actorProvider() { + return new SecurityContextActorProvider(); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java new file mode 100644 index 0000000..502823d --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java @@ -0,0 +1,61 @@ +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.filter.DelegatingFilterProxy; +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, "/*"); + + // 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 new file mode 100644 index 0000000..2f3ecc7 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -0,0 +1,82 @@ +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; +import org.springframework.context.annotation.Bean; +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 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, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class, + com.edgareldy.springjdbctutorial.core.catalog.config.ServiceConfig.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 { + + // 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, + // 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(); + } + + // 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(objectMapper())); + } + + // LocalValidatorFactoryBean bootstraps Bean Validation (Hibernate Validator on the classpath). + // 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() { + return validator(); + } +} 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"); + } +} 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/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"); + } +} 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/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"); + } +} 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/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"); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/RoleController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/RoleController.java new file mode 100644 index 0000000..4b6e15f --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/RoleController.java @@ -0,0 +1,79 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.auth.service.RbacService; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import org.springframework.security.access.prepost.PreAuthorize; +import com.edgareldy.springjdbctutorial.ws.converter.RoleConverter; +import com.edgareldy.springjdbctutorial.ws.payload.rbac.RoleRequest; +import com.edgareldy.springjdbctutorial.ws.payload.rbac.RoleResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import java.util.List; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points to manage roles and the permissions assigned onto them. No business logic. + *

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

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

+ * 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()); + } + + public static PageResponse toPageResponse(PageDto page) { + List content = new ArrayList<>(); + for (UserDto dto : page.getContent()) { + content.add(toResponse(dto)); + } + return PageResponse.of(content, page.getPage(), page.getSize(), page.getTotalElements()); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..1e34e89 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java @@ -0,0 +1,181 @@ +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; +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.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; +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.Objects; +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) { + // One "parameter: message" entry per violated constraint, like the "field: message" of body errors + String message = e.getParameterValidationResults().stream() + .flatMap(result -> result.getResolvableErrors().stream() + .map(error -> Objects.requireNonNullElse(result.getMethodParameter().getParameterName(), + "parameter") + ": " + error.getDefaultMessage())) + .sorted() + .collect(Collectors.joining("; ")); + return build(HttpStatus.BAD_REQUEST, message); + } + + @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"); + } + + // 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. 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"); + } + + // 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) { + 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/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 + "}"; + } +} 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 + + "}"; + } +} 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; + } +} 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 + + "}"; + } +} 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 + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/PermissionRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/PermissionRequest.java new file mode 100644 index 0000000..e40d160 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/rbac/PermissionRequest.java @@ -0,0 +1,72 @@ +package com.edgareldy.springjdbctutorial.ws.payload.rbac; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.Objects; + +/** + * Body of the create and update permission calls (resource and action, letters and underscore only). + *

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

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

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

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class RoleResponse { + + private Long id; + + private String roleName; + + private List permissions; + + public RoleResponse() { + } + + public RoleResponse(Long id, String roleName, List permissions) { + this.id = id; + this.roleName = roleName; + this.permissions = permissions; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RoleResponse that = (RoleResponse) o; + return Objects.equals(id, that.id) && Objects.equals(roleName, that.roleName) && Objects.equals(permissions, that.permissions); + } + + @Override + public int hashCode() { + return Objects.hash(id, roleName, permissions); + } + + @Override + public String toString() { + return "RoleResponse{" + + "id=" + id + + ", roleName=" + roleName + + ", permissions=" + permissions + + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/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")); + } +} 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/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/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/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); + } +} 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 + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/SecurityContextActorProvider.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/SecurityContextActorProvider.java new file mode 100644 index 0000000..33b36a9 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/SecurityContextActorProvider.java @@ -0,0 +1,27 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import com.edgareldy.springjdbctutorial.core.auth.service.ActorProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Tells the core audit logger who the current actor is, read from the SecurityContext (null when anonymous). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// core has no web or security dependency: it only knows the ActorProvider interface, and ws supplies +// this implementation so audit rows carry the id of the authenticated user. +public class SecurityContextActorProvider implements ActorProvider { + + @Override + public Long currentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof AuthenticatedUser user) { + return user.getUserId(); + } + return null; + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java new file mode 100644 index 0000000..26388ca --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java @@ -0,0 +1,116 @@ +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.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; + +/** + * 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); + when(servletContext.addFilter(eq("springSecurityFilterChain"), any(Filter.class))) + .thenReturn(mock(FilterRegistration.Dynamic.class)); + + 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)); + when(servletContext.addFilter(eq("springSecurityFilterChain"), 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); + when(servletContext.addFilter(eq("springSecurityFilterChain"), any(Filter.class))) + .thenReturn(mock(FilterRegistration.Dynamic.class)); + + 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("/*")); + } + + @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 new file mode 100644 index 0000000..056342c --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java @@ -0,0 +1,80 @@ +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; +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, auth services, + * SecurityConfig, 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() { + // 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_ShouldReturn200WithStatusUpWithoutToken_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_ShouldReturn401ApiResponse_WhenUrlIsUnknownAndNoTokenIsSent() throws Exception { + assertError(mockMvc.perform(get("/api/v1/nothing-here")).andReturn(), 401); + } + + @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/AdminBootstrapFailureIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AdminBootstrapFailureIntegrationTest.java new file mode 100644 index 0000000..44e7e94 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AdminBootstrapFailureIntegrationTest.java @@ -0,0 +1,52 @@ +package com.edgareldy.springjdbctutorial.ws.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.auth.service.impl.AdminBootstrap; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.web.context.WebApplicationContext; + +/** + * A bootstrap that cannot succeed (password over 72 bytes, refused before anything is written) must not fail + * the startup of the real application context: the context loads, the bean exists, no account is created. + *

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

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

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

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

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

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

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

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

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class SecurityContextActorProviderTest { + + private final SecurityContextActorProvider provider = new SecurityContextActorProvider(); + + // SecurityContextHolder is thread-local static state: always clear it so no test leaks into another + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void _01_ShouldReturnTheUserId_WhenAnAuthenticatedUserIsInTheContext() { + AuthenticatedUser principal = new AuthenticatedUser(42L, "a@example.com", "jti", "hash", + Instant.now(), Instant.now().plusSeconds(60)); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, AuthorityUtils.NO_AUTHORITIES)); + + assertThat(provider.currentUserId()).isEqualTo(42L); + } + + @Test + void _02_ShouldReturnNull_WhenThereIsNoAuthentication() { + assertThat(provider.currentUserId()).isNull(); + } + + @Test + void _03_ShouldReturnNull_WhenTheAuthenticationIsAnonymous() { + SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken("key", + "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); + + assertThat(provider.currentUserId()).isNull(); + } + + @Test + void _04_ShouldReturnNull_WhenThePrincipalIsNotAnAuthenticatedUser() { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("just-a-name", null, AuthorityUtils.NO_AUTHORITIES)); + + assertThat(provider.currentUserId()).isNull(); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/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..bd63e17 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/JwtTestSupport.java @@ -0,0 +1,130 @@ +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); + } + + /** 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 { + 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")); + } +} 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(); + } +}