diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..a2dfc1e --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,363 @@ +# API Reference + +Base URL: `http://localhost:8080/api/v1` + +Interactive documentation is also available via Swagger UI at `http://localhost:8080/swagger-ui.html`. + +## Authentication + +Endpoints that require authentication expect a JWT access token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +Obtain a token via `POST /auth/login`. Access levels in this document: + +| Level | Meaning | +|---|---| +| Public | No token required | +| Authenticated | Any valid, non-expired, non-blacklisted token for an enabled, non-locked account | +| ADMIN | Token must carry the `ROLE_ADMIN` authority | + +--- + +## Standard response format + +Every response body follows this envelope: + +```json +{ + "success": true, + "message": "...", + "data": { ... }, + "timestamp": "2026-01-01T00:00:00Z" +} +``` + +**Paginated lists** return a `PageResponse` as `data`: + +```json +{ + "data": { + "content": [ ... ], + "page": 0, + "size": 20, + "total_elements": 42, + "total_pages": 3 + } +} +``` + +**Errors** return `success: false` and a RFC 7807 `ProblemDetail` as `data`: + +```json +{ + "success": false, + "message": "Product not found", + "data": { + "type": "about:blank", + "title": "Not Found", + "status": 404, + "detail": "Product not found with id 99", + "instance": "/api/v1/products/99" + }, + "timestamp": "2026-01-01T00:00:00Z" +} +``` + +Validation errors (400) add a `fieldErrors` map on `data`: + +```json +{ + "data": { + "status": 400, + "fieldErrors": { "product_name": "must not be blank" } + } +} +``` + +--- + +## Categories + +| Method | URL | Description | Access | +|---|---|---|---| +| GET | `/categories` | Paginated list | Public | +| GET | `/categories/{id}` | Category detail with associated products | Public | +| POST | `/categories` | Create a category | ADMIN | +| PUT | `/categories/{id}` | Update a category | ADMIN | +| DELETE | `/categories/{id}` | Delete a category (fails with 422 if products exist) | ADMIN | + +**Query parameters** (`GET /categories`): `page` (default 0), `size` (default 20), `sort` (e.g. `categoryName,asc`). + +**CategoryRequest** (POST / PUT body): +```json +{ "category_name": "Electronics" } +``` + +**CategoryResponse**: +```json +{ + "id": 1, + "category_name": "Electronics", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "products": [] +} +``` +`products` is populated only on the detail endpoint (`GET /categories/{id}`), not on the list. + +--- + +## Products + +| Method | URL | Description | Access | +|---|---|---|---| +| GET | `/products` | Paginated list, filterable and sortable | Public | +| GET | `/products/{id}` | Product detail | Public | +| POST | `/products` | Create a product | ADMIN | +| PUT | `/products/{id}` | Update a product | ADMIN | +| DELETE | `/products/{id}` | Delete a product (fails with 422 if orders exist) | ADMIN | + +**Query parameters** (`GET /products`): + +| Parameter | Type | Description | +|---|---|---| +| `categoryId` | Long | Filter by category | +| `productName` | String | Case-insensitive contains search | +| `minPrice` | Double | Minimum unit price | +| `maxPrice` | Double | Maximum unit price | +| `page` | int | Page index (default 0) | +| `size` | int | Page size (default 20) | +| `sort` | String | e.g. `unitPrice,desc` | + +**ProductRequest** (POST / PUT body): +```json +{ "product_name": "Mechanical keyboard", "unit_price": 79.99, "category_id": 1 } +``` + +**ProductResponse**: +```json +{ + "id": 12, + "product_name": "Mechanical keyboard", + "unit_price": 79.99, + "category_id": 1, + "category_name": "Electronics", + "version": 0, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" +} +``` + +`PUT /products/{id}` requires the client to send the current `version` value. A stale version returns 409 Conflict. + +--- + +## Customers + +| Method | URL | Description | Access | +|---|---|---|---| +| GET | `/customers` | Paginated list, searchable by name | Authenticated | +| GET | `/customers/{id}` | Customer detail with ordered products | Authenticated | +| POST | `/customers` | Create a customer | ADMIN | +| PUT | `/customers/{id}` | Update a customer | ADMIN | +| DELETE | `/customers/{id}` | Delete a customer (fails with 422 if orders exist) | ADMIN | +| GET | `/customers/{id}/orders` | Orders placed by a customer | Authenticated | + +**Query parameters** (`GET /customers`): `search` (matches first or last name), `page`, `size`, `sort`. + +**CustomerRequest** (POST / PUT body): +```json +{ + "first_name": "Ada", "last_name": "Lovelace", + "telephone": "+1 202-555-0100", + "email": "ada@example.com", + "address": "1 Analytical Engine Way" +} +``` + +**CustomerResponse**: +```json +{ + "id": 1, "first_name": "Ada", "last_name": "Lovelace", + "telephone": "+1 202-555-0100", + "email": "ada@example.com", + "address": "1 Analytical Engine Way", + "products": [] +} +``` +`products` (the distinct products ordered by this customer) is populated only on `GET /customers/{id}`. + +--- + +## Orders + +| Method | URL | Description | Access | +|---|---|---|---| +| GET | `/orders` | Paginated list, filterable by customer or product | Authenticated | +| GET | `/orders/{id}` | Order detail | Authenticated | +| POST | `/orders` | Create an order | ADMIN | +| PUT | `/orders/{id}` | Update an order | ADMIN | +| DELETE | `/orders/{id}` | Delete an order | ADMIN | + +**Query parameters** (`GET /orders`): `customerId`, `productId`, `page`, `size`, `sort`. + +**OrderRequest** (POST / PUT body): +```json +{ "customer_id": 1, "product_id": 12, "quantity": 2 } +``` + +`total` is computed server-side as `quantity * product.unitPrice`. + +**OrderResponse** embeds the full customer and product DTOs: +```json +{ + "id": 5, + "customer": { "id": 1, "first_name": "Ada", ... }, + "product": { "id": 12, "product_name": "Mechanical keyboard", ... }, + "quantity": 2, + "total": 159.98 +} +``` + +--- + +## Authentication + +| Method | URL | Description | Access | +|---|---|---|---| +| POST | `/auth/register` | Create an account (sends activation email) | Public | +| POST | `/auth/login` | Sign in, returns access + refresh tokens | Public | +| POST | `/auth/refresh-token` | Exchange a refresh token for a new access token | Public | +| GET | `/auth/me` | Current user profile | Authenticated | +| POST | `/auth/logout` | Revoke the current access token + refresh token | Authenticated | +| GET | `/auth/activate` | Activate account with email token | Public | +| POST | `/auth/forgot-password` | Request a password-reset email | Public | +| POST | `/auth/reset-password` | Reset password with email token | Public | +| GET | `/auth/token-info` | Inspect claims of the current access token | Authenticated | + +**RegisterRequest**: +```json +{ + "first_name": "Ada", "last_name": "Lovelace", + "username": "ada", + "email": "ada@example.com", + "password": "Password1@", + "confirm_password": "Password1@" +} +``` + +Password rules: minimum 8 characters, at least one uppercase letter, one digit, one special character (`@#$%^&+=!`), no spaces. + +**LoginRequest**: +```json +{ "email": "ada@example.com", "password": "Password1@" } +``` + +**AuthResponse** (login / refresh): +```json +{ + "access_token": "eyJhbGc...", + "token_type": "Bearer", + "refresh_token": "dGhpcyBp...", + "expires_in": 3600 +} +``` + +`expires_in` is in seconds (default: 3600). The refresh token is valid for 7 days. + +**Refresh token** (`POST /auth/refresh-token`): send the refresh token in the `Authorization` header: +``` +Authorization: Bearer +``` + +**Logout** (`POST /auth/logout`): send the access token in the `Authorization` header. Both the access token (blacklisted by `jti`) and the refresh token are invalidated. + +**Activate** (`GET /auth/activate`): `?token=` + +**Forgot password** (`POST /auth/forgot-password`): +```json +{ "email": "ada@example.com" } +``` + +**Reset password** (`POST /auth/reset-password?token=`): +```json +{ "new_password": "NewPass1@", "confirm_password": "NewPass1@" } +``` + +--- + +## Roles + +All endpoints are ADMIN-only. + +| Method | URL | Description | +|---|---|---| +| GET | `/roles` | Paginated list of roles with their permissions | +| POST | `/roles` | Create a role | +| PUT | `/roles/{id}` | Rename a role | +| DELETE | `/roles/{id}` | Delete a role | +| POST | `/roles/{id}/permissions/{permissionId}` | Assign a permission to a role | +| DELETE | `/roles/{id}/permissions/{permissionId}` | Remove a permission from a role | + +**RoleRequest**: +```json +{ "role_name": "MODERATOR" } +``` + +--- + +## Permissions + +All endpoints are ADMIN-only. + +| Method | URL | Description | +|---|---|---| +| GET | `/permissions` | Paginated list of permissions | +| POST | `/permissions` | Create a permission | +| DELETE | `/permissions/{id}` | Delete a permission | + +**PermissionRequest**: +```json +{ "resource": "PRODUCTS", "action": "WRITE" } +``` + +--- + +## Users + +All endpoints are ADMIN-only. + +| Method | URL | Description | +|---|---|---| +| GET | `/users` | Paginated list of users | +| GET | `/users/{id}` | User detail | +| PUT | `/users/{id}/roles` | Assign roles to a user | +| DELETE | `/users/{id}/roles/{roleId}` | Remove a role from a user | +| DELETE | `/users/{id}` | Delete a user | +| POST | `/users/{id}/lock` | Lock an account | +| POST | `/users/{id}/unlock` | Unlock an account | +| PUT | `/users/{id}/profile` | Update a user's profile (firstName, lastName) | + +**AssignRolesRequest**: +```json +{ "role_ids": [1, 2] } +``` + +--- + +## Common HTTP status codes + +| Code | When | +|---|---| +| 200 OK | Successful read or update | +| 201 Created | Successful creation | +| 400 Bad Request | Validation failure, invalid token | +| 401 Unauthorized | Missing, expired, or blacklisted token; bad credentials; account disabled | +| 403 Forbidden | Authenticated but insufficient role or permission | +| 404 Not Found | Resource does not exist | +| 409 Conflict | Optimistic locking failure (stale `version` on a product update) | +| 422 Unprocessable Entity | Business rule violation (e.g. delete a category that still has products) | +| 500 Internal Server Error | Unexpected server error | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1528f09 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,211 @@ +# Architecture + +## Layered architecture + +``` +┌─────────────────────────────────────────────┐ +│ HTTP Client / Swagger UI │ +└──────────────────────┬──────────────────────┘ + │ REST (JSON) +┌──────────────────────▼──────────────────────┐ +│ Controller layer │ +│ @RestController @RequestMapping /api/v1 │ +│ Returns ResponseEntity> │ +└──────────────────────┬──────────────────────┘ + │ calls interface +┌──────────────────────▼──────────────────────┐ +│ Service layer │ +│ XxxService (interface) + XxxServiceImpl │ +│ @Service @Transactional (writes only) │ +└──────────────────────┬──────────────────────┘ + │ Spring Data JPA +┌──────────────────────▼──────────────────────┐ +│ Repository layer │ +│ JpaRepository / JpaSpecificationExecutor │ +│ @Query, @EntityGraph, derived queries │ +└──────────────────────┬──────────────────────┘ + │ JDBC +┌──────────────────────▼──────────────────────┐ +│ PostgreSQL 16 (Docker) │ +│ Schema managed by Flyway │ +└─────────────────────────────────────────────┘ +``` + +Cross-cutting concerns run as AOP aspects alongside every layer (see [AOP](#aop-aspects) below). + +--- + +## Package structure + +``` +com.edgareldy.springboottutorial/ +├── SpringBootTutorialApplication.java +│ +├── actuator/ Custom Actuator endpoint (AppInfoEndpoint) +├── aspect/ AOP aspects (LoggingAspect, ExecutionTimeAspect, RepositoryAuditAspect) +├── config/ Spring configuration beans +│ ├── AsyncConfig.java +│ ├── CacheConfig.java +│ ├── CorsConfig.java +│ ├── DataSeeder.java +│ ├── JpaAuditingConfig.java +│ ├── MethodSecurityConfig.java +│ ├── OpenApiConfig.java +│ ├── SchedulingConfig.java +│ └── SecurityConfig.java +│ +├── controller/ REST controllers (@RestController) +├── dto/ Data Transfer Objects (Java records, immutable) +│ ├── auth/ RegisterRequest, LoginRequest, AuthResponse, UserResponse, ... +│ ├── common/ ApiResponse, PageResponse +│ ├── category/ CategoryRequest, CategoryResponse +│ ├── customer/ CustomerRequest, CustomerResponse +│ ├── order/ OrderRequest, OrderResponse +│ ├── permission/ PermissionRequest, PermissionResponse +│ ├── product/ ProductRequest, ProductResponse +│ ├── role/ RoleRequest, RoleResponse +│ └── user/ AssignRolesRequest, UpdateProfileRequest +│ +├── email/ EmailTemplateName enum, Thymeleaf HTML templates +├── entity/ JPA entities +│ ├── Category, Product, Customer, Order +│ ├── Permission, Role +│ ├── token/ ActivationToken, BlacklistedToken, PasswordResetToken, RefreshToken +│ └── user/ AppUser +│ +├── event/ Spring application events +│ ├── OrderCreatedEvent.java +│ └── OrderCreatedEventListener.java +│ +├── exception/ Custom exceptions + GlobalExceptionHandler +├── mapper/ MapStruct mappers (AppUserMapper, CategoryMapper, ...) +├── repository/ Spring Data JPA repositories + Specification + Projection +├── scheduler/ @Scheduled tasks (TokenCleanupScheduler) +├── security/ JWT + Spring Security components +│ ├── CustomPermissionEvaluator.java +│ ├── JwtAccessDeniedHandler.java +│ ├── JwtAuthFilter.java +│ ├── JwtAuthenticationEntryPoint.java +│ ├── JwtProperties.java +│ ├── JwtService.java +│ ├── SecureTokenGenerator.java +│ └── UserDetailsServiceImpl.java +│ +├── service/ Service interfaces (XxxService) +│ └── impl/ Service implementations (XxxServiceImpl, @Service) +│ +└── validation/ Custom Jakarta Bean Validation constraints + ├── password/ @ValidPassword, @PasswordsMatch + └── unique/ @Unique +``` + +--- + +## Key design decisions + +### Contract / implementation pattern for services + +Every service has a **Java interface** (`XxxService`) in `service/` and an **implementation** (`XxxServiceImpl`) in `service/impl/`. Controllers and tests depend only on the interface, never on the concrete class. `@Transactional` is placed on the implementation, not the interface. + +```java +// controller injects the interface +private final CategoryService categoryService; + +// implementation owns @Transactional +@Service +public class CategoryServiceImpl implements CategoryService { + @Override + @Transactional + public CategoryResponse create(CategoryRequest request) { ... } +} +``` + +### Response envelope + +Every response (success and error) is wrapped in `ApiResponse`: + +```java +public record ApiResponse(boolean success, String message, T data, Instant timestamp) { + public static ApiResponse success(T data, String message) { ... } + public static ApiResponse error(String message) { ... } +} +``` + +Paginated list endpoints use `ApiResponse>` as the return type, never a bare `List`. + +Controllers return `ResponseEntity>` to control the HTTP status code alongside the body. + +### DTOs as Java records + +DTOs are immutable Java records - no setters, no default constructor. MapStruct generates the mappers at compile time, matching DTO fields to entity fields by name. + +JSON field names are snake_case, Java field names are camelCase. The two are decoupled via `@JsonProperty` on each record component: + +```java +public record ProductResponse( + Long id, + @JsonProperty("product_name") String productName, + @JsonProperty("unit_price") double unitPrice, + @JsonProperty("category_id") Long categoryId +) {} +``` + +### Error format (RFC 7807) + +Error responses use Spring 6's built-in `ProblemDetail` as the `data` field of `ApiResponse`: + +```json +{ + "success": false, + "message": "Product not found", + "data": { + "type": "about:blank", + "title": "Not Found", + "status": 404, + "detail": "Product not found with id 99", + "instance": "/api/v1/products/99" + }, + "timestamp": "2026-07-02T10:16:05Z" +} +``` + +Validation errors (400) add a `fieldErrors` extension on `ProblemDetail`: + +```json +{ + "data": { + "status": 400, + "fieldErrors": { "productName": "must not be blank", "unitPrice": "must be greater than 0" } + } +} +``` + +### Schema management + +`spring.jpa.hibernate.ddl-auto=validate` in every profile. Hibernate only checks that entity mappings match the schema; it never creates or alters tables. Flyway is the single source of truth for the schema. + +--- + +## AOP aspects + +Three aspects run across the application, covering each layer separately: + +| Aspect | Pointcut | Advice types | Purpose | +|---|---|---|---| +| `LoggingAspect` | All `@Service` methods | `@Around` | Logs method entry, exit, arguments (sensitive fields masked), and exceptions | +| `ExecutionTimeAspect` | All `@RestController` methods | `@Around` | Measures and logs HTTP request execution time | +| `RepositoryAuditAspect` | All repository methods | `@Before`, `@After`, `@AfterReturning`, `@AfterThrowing` | Traces every repository call, including save results and constraint violations | + +`LoggingAspect` masks fields annotated `@SensitiveField` before logging, so passwords and tokens never appear in logs. + +--- + +## Caching + +Caffeine cache is configured in `CacheConfig` with two named caches (`categories`, `products`), a 10-minute TTL, and a 500-entry maximum. `@Cacheable` / `@CacheEvict` are applied on `findById`, `update`, and `delete` in `CategoryServiceImpl` and `ProductServiceImpl`. List (paginated) endpoints are intentionally not cached. + +--- + +## Application events + +`OrderCreatedEvent` is published by `OrderServiceImpl.create()` inside its `@Transactional` boundary. `OrderCreatedEventListener` consumes it with `@TransactionalEventListener(phase = AFTER_COMMIT)`, ensuring the listener only fires once the order row is durably committed. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..c82d236 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,192 @@ +# Configuration + +## How Spring Boot profiles work + +Spring Boot lets you define **multiple configuration files** for different environments. The active profile is selected at startup and determines which file is loaded on top of the base `application.yml`. + +``` +application.yml <-- always loaded (shared defaults) +application-dev.yml <-- loaded when profile = dev +application-test.yml <-- loaded when profile = test +application-prod.yml <-- loaded when profile = prod +``` + +Values in a profile file override those in `application.yml`. This means you can define a default in `application.yml` and only override what differs per environment. The active profile is set via: + +- `SPRING_PROFILES_ACTIVE=prod` environment variable (used in `docker-compose.yml`) +- `-Dspring-boot.run.profiles=dev` Maven flag (used for local development) +- `spring.profiles.active=test` in `application-test.yml` (picked up by test slices) + +--- + +## Profile comparison + +| Setting | `dev` | `test` | `prod` | +|---|---|---|---| +| Datasource | `localhost:5432/spring_boot_db` (defaults) | Testcontainers (auto-wired by `@ServiceConnection`) | Required via env vars | +| `show-sql` | `true` | - | `false` | +| Flyway | enabled | enabled | enabled | +| Mail host | `localhost:1025` (MailHog) | `localhost:1025` (stubbed) | Required via env vars | +| Actuator endpoints exposed | health, info, metrics, appinfo | health | health | +| Log level (project) | DEBUG | - | INFO (root) | +| Log format | plain text | plain text | ECS JSON (structured) | +| Log file | - | - | `logs/app.log` | + +--- + +## Environment variables + +### Required in production + +These have **no default value** in `application-prod.yml`. The application will fail to start if they are not set. + +| Variable | Used for | +|---|---| +| `DB_URL` | JDBC connection URL, e.g. `jdbc:postgresql://db:5432/mydb` | +| `DB_USERNAME` | PostgreSQL username | +| `DB_PASSWORD` | PostgreSQL password | +| `JWT_SECRET` | HMAC-SHA256 signing key for JWT. Must be long and random (32+ characters). | +| `MAIL_HOST` | SMTP server hostname | +| `MAIL_PORT` | SMTP server port | + +### Optional in production + +| Variable | Default | Used for | +|---|---|---| +| `MAIL_USERNAME` | (empty) | SMTP auth username | +| `MAIL_PASSWORD` | (empty) | SMTP auth password | +| `MAIL_SMTP_AUTH` | `true` | Enable SMTP authentication | +| `MAIL_SMTP_STARTTLS` | `true` | Enable STARTTLS | +| `APP_BASE_URL` | `http://localhost:8080` | Base URL for links in emails (activation, reset) | +| `APP_FRONTEND_URL` | `http://localhost:4200` | Frontend URL (used for CORS in `dev` profile) | +| `MAIL_FROM` | `noreply@spring-boot-tutorial.com` | Sender address in outgoing emails | +| `POSTGRES_DB` | `spring_boot_tutorial` | Docker Compose: PostgreSQL database name | +| `POSTGRES_USER` | `spring_boot_tutorial` | Docker Compose: PostgreSQL user | +| `POSTGRES_PASSWORD` | `spring_boot_tutorial` | Docker Compose: PostgreSQL password | + +> Copy `.env.example` to `.env` and fill in at minimum `JWT_SECRET`, `DB_PASSWORD`, and `POSTGRES_PASSWORD` before running `docker compose up`. + +--- + +## Key application properties + +### JWT + +```yaml +jwt: + secret: ${JWT_SECRET:dev-only-jwt-secret-...} + expiration-ms: 3600000 # access token TTL = 1 hour + refresh-expiration-ms: 604800000 # refresh token TTL = 7 days +``` + +These are bound to `JwtProperties` via `@ConfigurationProperties(prefix = "jwt")`. This is a Spring Boot best practice: instead of scattering `@Value("${jwt.secret}")` across multiple beans, all JWT settings are grouped in one immutable record and injected where needed. + +### Application URLs + +```yaml +app: + base-url: ${APP_BASE_URL:http://localhost:8080} + frontend-url: ${APP_FRONTEND_URL:http://localhost:4200} + mail: + from: ${MAIL_FROM:noreply@spring-boot-tutorial.com} +``` + +`app.base-url` is used by `EmailServiceImpl` to construct the activation and password-reset links sent in emails. + +### Cache (Caffeine) + +```yaml +# Configured programmatically in CacheConfig.java +# Named caches: "categories", "products" +# TTL: 10 minutes +# Max entries: 500 per cache +``` + +`CacheConfig` builds `CaffeineCacheManager` as a Spring bean. Caffeine is a high-performance, in-memory cache library. `@Cacheable("categories")` on `CategoryServiceImpl.findById` stores the result under the key `#id`; `@CacheEvict("categories")` on `update` and `delete` removes the stale entry. This demonstrates Spring's cache abstraction: the service code is unaware of Caffeine specifically - only the abstraction annotations appear there. + +### Actuator + +```yaml +# dev profile +management: + endpoints: + web: + exposure: + include: health,info,metrics,appinfo + +# prod profile +management: + endpoints: + web: + exposure: + include: health +``` + +Actuator is Spring Boot's built-in monitoring system. It exposes HTTP endpoints that report on the application's health, metrics, and configuration. In prod, only `health` is exposed to avoid leaking internal details. `appinfo` is a **custom Actuator endpoint** implemented in `AppInfoEndpoint.java` using `@Endpoint(id = "appinfo")` - it demonstrates how to create your own endpoint beyond the built-in ones. + +### Async executor + +```yaml +# Configured in AsyncConfig.java +# Bean name: "emailTaskExecutor" +# Core pool: 2 threads, Max: 5, Queue capacity: 100 +``` + +`@Async("emailTaskExecutor")` on `EmailServiceImpl` methods means email sending does **not block** the HTTP request thread. The annotation tells Spring to run the method on a background thread from the named pool. Without `@Async`, a slow SMTP server would delay the HTTP response for the registering user. + +### Scheduling + +```yaml +# Activated by @EnableScheduling in SchedulingConfig.java +``` + +`TokenCleanupScheduler` runs `@Scheduled(cron = "0 0 3 * * *")` - a cron expression meaning "every day at 03:00". It deletes expired tokens from `activation_tokens`, `password_reset_tokens`, and `blacklisted_tokens`, preventing unbounded table growth. + +--- + +## Hibernate DDL strategy + +```yaml +spring: + jpa: + hibernate: + ddl-auto: validate # in every profile +``` + +`validate` means Hibernate reads the existing schema and checks that every entity mapping matches a real column. It never creates or modifies tables. If a migration is missing or an entity has a typo in a column name, the app fails to start immediately with a clear error. **Flyway is the single source of truth** for the schema; Hibernate is just a consistency check. + +This is safer than `update` (which silently alters tables and can cause data loss in prod) or `create-drop` (which wipes the database on every restart). + +--- + +## Logging + +### Dev / test + +Plain text logs to the console. The project package is logged at DEBUG level in dev: + +```yaml +logging: + level: + com.edgareldy.springboottutorial: DEBUG +``` + +### Production + +Structured ECS JSON logs to both console and a file (`logs/app.log`): + +```yaml +logging: + level: + root: INFO + file: + name: logs/app.log + structured: + format: + console: ecs + file: ecs +``` + +ECS (Elastic Common Schema) is a standard JSON log format, suitable for ingestion into the Elastic Stack (Elasticsearch + Kibana) or any other structured log platform. Spring Boot 3.4+ supports it natively via the `logging.structured.format` property - no extra library needed. + +The `Dockerfile` creates the `/app/logs` directory and assigns it to the `appuser` system user so the application can write to it without root privileges. diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..5fc4599 --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,143 @@ +# Data Model + +## EER diagram + +``` +categories + id BIGINT PK + category_name VARCHAR(255) NOT NULL + created_at TIMESTAMP NOT NULL + updated_at TIMESTAMP NOT NULL + | + | 1:N + | +products + id BIGINT PK + category_id BIGINT FK -> categories.id NOT NULL + product_name VARCHAR(255) NOT NULL + unit_price REAL NOT NULL (> 0) + version BIGINT NOT NULL DEFAULT 0 + | + | 1:N + | +orders + id BIGINT PK + customer_id BIGINT FK -> customers.id NOT NULL + product_id BIGINT FK -> products.id NOT NULL + quantity INT NOT NULL (> 0) + total DOUBLE PRECISION NOT NULL + | + | N:1 + | +customers + id BIGINT PK + first_name VARCHAR(255) NOT NULL + last_name VARCHAR(255) NOT NULL + telephone VARCHAR(50) NOT NULL + email VARCHAR(255) NOT NULL UNIQUE + address VARCHAR(255) NOT NULL + + +app_users + id BIGINT PK + username VARCHAR(50) NOT NULL UNIQUE + email VARCHAR(255) NOT NULL UNIQUE + password VARCHAR(255) NOT NULL + first_name VARCHAR(100) + last_name VARCHAR(100) + enabled BOOLEAN NOT NULL DEFAULT TRUE + account_locked BOOLEAN NOT NULL DEFAULT FALSE + | + | N:M via role_user + | +roles + id BIGINT PK + role_name VARCHAR(50) NOT NULL UNIQUE + | + | N:M via role_permission + | +permissions + id BIGINT PK + resource VARCHAR(50) NOT NULL + action VARCHAR(50) NOT NULL + UNIQUE (resource, action) + + +role_user + role_id BIGINT FK -> roles.id PK + user_id BIGINT FK -> app_users.id PK + +role_permission + role_id BIGINT FK -> roles.id PK + permission_id BIGINT FK -> permissions.id PK + + +activation_tokens + id BIGINT PK + user_id BIGINT FK -> app_users.id NOT NULL + token VARCHAR(255) NOT NULL UNIQUE + created_at TIMESTAMP NOT NULL + expires_at TIMESTAMP + validated_at TIMESTAMP (NULL = not yet used) + +password_reset_tokens + id BIGINT PK + user_id BIGINT FK -> app_users.id NOT NULL + token VARCHAR(255) NOT NULL UNIQUE + type VARCHAR(255) NOT NULL + created_at TIMESTAMP NOT NULL + expiry_date TIMESTAMP NOT NULL + +blacklisted_tokens + id BIGINT PK + user_id BIGINT FK -> app_users.id NOT NULL + token VARCHAR(768) NOT NULL + jti VARCHAR(255) NOT NULL UNIQUE + blacklisted_at TIMESTAMP + created_at TIMESTAMP NOT NULL + expires_at TIMESTAMP + +refresh_tokens + id BIGINT PK + user_id BIGINT FK -> app_users.id NOT NULL + token VARCHAR(768) NOT NULL UNIQUE + created_at TIMESTAMP NOT NULL + expires_at TIMESTAMP NOT NULL + revoked_at TIMESTAMP (NULL = still valid) +``` + +--- + +## Flyway migration history + +| Version | File | What it does | +|---|---|---| +| V1 | `V1__init_schema.sql` | Creates `categories`, `products`, `customers`, `orders` with FKs and indexes | +| V2 | `V2__init_users_and_roles.sql` | Creates `app_users` with an inline `role` enum column (later replaced by V7) | +| V3 | `V3__add_version_to_products.sql` | Adds `version BIGINT` to `products` for optimistic locking (`@Version`) | +| V4 | `V4__add_audit_columns_to_categories.sql` | Adds `created_at` and `updated_at` to `categories` for JPA auditing | +| V5 | `V5__init_roles_and_permissions.sql` | Creates `roles`, `permissions`, and `role_permission` join table | +| V6 | `V6__init_token_tables.sql` | Creates `activation_tokens`, `password_reset_tokens`, and `blacklisted_tokens` | +| V7 | `V7__role_user.sql` | Creates `role_user` join table; seeds ADMIN/USER roles; migrates the `role` enum column to rows in `role_user`; drops the old `role` column; adds `enabled` | +| V8 | `V8__add_account_locked.sql` | Adds `account_locked BOOLEAN` to `app_users` | +| V9 | `V9__add_name_fields_to_app_users.sql` | Adds nullable `first_name` and `last_name` to `app_users` | +| V11 | `V11__add_created_at_to_password_reset_tokens.sql` | Backfills `created_at` on `password_reset_tokens` (column added by `AuditableToken` base class) | +| V12 | `V12__add_refresh_tokens.sql` | Creates `refresh_tokens` table | + +> V10 is intentionally absent - the number was reserved during development and never used. + +--- + +## Table notes + +**products.version** +Used by JPA's `@Version` for optimistic locking. Hibernate increments this column on every UPDATE. If two concurrent transactions read the same row and both try to update it, the second one finds a version mismatch and Spring throws `ObjectOptimisticLockingFailureException`, mapped to 409 Conflict by `GlobalExceptionHandler`. Clients must echo the current `version` value in every PUT request. + +**activation_tokens.validated_at** +Tokens are not deleted after use. Setting `validated_at` marks them as consumed. This preserves an audit trail and prevents reuse: the service rejects any token where `validated_at IS NOT NULL`. + +**blacklisted_tokens.jti** +Every JWT carries a unique `jti` claim (UUID). On logout, the `jti` is stored here until the token's natural expiry. `JwtAuthFilter` checks `existsByJti` before populating the `SecurityContext`, so the access token is effectively revoked even though JWTs are stateless. + +**refresh_tokens.revoked_at** +Set when the token is consumed (rotated) or when the user logs out. A non-null `revoked_at` means the token is no longer usable, even if `expires_at` is in the future. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..b47b4a0 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,187 @@ +# Deployment + +## How the application is containerized + +The project ships with a **multi-stage Dockerfile**. Using multiple stages is a production best practice: the first stage compiles the application using a full JDK and Maven image; the second stage copies only the resulting JAR into a minimal JRE image. This keeps the final image small and free of build tools. + +```dockerfile +# Stage 1 - build +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /workspace +COPY pom.xml . +RUN mvn -B dependency:go-offline # download deps first (layer cache) +COPY src ./src +RUN mvn -B clean package -DskipTests + +# Stage 2 - runtime +FROM eclipse-temurin:17-jre-jammy AS runtime +WORKDIR /app +RUN useradd --system --create-home appuser \ + && mkdir -p /app/logs \ + && chown -R appuser:appuser /app +USER appuser +COPY --from=build /workspace/target/spring-boot-tutorial-*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] +``` + +**Why copy `pom.xml` before `src/`?** Docker builds images layer by layer. By copying `pom.xml` first and running `dependency:go-offline`, Maven downloads all dependencies into a dedicated layer. This layer is cached as long as `pom.xml` doesn't change - a typical code edit only invalidates the `src/` layer and the compilation step, not the dependency download. + +**Why a non-root user (`appuser`)?** Running as `root` inside a container is a security risk: a vulnerability in the app or its dependencies could give an attacker root access to the host. Using a system user limits the blast radius. + +--- + +## docker-compose.yml + +Three services are declared: + +| Service | Image | Purpose | +|---|---|---| +| `db` | `postgres:16` | PostgreSQL database with a persistent volume | +| `app` | Built from `Dockerfile` | The Spring Boot application (prod profile) | +| `mailhog` | `mailhog/mailhog` | Local SMTP server for email testing | + +```yaml +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: ${POSTGRES_DB:-spring_boot_tutorial} + POSTGRES_USER: ${POSTGRES_USER:-spring_boot_tutorial} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-spring_boot_tutorial} + ports: + - "5432:5432" + volumes: + - db-data:/var/lib/postgresql/data # data survives container restarts + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-spring_boot_tutorial}"] + interval: 5s + retries: 5 + + app: + build: . + depends_on: + db: + condition: service_healthy # wait for PostgreSQL to be ready + environment: + SPRING_PROFILES_ACTIVE: prod + DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/spring_boot_tutorial} + DB_USERNAME: ${DB_USERNAME:-spring_boot_tutorial} + DB_PASSWORD: ${DB_PASSWORD:-spring_boot_tutorial} + JWT_SECRET: ${JWT_SECRET:-docker-compose-demo-secret-...} + ... + ports: + - "8080:8080" +``` + +**`depends_on` with `condition: service_healthy`** ensures the app container does not start until PostgreSQL is ready to accept connections. Without this, the app would start, try to connect to PostgreSQL before it finishes initializing, and crash. + +**`${VAR:-default}` syntax** reads a variable from the `.env` file (or the shell environment) and falls back to the default if not set. This keeps docker-compose.yml usable out of the box for demos while allowing proper secrets in real deployments. + +**Named volume `db-data`** persists database files across `docker compose down` / `up` cycles. Use `docker compose down -v` to also delete the volume (e.g. for a clean reset). + +--- + +## First-time setup + +```bash +# 1. Copy the environment template +cp .env.example .env + +# 2. Set the minimum required variables in .env +# - POSTGRES_PASSWORD (database password) +# - JWT_SECRET (long, random string - at least 32 characters) + +# 3. Build and start +docker compose up --build + +# 4. On subsequent starts (image already built) +docker compose up +``` + +Flyway runs all migrations automatically when the application starts. On a fresh database this creates all tables; on an existing database it only applies new migrations. + +--- + +## Useful commands + +```bash +# Start all services in the foreground (see logs) +docker compose up + +# Start in the background +docker compose up -d + +# View logs of the app service +docker compose logs -f app + +# Stop without deleting data +docker compose down + +# Stop and delete the database volume (full reset) +docker compose down -v + +# Rebuild the app image after a code change +docker compose up --build app + +# Connect to the database directly +docker compose exec db psql -U spring_boot_tutorial -d spring_boot_tutorial +``` + +--- + +## CI/CD (GitHub Actions) + +The project uses GitHub Actions for continuous integration. The workflow is defined in `.github/workflows/ci.yml` and runs on every push and pull request on any branch. + +```yaml +name: CI +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: "17" + cache: maven # caches ~/.m2 between runs + - run: ./mvnw -B clean verify +``` + +`./mvnw -B clean verify` compiles the project, runs all tests (including Testcontainers), and fails the build if any test fails. GitHub Actions provides Docker out of the box on `ubuntu-latest`, so Testcontainers works without any extra setup. + +The Maven cache (`cache: maven`) stores the local Maven repository between runs, so dependencies are only downloaded once per cache key instead of on every push. + +--- + +## Production considerations + +### JWT_SECRET + +`application-prod.yml` reads `${JWT_SECRET}` **without a default value**. The application will refuse to start if this variable is not set. This is intentional: a default JWT secret would be the same across every deployment, making it possible to forge tokens for one deployment and use them against another. + +Generate a strong secret with: +```bash +openssl rand -base64 48 +``` + +### Swagger UI in production + +Swagger UI is currently accessible in all profiles at `/swagger-ui.html`. Before going to production, consider one of: +- Disabling it with `springdoc.api-docs.enabled=false` in `application-prod.yml` +- Restricting access to it via `SecurityConfig` (require ADMIN role) +- Moving it behind a VPN or internal network + +### HTTPS + +The application does not handle TLS termination. In a production deployment, place it behind a reverse proxy (nginx, Traefik, AWS ALB, etc.) that terminates HTTPS and forwards HTTP to port 8080. + +### Email + +The `dev` profile points to MailHog (a local SMTP trap that never actually sends emails). In production, `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, and `MAIL_PASSWORD` must point to a real SMTP server (e.g. Amazon SES, SendGrid, Mailgun). MailHog's web UI at `http://localhost:8025` lets you inspect outgoing emails during local development. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..121fe86 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,125 @@ +# Getting Started + +## Prerequisites + +| Tool | Minimum version | Notes | +|---|---|---| +| Java | 17 (LTS) | The project targets Java 17 explicitly. Set `JAVA_HOME` if multiple JDKs are installed. | +| Maven | 3.9+ | The Maven wrapper (`./mvnw`) is included; no separate installation needed. | +| Docker | 24+ | Required for PostgreSQL and MailHog. Docker Desktop or Docker Engine both work. | +| Git | any recent | - | + +--- + +## Option A - Full stack via Docker Compose + +Builds the application image and starts PostgreSQL, MailHog, and the app together. + +```bash +# 1. Clone the repository +git clone https://github.com/EdgarEldy/spring-boot-tutorial.git +cd spring-boot-tutorial + +# 2. Copy the environment template and fill in the required values +cp .env.example .env +# Edit .env: set at least JWT_SECRET, DB_PASSWORD, POSTGRES_PASSWORD + +# 3. Build and start all services +docker compose up --build +``` + +The app starts with the `prod` profile. Flyway runs all migrations automatically on startup. + +| Service | URL | +|---|---| +| REST API | http://localhost:8080 | +| Swagger UI | http://localhost:8080/swagger-ui.html | +| Actuator health | http://localhost:8080/actuator/health | +| MailHog UI | http://localhost:8025 | + +--- + +## Option B - Local Maven run (dev profile) + +Runs the app via Maven with hot-reload (DevTools). The database and MailHog run in separate containers. + +### 1. Start PostgreSQL + +```bash +docker run -d \ + --name sbt-db \ + -e POSTGRES_DB=spring_boot_db \ + -e POSTGRES_USER=spring_boot_db \ + -e POSTGRES_PASSWORD=123456 \ + -p 5432:5432 \ + postgres:16 +``` + +These credentials match the defaults in `application-dev.yml` - no extra configuration needed. + +### 2. Start MailHog + +```bash +docker compose up -d mailhog +``` + +### 3. Run the application + +```bash +JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \ + ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev +``` + +Flyway applies all pending migrations on startup. The app is ready when you see: +``` +Started SpringBootTutorialApplication in X.XXX seconds +``` + +| Service | URL | +|---|---| +| REST API | http://localhost:8080 | +| Swagger UI | http://localhost:8080/swagger-ui.html | +| Actuator (health, info, metrics, appinfo) | http://localhost:8080/actuator | +| MailHog UI | http://localhost:8025 | + +--- + +## Seed data (dev profile only) + +`DataSeeder` runs at startup in the `dev` profile and creates a default admin account when no user exists yet: + +| Field | Value | +|---|---| +| Username | `admin` | +| Email | `admin@example.com` | +| Password | `Admin1234!` | +| Role | ADMIN | + +Use this account to access ADMIN-only endpoints directly in Swagger UI without going through the registration flow. + +--- + +## First request + +1. Open Swagger UI at http://localhost:8080/swagger-ui.html +2. Expand `POST /api/v1/auth/login` and log in with the seed credentials +3. Copy the `access_token` from the response +4. Click the **Authorize** button at the top of the page, enter `Bearer `, and confirm +5. All authenticated and ADMIN-only endpoints are now available + +--- + +## Stopping the services + +**Option A:** +```bash +docker compose down # stop containers, keep volumes +docker compose down -v # stop containers AND delete the database volume +``` + +**Option B:** +```bash +# Stop the app: Ctrl+C in the terminal running ./mvnw spring-boot:run +docker stop sbt-db && docker rm sbt-db +docker compose down mailhog +``` diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..f2f73ac --- /dev/null +++ b/docs/security.md @@ -0,0 +1,182 @@ +# Security + +## Overview + +Security is built on **Spring Security 6** with **stateless JWT authentication**. No session is stored server-side; every request is authenticated independently by inspecting the `Authorization: Bearer ` header. + +``` +Request + | + v +JwtAuthFilter (OncePerRequestFilter) + |-- extract token from Authorization header + |-- check blacklist (by jti) + |-- parse + validate JWT signature and expiry + |-- load UserDetails (UserDetailsServiceImpl) + |-- check enabled + non-locked + |-- populate SecurityContext + | + v +SecurityFilterChain + |-- per-endpoint authorization rules (SecurityConfig) + |-- method-level rules (@PreAuthorize) +``` + +--- + +## JWT structure + +Access tokens are signed HS256 JWTs with the following claims: + +| Claim | Type | Content | +|---|---|---| +| `sub` | String | Username | +| `jti` | String | Unique token ID (UUID) - used for blacklisting | +| `roles` | List\ | e.g. `["ROLE_ADMIN"]` | +| `permissions` | List\ | e.g. `["PERMISSION_PRODUCTS:WRITE"]` | +| `iat` | Instant | Issued at | +| `exp` | Instant | Expiry (default: 1 hour after `iat`) | + +**Refresh tokens** are opaque random strings (32 bytes, Base64 URL-safe), stored in the `refresh_tokens` table. They are valid for 7 days. + +Both values are configured in `application.yml` via `jwt.expiration-ms` (access) and `jwt.refresh-expiration-ms` (refresh). + +--- + +## Authentication flows + +### Register and activate + +``` +Client Server + | | + |-- POST /auth/register ----------->| saves AppUser (enabled=false), sends activation email + | | + |<-- 201 UserResponse --------------| + | | + | (user clicks link in email) | + | | + |-- GET /auth/activate?token=... -->| validates token, sets enabled=true + | | + |<-- 200 UserResponse --------------| +``` + +The activation token has a 24-hour TTL. `validated_at` is set on use (the token is never deleted, preserving the audit trail). + +### Login + +``` +Client Server + | | + |-- POST /auth/login (email+pwd) -->| loads AppUser by email, authenticates, + | | generates access token + refresh token + |<-- 200 AuthResponse --------------| { access_token, token_type, refresh_token, expires_in } +``` + +### Refresh + +``` +Client Server + | | + |-- POST /auth/refresh-token ------>| Authorization: Bearer + | (refresh token in header) | validates, rotates: deletes old, issues new pair + | | + |<-- 200 AuthResponse --------------| { new access_token, new refresh_token, expires_in } +``` + +### Logout + +``` +Client Server + | | + |-- POST /auth/logout ------------->| Authorization: Bearer + | | blacklists access token jti + | | deletes refresh token + |<-- 200 OK -----------------------| + | | + |-- GET /api/v1/customers --------->| JwtAuthFilter: jti is blacklisted -> 401 + |<-- 401 Unauthorized -------------| +``` + +### Forgot password / reset password + +``` +Client Server + | | + |-- POST /auth/forgot-password ---->| finds user by email, generates reset token (1h TTL), + | | sends email (async, via @Async) + |<-- 200 OK -----------------------| + | | + | (user clicks link in email) | + | | + |-- POST /auth/reset-password ----->| ?token=, body: { new_password, confirm } + | | deletes token, encodes and saves new password + |<-- 200 OK -----------------------| +``` + +The password reset token is **deleted on consumption** (single-use by deletion, not by `validated_at`). + +--- + +## Authorization rules + +### Endpoint-level (SecurityConfig) + +| Resource | GET | POST / PUT / DELETE | +|---|---|---| +| `/api/v1/categories`, `/api/v1/products` | Public | ADMIN | +| `/api/v1/customers`, `/api/v1/orders` | Authenticated | ADMIN | +| `/api/v1/auth/register`, `/api/v1/auth/login`, `/api/v1/auth/refresh-token` | Public | - | +| `/api/v1/auth/activate`, `/api/v1/auth/forgot-password`, `/api/v1/auth/reset-password` | Public | - | +| `/api/v1/auth/me`, `/api/v1/auth/logout`, `/api/v1/auth/token-info` | Authenticated | - | +| `/api/v1/users`, `/api/v1/roles`, `/api/v1/permissions` | ADMIN | ADMIN | +| `/actuator/**` | Public (health only in prod) | - | +| `/swagger-ui/**`, `/v3/api-docs/**` | Public | - | + +### Method-level (@PreAuthorize) + +`UserController`, `RoleController`, and `PermissionController` are annotated with `@PreAuthorize("hasRole('ADMIN')")` at the class level, adding a second enforcement layer on top of the filter chain rules. + +`@EnableMethodSecurity` is configured in `MethodSecurityConfig`. A static `MethodSecurityExpressionHandler` bean injects `CustomPermissionEvaluator` to resolve `hasPermission(target, action)` calls. + +### Custom PermissionEvaluator + +`CustomPermissionEvaluator` enables fine-grained checks like `@PreAuthorize("hasPermission('PRODUCTS', 'WRITE')")`. It matches the requested `RESOURCE:ACTION` pair against the `PERMISSION_RESOURCE:ACTION` authorities extracted from the JWT claims and loaded into the `SecurityContext`. + +--- + +## Token blacklist + +Access tokens are revoked by their `jti` (JWT ID), not their full value. On logout: + +1. `JwtService.extractJti(token)` reads the `jti` claim. +2. `BlacklistedTokenService.blacklist(user, token, jti, expiresAt)` saves a row in `blacklisted_tokens`. +3. `TokenCleanupScheduler` deletes expired rows every night at 03:00 to bound table growth. + +`JwtAuthFilter` calls `BlacklistedTokenService.isBlacklisted(jti)` on every authenticated request, before populating the `SecurityContext`. A `DataAccessException` on this check is caught and treated as "not blacklisted" (fail-open) to avoid a database outage blocking all authenticated traffic. + +--- + +## Password constraints + +Passwords are enforced by the `@ValidPassword` annotation on `RegisterRequest` and `ResetPasswordRequest`: + +- Minimum 8 characters +- At least one uppercase letter +- At least one digit +- At least one special character from `@#$%^&+=!` +- No spaces + +--- + +## Sensitive field masking + +`LoggingAspect` logs the arguments and return values of every service method. `AuthResponse`, `RegisterRequest`, and `LoginRequest` override `toString()` to mask sensitive fields (`password`, `access_token`, `refresh_token`), so they never appear in application logs. + +--- + +## Production notes + +- `jwt.secret` has no default value in `application-prod.yml` (`${JWT_SECRET}` with no fallback). The app refuses to start in prod without it. +- Swagger UI is accessible in all profiles including prod. Restrict or disable it once a real production deployment is set up. +- The structured log format in prod is ECS JSON (`logging.structured.format.console/file: ecs`), suitable for Elastic Stack ingestion. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..c4133e0 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,207 @@ +# Testing + +## Why these test types? + +Spring Boot applications have multiple layers, and each layer has different testing needs. Using the right test type for each layer keeps tests **fast, focused, and trustworthy**. + +``` + Scope Speed Spring context + ───────────── ───────── ────────────── +Unit tests Single class Very fast None +Repository tests DB layer only Fast Partial (JPA slice) +Controller tests HTTP layer Fast Partial (MVC slice) +Integration tests Full app Slow Full +``` + +This project follows this pyramid: many unit tests at the base, fewer but more realistic integration tests at the top. + +--- + +## Unit tests (Mockito) + +**Location:** `service/impl/`, `security/`, `scheduler/`, `dto/auth/`, `validation/`, `mapper/` + +**Annotation:** `@ExtendWith(MockitoExtension.class)` + +Unit tests isolate a single class by mocking all its dependencies with `@Mock`. They run without Spring and without a database, making them very fast. + +**Example:** `CategoryServiceImplTest` mocks `CategoryRepository` and `CategoryMapper`. It verifies that `findById` throws `ResourceNotFoundException` when the repository returns empty - without touching a real database. + +```java +@ExtendWith(MockitoExtension.class) +class CategoryServiceImplTest { + @Mock CategoryRepository categoryRepository; + @Mock CategoryMapper categoryMapper; + @InjectMocks CategoryServiceImpl categoryService; + + @Test + void _02_ShouldThrowNotFound_WhenCategoryMissing() { + when(categoryRepository.findById(99L)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> categoryService.findById(99L)) + .isInstanceOf(ResourceNotFoundException.class); + } +} +``` + +**When to use:** business logic, service rules, exception cases, edge cases. + +--- + +## Repository tests (@DataJpaTest + Testcontainers) + +**Location:** `repository/` + +**Annotations:** `@DataJpaTest`, `@AutoConfigureTestDatabase(replace = NONE)`, `@Import(RepositoryTestcontainersConfiguration.class)` + +`@DataJpaTest` is a **Spring slice**: it loads only the JPA layer (entities, repositories, Flyway) without controllers or services. This is much faster than a full application context. + +The critical choice here is `@AutoConfigureTestDatabase(replace = NONE)` combined with Testcontainers. By default, `@DataJpaTest` would try to replace the datasource with an in-memory H2 database. We override this to use a **real PostgreSQL container** instead. + +**Why not H2?** PostgreSQL and H2 have different SQL dialects. For example: +- PostgreSQL's `CHECK (email ~* '...')` (regex constraint) has no H2 equivalent +- PostgreSQL's `GENERATED BY DEFAULT AS IDENTITY` behaves differently from H2's `AUTO_INCREMENT` +- Flyway migrations written for PostgreSQL may fail on H2 + +Using a real PostgreSQL means what passes in tests will work in production. + +```java +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(RepositoryTestcontainersConfiguration.class) +class CategoryRepositoryTest { + @Autowired CategoryRepository categoryRepository; + // Flyway runs migrations against the Testcontainers PostgreSQL +} +``` + +**`RepositoryTestcontainersConfiguration`** declares a `PostgreSQLContainer` bean annotated `@ServiceConnection`. Spring Boot's `@ServiceConnection` automatically wires the container's JDBC URL, username, and password into the test's datasource - no manual property overriding needed. + +**When to use:** custom `@Query` methods, derived queries, `@EntityGraph` behavior, Flyway migration correctness. + +--- + +## Controller tests (@WebMvcTest) + +**Location:** `controller/` + +**Annotations:** `@WebMvcTest(XxxController.class)`, `@MockitoBean` for services + +`@WebMvcTest` is another Spring slice: it loads only the web layer (controllers, filters, security config) without services or repositories. Services are replaced by `@MockitoBean`, which creates a Mockito mock and registers it as a Spring bean. + +```java +@WebMvcTest(CategoryController.class) +class CategoryControllerTest { + @Autowired MockMvc mockMvc; + @MockitoBean CategoryService categoryService; + // JwtAuthFilter also needs these to construct (see note below): + @MockitoBean JwtService jwtService; + @MockitoBean UserDetailsService userDetailsService; + @MockitoBean BlacklistedTokenService blacklistedTokenService; +} +``` + +> **Important:** Spring Boot includes every `Filter` bean in `@WebMvcTest` slices, even those unrelated to the controller under test. `JwtAuthFilter` depends on `JwtService`, `UserDetailsService`, and `BlacklistedTokenService`. All three must be mocked even in controller tests that never send a JWT, or the slice will fail to build. + +**When to use:** HTTP status codes, JSON serialization, `@Valid` validation, request mapping, response body shape. + +--- + +## Integration tests (Testcontainers + full context) + +**Location:** `controller/SecurityAuthorizationTest.java`, `service/impl/CategoryServiceCacheTest.java`, etc. + +**Annotations:** `@SpringBootTest`, `@AutoConfigureMockMvc`, `@Import(SecurityTestcontainersConfiguration.class)` + +`@SpringBootTest` loads the complete application context - every bean, filter, and configuration class, with Testcontainers providing the real PostgreSQL. These tests are slower but test real behavior end to end. + +`SecurityAuthorizationTest` is the main integration test suite. It exercises the real `SecurityFilterChain` with real JWTs: + +```java +// Register -> enable account -> login -> extract real JWT -> call protected endpoint +MvcResult loginResult = mockMvc.perform(post("/api/v1/auth/login") ...) + .andReturn(); +String token = objectMapper.readTree(loginResult.getResponse().getContentAsString()) + .path("data").path("access_token").asText(); + +mockMvc.perform(get("/api/v1/customers").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); +``` + +This kind of test cannot be replaced by unit or slice tests: it verifies that the security rules declared in `SecurityConfig` are actually enforced by the real filter chain, not by a mock assumption. + +**When to use:** security rules, full flows (register/activate/login/logout), cache behavior, event publishing. + +--- + +## Test naming convention + +Every test method follows this pattern: + +``` +_NN_Should_When +``` + +- `NN` is a zero-padded sequence number, starting at `01` in each class +- `` describes what the method returns or does +- `` describes the input state + +Examples: +```java +_01_ShouldReturnCategory_WhenCategoryExists() +_02_ShouldThrowNotFound_WhenCategoryMissing() +_03_ShouldReturn400_WhenCategoryNameIsBlank() +``` + +The numbered prefix ensures tests appear in a predictable, readable order in the IDE's test runner, even though JUnit 5 does not guarantee execution order. + +--- + +## Running the tests + +```bash +# Run the full test suite +JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 ./mvnw clean test + +# Run a single test class +JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 ./mvnw test -Dtest=CategoryServiceImplTest + +# Run with explicit Docker socket (if Testcontainers can't find Docker) +DOCKER_HOST=unix:///home//.docker/desktop/docker.sock \ + JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 ./mvnw clean test +``` + +> If Testcontainers fails with `Could not find a valid Docker environment` but `docker info` works from the terminal, Docker Desktop is using a non-default socket. Set `DOCKER_HOST` to the Docker Desktop socket path and retry. + +--- + +## Known test design decisions + +### No H2, only Testcontainers + +There is no H2 dependency in `pom.xml`. All repository and integration tests use a real PostgreSQL container. The tradeoff: tests take longer to start (container spin-up), but they never produce false positives from dialect differences. + +### @WebMvcTest with addFilters=false does NOT remove JwtAuthFilter + +`@AutoConfigureMockMvc(addFilters = false)` prevents filters from executing during the request, but Spring Boot still includes every `Filter` bean in the application context for `@WebMvcTest`. `JwtAuthFilter` will still be instantiated and needs its dependencies mocked. + +### Cache isolation between tests + +`@SpringBootTest` reuses the same application context (and the same `CacheManager`) across all test methods in a class. Without explicit cache clearing, a cached result from one test can affect the next. `CategoryServiceCacheTest` and `ProductServiceCacheTest` clear the cache in `@BeforeEach`: + +```java +@BeforeEach +void clearCache() { + cacheManager.getCache("categories").clear(); +} +``` + +### @DataJpaTest requires JpaAuditingConfig for tables with audit columns + +`Category` has `created_at`/`updated_at` columns managed by JPA auditing (`@CreatedDate`, `@LastModifiedDate`). Any `@DataJpaTest` that creates a `Category` must import `JpaAuditingConfig`: + +```java +@Import({RepositoryTestcontainersConfiguration.class, JpaAuditingConfig.class}) +class CategoryRepositoryTest { ... } +``` + +Without it, the NOT NULL constraint on `created_at` causes a `DataIntegrityViolationException`.