diff --git a/.gitignore b/.gitignore index 0f67200..0065572 100644 --- a/.gitignore +++ b/.gitignore @@ -86,10 +86,6 @@ docs/database/merise/learn-dev_mld.mcd docs/database/merise/learn-dev_mld.md docs/database/merise/learn-dev_mld_geo.json -docs/database/ddl/learn-dev_geo.json -docs/database/ddl/learn-dev.mcd -docs/database/ddl/learn-dev.svg - ### ~~~~~~~~~~~~~~~~~~~~~~~~~~ ### Python Virtual Environment ### ~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..6967490 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,145 @@ +# Architecture + +How learn-dev is structured and how a request flows through it. This file answers +**"how do the pieces fit together, and why is it built this way?"** For the list of +tools and versions see [docs/tech-stacks.md](docs/tech-stacks.md); for term +definitions see [GLOSSARY.md](GLOSSARY.md); for individual decisions and their +trade-offs see the [ADRs](docs/adr/README.md). + +## Overview + +learn-dev is a server-rendered, layered Spring Boot monolith. The browser talks to +Spring MVC controllers; controllers delegate to services; services use Spring Data +JPA repositories over a PostgreSQL relational core. HTML is produced server-side by +Thymeleaf. The app is a monolith today, with a longer-term intent to split selected +concerns into microservices (which is why service-to-service auth is already being +considered in [ADR-0002](docs/adr/0002-service-to-service-auth-via-service-token.md)). + +## Architectural style + +- **Server-rendered MVC.** Controllers return logical view names, not HTML or JSON; + a Thymeleaf `ViewResolver` renders the corresponding template. +- **Layered.** Each layer depends only on the one below it: + + ``` + Browser + │ HTTP (form posts, GETs) + ▼ + Controller (web layer: @Controller, request mapping, validation) + │ calls + ▼ + Service (business logic, @Transactional boundaries) + │ calls + ▼ + Repository (Spring Data JPA interfaces) + │ SQL via Hibernate + ▼ + PostgreSQL (relational core) + ``` + +## Package structure + +Packages are organised by **feature**, not by technical layer, so a feature's +controller, service, entity, and repository live together: + +``` +com.ericbouchut.learndev +├── auth # AuthController, RegistrationService, CustomUserDetailsService, +│ # dto/RegisterForm, exception/Duplicate*Exception +├── user # entity/User, repository/UserRepository +├── role # entity/Role, repository/RoleRepository +├── common +│ └── config # SecurityConfig (filter chain, PasswordEncoder) +└── (test) support # AbstractPostgresIT (shared Testcontainers base) +``` + +## Request and rendering flow + +1. A controller method (for example `AuthController.home()`) returns a **view name** + such as `"home"` (a lookup key, not HTML). +2. The Thymeleaf `ViewResolver` maps the name to `src/main/resources/templates/home.html`. +3. The template engine renders the HTML, evaluating `th:*` attributes, escaping + output (XSS defence), and injecting the CSRF token into forms that use `th:action`. +4. The `DispatcherServlet` writes the HTML as the response body with the appropriate + headers and status. + +A method may instead return a `redirect:` prefix (for example +`redirect:/auth/login?registered`), which produces a `302` rather than rendering a view. + +## Authentication and authorization + +- **Session-based form login.** Credentials are verified once; the session is kept + server-side and referenced by the `JSESSIONID` cookie + (see [ADR-0001](docs/adr/0001-use-server-side-sessions-over-jwt.md)). JWT was + rejected for the browser flow. +- **Filter chain.** `SecurityConfig` defines the `SecurityFilterChain`: public paths + (`/`, `/auth/**`, static assets) are permitted; everything else requires + authentication. Auth endpoints are grouped under the `/auth/` URL prefix. +- **User loading.** `CustomUserDetailsService` loads a `User` by username and maps + each `Role` to a Spring Security authority prefixed with `ROLE_` (so `ADMIN` + becomes `ROLE_ADMIN`). Account flags map to `disabled` (`is_active`) and + `accountLocked` (`is_locked`). +- **Passwords.** Hashed with BCrypt; the raw password is never persisted. +- **CSRF.** Enabled by default; Thymeleaf injects a per-form token. `SameSite=Lax` + on the session cookie adds browser-level defence in depth. +- **Session cookie hardening.** `HttpOnly` and `SameSite=Lax` are set in the base + config; `Secure` is enabled once served over HTTPS. + +### Registration flow + +`RegisterForm` (validated with Bean Validation) → `AuthController` → `RegistrationService` +(checks unique username/email, hashes the password, assigns the default `STUDENT` +role, saves) → redirect to the login page. Duplicate username/email surface as +field errors on the re-rendered form. + +## Data architecture + +- **Relational core (PostgreSQL).** Users, roles, and (upcoming) courses/lessons. + Users use a **UUID** primary key to avoid enumeration; other tables use `BIGINT` + identity (see [ADR-0003](docs/adr/0003-uuid-pk-for-users-bigint-elsewhere.md)). +- **Document store (MongoDB).** Provisioned and configured for future content + storage; not yet used by any feature. +- **Schema evolution.** Managed by Liquibase, run at startup. Migrations are + hand-written formatted-SQL files, one atomic changeset per file, append-only + (see [ADR-0005](docs/adr/0005-handwrite-liquibase-migrations-over-mcd-ddl.md)). +- **Modelling.** The schema is designed in Merise (MCD → MLD → MPD) and cross-checked + against the migrations by a schema-drift CI job. + +## Configuration and environments + +- **Profiles.** `application.yaml` holds base config; `application-dev.yaml` holds + dev overrides. `SPRING_PROFILES_ACTIVE=dev` selects the profile and the Liquibase + `dev` context. +- **Secrets.** Loaded from `./.env` (a 1Password-filled FIFO) via spring-dotenv at + startup, so the working directory must be the project root. + +## Testing strategy + +Tests form a pyramid, all run under Surefire in `mvn test` +(see [ADR-0009](docs/adr/0009-run-tests-under-surefire-not-failsafe.md)): + +- **Unit tests** — mock collaborators (Mockito), no I/O + (`RegistrationServiceTest`, `CustomUserDetailsServiceTest`). +- **Slice tests** — `@DataJpaTest` against a real Postgres container + (`UserRepositoryTest`, `RoleRepositoryTest`). +- **Integration test** — full context + MockMvc for the end-to-end auth journey + (`AuthFlowTest`). +- **Smoke test** — verifies the context starts (`LearnDevApplicationTests`). + +Tests use a real PostgreSQL via Testcontainers rather than H2 +(see [ADR-0006](docs/adr/0006-test-against-real-postgres-testcontainers.md)), shared +as a static singleton container (see [ADR-0008](docs/adr/0008-share-singleton-testcontainers-postgres.md)). + +## Build and run + +- `make test` — run the suite (Podman-aware Testcontainers wiring). +- `make run` — start the databases and run the app (`http://localhost:8080/`). +- `docker compose up -d` — start Postgres and Mongo (`docker` is Podman here). + +## Direction of travel + +- Password-reset flow with email (Mailpit locally, see + [ADR-0004](docs/adr/0004-use-mailpit-as-local-smtp-catcher.md)). +- Possible extraction of microservices, with service-to-service authentication + ([ADR-0002](docs/adr/0002-service-to-service-auth-via-service-token.md)). +- A `SUPERADMIN` role (deferred under YAGNI; issue #65). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d29b497..7846b46 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,12 +37,14 @@ The code reference documentation is not yet available and will be added to this #### Architecture Decision Records (ADR) -Significant architectural and design decisions are recorded as **ADRs** under -[`docs/adr/`](docs/adr/), using the **MADR** short form. ADRs are an -append-only, numbered log: a decision is never rewritten; a new ADR supersedes -an old one. Files are named `NNNN-short-title-in-kebab-case.md` (4-digit -zero-padded sequence). To add one, copy [`docs/adr/template.md`](docs/adr/template.md) -and add it to the index in [`docs/adr/README.md`](docs/adr/README.md). +Significant Architectural and design Decisions are Recorded as **ADRs** under +[`docs/adr/`](docs/adr/), as Markdown files using the **[MADR](https://adr.github.io/madr/)** structure. +ADRs are an append-only, numbered log: a decision is never rewritten. +A new ADR supersedes an old one. +Files are named `NNNN-short-title-in-kebab-case.md` (4-digit zero-padded sequence). + +When creating an ADR use [`docs/adr/template.md`](docs/adr/template.md) as a template, +then add a link to the new ADR to the index in [`docs/adr/README.md`](docs/adr/README.md). #### Architecture Overview @@ -335,6 +337,17 @@ The main advantages in my opinion are: > e.g.: **`learnDevApplication`** +#### URL / Routing Conventions + +- **Authentication endpoints are grouped under the `/auth/` prefix**: + `/auth/login`, `/auth/register`, `/auth/logout` (and future `/auth/reset-password`). + This centralizes everything related to authentication and mirrors the + feature-based package layout (the `auth` package owns `/auth/**`). +- **Application pages stay at the root or under their own feature prefix** + (for example `/dashboard`, `/courses/**`), not under `/auth/`, since they are + not authentication actions. + + #### Database The *learn-dev* platform uses a **[PostgreSQL](https://www.postgresql.org/)** relational database to persist entities. @@ -886,10 +899,7 @@ TODO: Explain how and where to update the database schema ### Add a Dependency -We use different package/dependencies managers on the backend and the frontend: - -- `Maven` on the backend -- `npm` on the frontend +We use `Maven` as a packages/dependencies manager on the backend. ### Add a Backend Dependency @@ -915,7 +925,7 @@ We use different package/dependencies managers on the backend and the frontend: 5. Verify the dependency resolves correctly: ```shell - cd backend && mvn dependency:resolve + mvn dependency:resolve ``` @@ -923,13 +933,52 @@ We use different package/dependencies managers on the backend and the frontend: TODO: Explain how to write tests, what naming convention and best practices +#### Test Naming Conventions + +- The file name of a test class should end in `Test`. + Although this is counterintuitive and the opposite of the standard Java + method naming convention, it makes the test output easier to read. + + ### Running Tests -TODO: +Repository and integration tests run against a **real PostgreSQL** started by +[Testcontainers](https://testcontainers.com/) (see ADR-0006), so a container +engine must be running. This project uses **Podman**. #### Run All Tests -TODO: Explain how to run tests +The simplest way is to use ` make test`, which configures *Testcontainers* for +*Podman* automatically: + +```bash +make test +``` + +It is equivalent to `./mvnw test` plus the Podman wiring described below. + +#### Podman setup for Testcontainers + +*Testcontainers* looks for a _Docker_ socket at `/var/run/docker.sock`, +which does not exist under _Podman_. + +The workaround is to define two environment variables: + +```bash +# Point Testcontainers at the Podman socket (resolved dynamically): +export DOCKER_HOST="unix://$(podman machine inspect --format '{{.ConnectionInfo.PodmanSocket.Path}}')" + +# Ryuk (the Testcontainers reaper) misbehaves under rootless Podman, so disable it: +export TESTCONTAINERS_RYUK_DISABLED=true +``` + +Add these to your shell profile (for example `~/.zshrc`), +source it, then run `./mvnw test` directly, or just use `make test`, +which sets them for you. +Make sure the Podman machine is started first: `podman machine start`. + +On real Docker (for example in CI) neither variable is needed; `make test` +falls back to a plain `./mvnw test`. ### Generating the Documentation diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 0000000..6ed219e --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,127 @@ +# Glossary + +Definitions of the domain and technical terms used across the learn-dev +project. For the concrete tools and versions, see [docs/tech-stacks.md](docs/tech-stacks.md); +for how the pieces fit together, see [ARCHITECTURE.md](ARCHITECTURE.md); for the +rationale behind design decisions, see the [ADRs](docs/adr/README.md). + +## Domain terms + +- **Archive** — Unpublish a course or lesson so it is no longer available to + students, without deleting it. +- **Course** — A unit of learning content owned by an instructor; contains lessons. +- **Deactivate** — Disable an account (for example an instructor or student) so it + can no longer be used, without deleting it. See also *disabled account*. +- **Drop a course** — A student withdrawing from a course before finishing it. +- **Enrollment** — The relationship linking a student to a course they have joined. +- **Lesson** — An individual piece of content within a course. +- **Role** — A named set of permissions granted to a user. The seeded roles are + `STUDENT`, `INSTRUCTOR`, and `ADMIN`; `SUPERADMIN` is planned (see issue #65). + +## Authentication and security + +- **Authority** — In Spring Security, a single granted permission string held by an + authenticated user. Roles are represented as authorities prefixed with `ROLE_` + (for example the `ADMIN` role becomes the authority `ROLE_ADMIN`). +- **BCrypt** — An adaptive password-hashing function. Passwords are stored as BCrypt + hashes, never in clear text. +- **CSRF (Cross-Site Request Forgery)** — An attack that tricks a logged-in user's + browser into submitting an unwanted request. Defended with a per-form token + (injected by Thymeleaf) and the `SameSite` cookie attribute. +- **Disabled account** — An account that exists but is not allowed to authenticate + (mapped from the `is_active = false` flag). Distinct from a *locked account*. +- **HttpOnly** — A cookie attribute that hides the cookie from client-side + JavaScript, mitigating session theft via XSS. +- **IDOR (Insecure Direct Object Reference)** — An access-control flaw where a + client-supplied identifier is trusted without an authorization check. Using UUID + primary keys for users mitigates enumeration (see [ADR-0003](docs/adr/0003-uuid-pk-for-users-bigint-elsewhere.md)). +- **Locked account** — An account temporarily blocked from authenticating (for + example after too many failed logins), mapped from the `is_locked` flag. Distinct + from a *disabled account*. +- **Principal** — The currently authenticated entity (typically the user) within a + security context. +- **SameSite** — A cookie attribute controlling whether the browser sends the cookie + on cross-site requests. Set to `Lax` here as CSRF defense in depth. +- **Secure (cookie)** — A cookie attribute that restricts the cookie to HTTPS. + Enabled only once the app is served over TLS. +- **Session (server-side)** — Authentication state kept on the server and referenced + by a session cookie (`JSESSIONID`), rather than a self-contained token + (see [ADR-0001](docs/adr/0001-use-server-side-sessions-over-jwt.md)). +- **XSS (Cross-Site Scripting)** — Injection of malicious scripts into pages viewed + by other users. Mitigated by Thymeleaf's automatic output escaping and `HttpOnly`. + +## Persistence and data modelling + +- **Changelog / Changeset (Liquibase)** — A changelog is the ordered list of + migrations; a changeset is one atomic migration, identified by `path::id::author`. +- **ERD (Entity-Relationship Diagram)** — A diagram of entities and their + relationships (rendered here with Mermaid). +- **Hibernate** — The JPA implementation (ORM) used to map Java entities to tables. +- **JPA (Jakarta Persistence API)** — The standard Java API for object-relational + mapping; implemented by Hibernate. +- **JSESSIONID** — The default name of the servlet session cookie. +- **Liquibase** — The database schema migration tool. Migrations are hand-written + formatted-SQL files applied at startup (see [ADR-0005](docs/adr/0005-handwrite-liquibase-migrations-over-mcd-ddl.md)). +- **Merise** — A French data-modelling method producing three views: MCD, MLD, MPD. +- **MCD (Modele Conceptuel de Donnees)** — Conceptual data model; the entities and + relationships independent of any database. +- **MLD (Modele Logique des Donnees)** — Logical data model; the relational schema + (tables, keys) derived from the MCD. +- **MPD (Modele Physique des Donnees)** — Physical data model; the concrete schema + as implemented in PostgreSQL. +- **ORM (Object-Relational Mapping)** — Mapping between Java objects and relational + tables; provided by Hibernate/JPA. +- **UUID** — A 128-bit identifier used as the primary key for users to avoid + sequential-id enumeration. + +## Build, testing, and tooling + +- **ADR (Architecture Decision Record)** — A short, numbered, append-only document + capturing one design decision and its trade-offs, in MADR format. +- **Bean Validation** — The Jakarta standard for declaring constraints + (`@NotBlank`, `@Email`, `@Size`) on form/DTO fields, enforced with `@Valid`. +- **DTO (Data Transfer Object)** — An object carrying data across a boundary, + deliberately separate from entities. A `...Form` DTO backs an HTML form. +- **Failsafe** — The Maven plugin that runs `*IT` integration tests in the `verify` + phase. This project does **not** use it (see [ADR-0009](docs/adr/0009-run-tests-under-surefire-not-failsafe.md)). +- **FIFO (named pipe)** — A special file that streams data on read. The project's + `.env` is a FIFO filled by 1Password; shell `source` cannot read it (0-byte stat). +- **HikariCP** — The JDBC connection pool bundled with Spring Boot. +- **Integration test** — A test that boots a Spring context and exercises multiple + layers together (here `@SpringBootTest` against a real Postgres container). +- **Lombok** — A library that generates boilerplate (getters, constructors) from + annotations at compile time. +- **MADR (Markdown ADR)** — The lightweight ADR template format used in `docs/adr/`. +- **Slice test** — A test that loads only one layer of the context (for example + `@DataJpaTest` for the persistence layer). +- **Smoke test** — A minimal test that the application context starts at all + (`LearnDevApplicationTests`). +- **Surefire** — The Maven plugin that runs `*Test`/`*Tests` unit and integration + tests in the `test` phase. All tests here run under Surefire. +- **Testcontainers** — A library that starts throwaway Docker/Podman containers for + tests; used to run a real PostgreSQL (see [ADR-0006](docs/adr/0006-test-against-real-postgres-testcontainers.md)). +- **Ryuk** — Testcontainers' companion container that cleans up resources; disabled + under Podman in this project. +- **YAGNI (You Aren't Gonna Need It)** — The principle of not building features + until they are actually needed (for example deferring the `SUPERADMIN` role). + +## Infrastructure and process + +- **Docker Compose** — Declarative multi-container orchestration; here it runs + Postgres and Mongo. `docker` on the dev machine is Podman. +- **GitButler** — The version-control tool wrapping Git; used via the `but` CLI when + the current branch is `gitbutler/workspace`. +- **Podman** — A daemonless container engine, used as the `docker` drop-in. +- **Spring profile** — A named configuration set (for example `dev`) selecting + profile-specific properties and Liquibase contexts. +- **Thymeleaf** — The server-side HTML template engine. Its Spring Security + **dialect** (`sec:` namespace) exposes the authenticated user to templates. + +## Certification + +- **CCP (Certificat de Competences Professionnelles)** — A competency block of a + French Titre Professionnel; the DWWM has a front-end and a back-end CCP. +- **DWWM (Developpeur Web et Web Mobile)** — The French Titre Professionnel this + capstone targets. +- **REAC (Referentiel Emploi Activites Competences)** — The official competency + reference framework defining what the certification assesses. diff --git a/Makefile b/Makefile index 4172e12..ee648c0 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Ignore existing files with the same name as phony targets -.PHONY: help diagrams mcd mld mpd clean check-schema-drift +.PHONY: help diagrams mcd mld mpd clean check-schema-drift test run # Default make target used if none specified .DEFAULT_GOAL := help @@ -13,16 +13,44 @@ help: @echo " make mpd — generate MPD" @echo " make clean — remove generated diagrams" @echo " make check-schema-drift — fail if a Liquibase column is missing from the MCD" + @echo " make test — run the test suite via Testcontainers" + @echo " make run — start the databases and run the Spring Boot app" # Generate all database diagrams (MCD, MLD, MPD) diagrams: mcd mld mpd @echo "All diagrams generated (MCD, MLD, MPD)" +# Run the test suite. Tests use Testcontainers (a real PostgreSQL), so a +# container engine must be running. Under Podman, point Testcontainers at the +# Podman socket and disable Ryuk. Under Docker, run the Maven wrapper directly. +test: + @echo "Running tests..." + @SOCK=$$(podman machine inspect --format '{{.ConnectionInfo.PodmanSocket.Path}}' 2>/dev/null); \ + if [ -n "$$SOCK" ]; then \ + echo "Podman detected, socket: $$SOCK"; \ + DOCKER_HOST="unix://$$SOCK" TESTCONTAINERS_RYUK_DISABLED=true ./mvnw test; \ + else \ + ./mvnw test; \ + fi + # Fail if a Liquibase table column is missing from the MCD diagram source. # Heuristic (column-name presence only); CI-friendly (non-zero exit on drift). check-schema-drift: python3 scripts/check_schema_drift.py +# Run the Spring Boot app locally. Container-engine agnostic: if Podman is +# installed, start its machine when the socket is unreachable; otherwise assume +# Docker. Then bring up the Postgres + Mongo containers and run the app in the +# foreground (Ctrl+C to stop). Run from the project root to load the ./.env file. +run: + @if command -v podman >/dev/null 2>&1; then \ + podman info >/dev/null 2>&1 || podman machine start; \ + fi + @echo "Starting databases..." + docker compose up -d + @echo "Starting the app (http://localhost:8080/ , Ctrl+C to stop)..." + ./mvnw spring-boot:run + # Generate MCD from Mocodo source mcd: @echo "Generating MCD..." diff --git a/README.md b/README.md index 3aa92f5..7e2fcb2 100644 --- a/README.md +++ b/README.md @@ -140,23 +140,58 @@ Here is the procedure: ## Run the application -This starts all the Docker services for the application: ```shell + # Make sure the required versions of Java and Maven are active for this shell + sdk env + + # Ensure the Podman "machine" is up and running + podman info >/dev/null 2>&1 || podman machine start + + # Start the "Docker" services for the application docker compose up -d + + # Run the app from the project root + ./mvnw spring-boot:run ``` -**For each service** (`postgres`, `mongo`) -declared in the *Docker Compose* configuration file -(`docker-compose.yaml`), *Docker Compose*: +The first command starts the Podman machine if it is not already running. +Then `docker compoose up -d` starts all the application Docker services + as declared in [docker-compose.yaml](docker-compose.yaml) +(the *Docker Compose* configuration file), like this. +For each service (`postgres` and `mongo`): + +1. Download the Docker image for this service as specified in `docker-compose.yaml` + from the [Docker Hub](https://hub.docker.com/) public registry, only if the Docker + image is not already cached locally. +2. Store the downloaded image in the local Docker image cache. +3. Start a Docker container (if it is not already running) based on this image + and the configuration in `docker-compose.yaml`. + + +> [!NOTE] +> A Docker init script automatically **creates the database user and the application database** +> when the **`postgres`** service is run **for the first time**. +> It does not create the database structure or populate the database. + +> [!NOTE] +> TODO: Explain how the database is created in MongoDB and when. + +> [!NOTE] +> For Docker or Podman to run on macOS and Windows they need a Linux OS. +> +> **Why?** +> Containers rely on Linux kernel features (*namespaces* and *cgroups*). +> Windows and macOS do not have a *Linux* kernel. +> This is why Docker Desktop and Podman run a lightweight *Linux* VM +> behind the scenes. +> The containers run inside that hidden *VM*, not directly on macOS/Windows. -1. downloads the Docker image (if not cached yet) from the Docker Hub registry, -2. stores the downloaded image in the local Docker image cache, -3. starts a Docker container based on this image (if it is not already running). ## Stop the Application -This stops all the Docker services for the application: +This command stops all the application services containers +declared in the Docker Compose file (`docker-compose.yaml`): ```shell docker compose down @@ -165,37 +200,41 @@ This stops all the Docker services for the application: ### Docker Terminology -I use ** Docker Compose** (a CLI tool) to describe and handle the lifecycle of services that comprise my application. +I use **Docker Compose** (a CLI tool) to describe and handle the lifecycle of services that comprise my application. A **service** is basically a component of the application packaged as a Docker container. It specifies the Docker image and version, configuration, and the network and Docker volume(s) if any. -A Docker image is pre-packaged piece of software that can work as a standalone on Linux. +A **Docker image** is pre-packaged piece of software that can work as a standalone on Linux. **Docker Hub** is a public registry that hosts and serves public Docker images. ### Postgres Service -#### Start Postgres +Once the `postgres` service container and its named data volume +have been created with `docker compose up -d`, +you can stop then restart the `postgres` service container individually. + -Running the app using `docker compose up -d` -starts **all** the application services, including `postgres`. +#### Stop Postgres -To only start the `postgres` service: ```shell -docker compose start -d postgres -docker compose logs postgres +docker compose stop postgres +``` + +This command stops the `postgres` service container. +It does NOT remove its data volume (its databases). + +#### Start Postgres + +This command **restarts the existing stopped** `postgres` service container. +If the service container does not already exist, use `docker compose up -d` to create it. + +```shell +docker compose start postgres ``` -> [!NOTE] -> -> The above command downloads, installs the `postgres` Docker image -> specified by the `postgres` service in `docker-compose.yaml`. -> Then it runs a Docker container with this image. -> [!NOTE] -> A Docker init script automatically **creates the database user and the application database** -> when the **`postgres`** service is run **for the first time**. Now, check that `postgres` is running: @@ -207,31 +246,42 @@ docker compose ps | grep postgres > and remove the (data) volumes. > See the `Remove all Posgres Databases` section for details. -#### Stop Postgres -```shell -docker compose stop postgres -``` +#### Remove the Postgres Databases -#### Remove all Postgres Databases - -Stops and remove the `postgres` container and its data volume. +Stops and **remove** the `postgres` service **container and its data volumes** (meaning all its databases). > [!CAUTION] -> This is a **destructive command** that will **remove all the databases -> (structure and content)** created by Postgres running in the container. +> This **destructive command** will: +> - stop and remove the `postgres` service container, +> - **remove ALL its databases: structure and content**, +> (i.e., everything created by Postgres running in the container). ```shell -docker compose stop postgres # Stop the container -docker rm postgres # Remove the container -docker volume rm pg_data # Remove the named volume +docker compose down -v postgres ``` ### Mongo Service +Once the `mongo`service container has been created with `docker compose up -d`, +you can stop then restart the `mongo` service container individually. + + +#### Stop MongoDB + +```shell +docker compose down mongo +``` +This command stops the `mongo` service container. +It does NOT remove its data volume (i.e., the MongoDB databases created in this container). + + #### Start MongoDB +This command **restarts the existing stopped** `mongo` service container. +If the service container does not already exist, use `docker compose up -d` to create it. + ```shell docker compose start mongo ``` @@ -242,52 +292,67 @@ Now, check that `mongo` is running: docker compose ps | grep mongo ``` -#### Stop MongoDB - -```shell -docker compose stop mongo -``` #### Remove MongoDB and its Databases > [!CAUTION] -> This **destructive command** will stop and remove the container, then **remove** its data **volume** -> (all the databases created by MongoDB running in the container). +> This **destructive command** will: +> - stop and remove the `mongo` service container, +> - **remove** its data **volumes** (i.e., **ALL** the **databases** created by MongoDB running in the container). ```shell -docker compose stop mongo # Stop container -docker rm mongo # Remove the stopped container -docker volume rm learn-dev_mongo_data # Remove the named volume +docker compose down -v mongo ``` +Where: +- `-v` request Compose to remove the named data volumes created for this service + ## Project Status -See the [GitHub Project](https://github.com/users/ebouchut/projects/7/views/3) for up-to-date information. +For up-to-date information about the status of the project, +visit [this link](https://github.com/users/ebouchut/projects/7/views/3). + + +## Documentation + +- [ARCHITECTURE.md](ARCHITECTURE.md) — how the pieces fit together (layers, request flow, authentication, data, testing). +- [docs/tech-stacks.md](docs/tech-stacks.md) — catalogue of tools, languages, and frameworks with versions used in the project. +- [GLOSSARY.md](GLOSSARY.md) — definitions of the domain and technical terms used across the project. +- [Architecture Decision Records](docs/adr/README.md) — A list of design decisions and their trade-offs. ## Contributing -See the [CONTRIBUTING.md](CONTRIBUTING.md) file for how to help out. -It contains detailed guidelines, including: +**[CONTRIBUTING.md](CONTRIBUTING.md)** contains: -- Architecture overview -- Code: +- How to help +- [Code of Conduct](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#code-of-conduct) +- [Architecture overview](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#architecture-overview) +- [Architecture Decision Records](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#architecture-decision-records-adr) (ADRs) +- Codebase: - Documentation - - Directory structure - - Naming conventions -- Database: - - Database schema, ERD (Entity Relationships Diagram) - - Running database migrations + - [MonoRepo](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#monorepo) + - [Directory structure](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#directory-structure) + - [Feature-based package layout](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#feature-based-package-layout) + - [File naming conventions](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#file-naming-convention) +- **Database**: + - [Database Naming Conventions](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#database-naming-conventions) + - Database schema: + - **[MCD](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#mcd-diagram)**, + - **[MLD](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#mld-diagram)**, + - **[MPD](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#mpd-diagram)**, + - **[ERD](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#erd-diagram)**. + - [Database migrations](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#database-migrations-liquibase) - Git: - - Git branching strategy - - Git commit message conventions + - Git [branching strategy](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#git-branching-strategy) + - Git [commit message convention](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#git-commit-message-convention) - Dependencies: - Adding dependencies - Installing dependencies - Running tests -- Submitting pull requests -- ... +- Submitting Pull Requests +- TODO: ... ## License diff --git a/docs/adr/0005-handwrite-liquibase-migrations-over-mcd-ddl.md b/docs/adr/0005-handwrite-liquibase-migrations-over-mcd-ddl.md new file mode 100644 index 0000000..9130a33 --- /dev/null +++ b/docs/adr/0005-handwrite-liquibase-migrations-over-mcd-ddl.md @@ -0,0 +1,59 @@ +# Hand-write the database schema as Liquibase migrations, not generated from the MCD + +- Status: accepted +- Date: 2026-06-16 +- Deciders: Eric Bouchut + +## Context and Problem Statement + +The Merise MCD (mocodo) can emit a PostgreSQL DDL via `mocodo -t postgres`, and +this was initially used to scaffold the schema. The generated DDL proved too +poor to create the real database: the MCD carries no physical types, so every +column came out as `VARCHAR(42)`, with no real types, constraints, defaults, or +indexes, and with invalid table names (e.g. `User`, a reserved word). How should +the database schema be authored and evolved? + +## Decision Drivers + +- The schema needs real PostgreSQL types, constraints, defaults, and indexes. +- The schema evolves over time and must be versioned and reviewable. +- A single, executable source of truth for the database structure. +- Avoid maintaining a generator whose output must be heavily rewritten anyway. + +## Considered Options + +- Generate the DDL from the MCD (`mocodo -t postgres`) and apply it. +- Hand-write the schema as Liquibase migrations; keep the MCD as documentation only. + +## Decision Outcome + +Chosen: **hand-write the schema as Liquibase migrations** +(`src/main/resources/db/changelog/changes/`, formatted SQL, append-only, one +changeset per file). The mocodo DDL generation is removed. The MCD/MLD/MPD remain +**documentation** of the model, not a source for the DDL. A `check-schema-drift` +guard verifies the MCD lists every column present in the migrations. + +### Consequences + +- Good: full control over PostgreSQL types, constraints, defaults, indexes, and + naming; the schema is versioned, reviewable, and rollback-able. +- Good: one executable source of truth (the migrations); the MPD (`tbls`) is + generated from the live database, so it cannot drift. +- Trade-off: the MCD must be kept in sync with the migrations by hand — mitigated + by the `check-schema-drift` CI check. +- Note: the `mocodo -t postgres` DDL target and `docs/database/ddl/` were removed. + +## Pros and Cons of the Options + +### Generate DDL from the MCD (mocodo) + +- 👍 Single source (the MCD); no hand-written SQL +- 👎 MCD has no physical types → `VARCHAR(42)` everywhere; no constraints, + defaults, or indexes; invalid/reserved table names; output must be rewritten, + so it is not actually a usable source + +### Hand-write Liquibase migrations (chosen) + +- 👍 Real types, constraints, indexes; versioned, reviewable, rollback-able; + executable source of truth +- 👎 MCD and migrations kept in sync manually (mitigated by `check-schema-drift`) diff --git a/docs/adr/0006-test-against-real-postgres-testcontainers.md b/docs/adr/0006-test-against-real-postgres-testcontainers.md new file mode 100644 index 0000000..483fbf1 --- /dev/null +++ b/docs/adr/0006-test-against-real-postgres-testcontainers.md @@ -0,0 +1,75 @@ +# Test the persistence layer against a real PostgreSQL (Testcontainers), not H2 + +- Status: accepted +- Date: 2026-06-16 +- Deciders: Eric Bouchut + +## Context and Problem Statement + +Repository and integration tests need a database. The schema (hand-written +Liquibase migrations, see ADR-0005) relies on PostgreSQL-specific types and +functions: `UUID` with `gen_random_uuid()`, `INET`, `JSONB`, `TIMESTAMPTZ`, and +`BIGINT GENERATED ALWAYS AS IDENTITY`. Which database should the tests run +against? + +## Decision Drivers + +- Fidelity: tests must exercise the same schema, types, and migrations as production. +- The migrations are PostgreSQL formatted SQL and must apply unchanged. +- Isolation: tests must never touch the dev or production database. +- Test speed and CI simplicity. +- Avoid maintaining a second, test-only schema. + +## Considered Options + +- In-memory H2 (optionally in PostgreSQL-compatibility mode). +- Real PostgreSQL via Testcontainers (Docker), wired with `@ServiceConnection`. + +## Decision Outcome + +Chosen: **real PostgreSQL via Testcontainers**. A shared, static +`PostgreSQLContainer` (base class `AbstractPostgresIT`) is wired to Spring Boot +through `@ServiceConnection`; Liquibase applies the real migrations to it and +Hibernate `validate` checks the entity mappings. H2 cannot execute +`gen_random_uuid()`, `INET`, or `JSONB`, so it would require a divergent +test-only schema and give false confidence. + +### Test isolation + +- Each test run starts a **brand-new, ephemeral PostgreSQL container** (its own + database, on a random host port) that is destroyed when the JVM exits. It is a + **distinct database**, separate from the dev database (`learndev` on `:5433`) + and from production. +- `@ServiceConnection` **overrides `spring.datasource.*`** at test time, so tests + point at the container and never reach the dev/production database — even though + `application.yaml` names the dev DB. +- The schema is rebuilt **fresh by Liquibase** on each run, starting from a clean + state. +- `@DataJpaTest` wraps each test method in a transaction that is **rolled back**, + so tests do not leak state into one another. (`@SpringBootTest` flows do not + auto-roll back, so they use unique data.) + +### Consequences + +- Good: tests run against the real schema, types, and migrations — the migrations + are themselves exercised on every run; no schema divergence; high-fidelity. +- Good: complete isolation from real data (ephemeral, distinct database; datasource + overridden), so tests cannot corrupt dev or production. +- Trade-off: requires Docker in dev and CI; container startup adds a few seconds, + amortized via the shared static container and Spring's context cache. +- Note: `spring-boot-testcontainers`, `testcontainers:junit-jupiter` and + `testcontainers:postgresql` were added as test dependencies. + +## Pros and Cons of the Options + +### In-memory H2 + +- 👍 Fastest; no Docker required +- 👎 Cannot execute Postgres-specific types/functions (`gen_random_uuid()`, + `INET`, `JSONB`); needs a separate test schema → divergence and false confidence + +### Real PostgreSQL via Testcontainers (chosen) + +- 👍 Same engine, types, and migrations as production; exercises the migrations; + fully isolated ephemeral database; no divergence +- 👎 Requires Docker; slower startup (amortized across the run) diff --git a/docs/adr/0007-use-postgresql-over-mysql.md b/docs/adr/0007-use-postgresql-over-mysql.md new file mode 100644 index 0000000..eeae12c --- /dev/null +++ b/docs/adr/0007-use-postgresql-over-mysql.md @@ -0,0 +1,62 @@ +# Use PostgreSQL as the relational database, not MySQL + +- Status: accepted +- Date: 2026-06-16 +- Deciders: Eric Bouchut + +## Context and Problem Statement + +The platform needs a relational database for its core data (users, roles, +tokens, audit). MySQL was the familiar option from prior experience. The schema, +however, leans on several capabilities: `UUID` with `gen_random_uuid()`, `INET`, +`JSONB`, `TIMESTAMPTZ`, and safe (transactional) migrations. Which relational +engine should the project use? + +## Decision Drivers + +- Native data types the schema needs: `UUID`, `INET`, `JSONB`, `TIMESTAMPTZ`. +- Migration safety: a failed migration should not leave a half-applied schema. +- SQL standards compliance and advanced indexing (GIN for JSONB, partial and expression indexes). +- Strong data integrity and correctness. +- Good open-source tooling (Liquibase, tbls, Docker images). + +## Considered Options + +- PostgreSQL +- MySQL / MariaDB + +## Decision Outcome + +Chosen: **PostgreSQL 17**. It natively provides the types the schema relies on +(`UUID` / `gen_random_uuid()`, `INET`, `JSONB`, `TIMESTAMPTZ`), supports +**transactional DDL** (a failed migration rolls back atomically), and offers +strong standards compliance and advanced indexing. These directly serve +decisions already made: UUID keys (ADR-0003), hand-written migrations (ADR-0005), +and real-Postgres tests (ADR-0006). + +### Consequences + +- Good: the schema uses native, validated types instead of workarounds (for + example an IP stored as plain text, or JSON as an opaque string). +- Good: **transactional DDL** makes Liquibase migrations safer. A failure leaves + the schema unchanged rather than partially applied, whereas MySQL implicitly + commits DDL. +- Good: `JSONB` with GIN indexing fits the `audit_logs.metadata` use case. +- Trade-off: less prior familiarity than MySQL, and some PostgreSQL-specific SQL + reduces engine portability. This is acceptable because database portability is + not a goal. + +## Pros and Cons of the Options + +### PostgreSQL (chosen) + +- 👍 Rich native types (`UUID`, `INET`, `JSONB`, `TIMESTAMPTZ`, arrays); + transactional DDL; advanced indexing; standards-compliant; extensible +- 👎 Less prior familiarity; some Postgres-specific SQL + +### MySQL / MariaDB + +- 👍 Familiar; extremely widespread +- 👎 No first-class `INET`; weaker `JSON` and indexing story than `JSONB` plus GIN; + non-transactional DDL (a failed migration can leave a partial schema); + historically looser typing and standards compliance diff --git a/docs/adr/0008-share-singleton-testcontainers-postgres.md b/docs/adr/0008-share-singleton-testcontainers-postgres.md new file mode 100644 index 0000000..95eecde --- /dev/null +++ b/docs/adr/0008-share-singleton-testcontainers-postgres.md @@ -0,0 +1,62 @@ +# Share one Testcontainers PostgreSQL as a static singleton, not @Container + +- Status: accepted +- Date: 2026-06-23 +- Deciders: Eric Bouchut + +## Context and Problem Statement + +Several test classes (RoleRepositoryTest, UserRepositoryTest, +LearnDevApplicationTests) share a common base, AbstractPostgresIT, that provides +a PostgreSQL container via Testcontainers (see ADR-0006). The container field is +static so it can be shared across classes. With @Testcontainers and @Container +managing the lifecycle, the suite failed when run as a whole: every test passed +in isolation, but a class failed once another class had already run. How should +the shared container's lifecycle be managed across multiple test classes? + +## Decision Drivers + +- One container shared across all test classes (start once, for speed). +- Reliable in a full multi-class run, not just in isolation. +- Minimal boilerplate. + +## Considered Options + +- @Testcontainers and @Container on the static field (JUnit extension manages the lifecycle). +- A static-initializer singleton (the JVM manages the lifecycle), with @ServiceConnection. + +## Decision Outcome + +Chosen: a **static-initializer singleton**. AbstractPostgresIT starts the +container once in a static block and never stops it explicitly; @ServiceConnection +wires Spring Boot's datasource to it. @Testcontainers and @Container are removed. + +With @Container, the JUnit extension stops the container after each test class. +Because the container is shared by several classes, the first class stopped it and +the next class reused a dead container, failing with "connection refused" after a +30 second Hikari timeout. A static initializer ties the lifecycle to the JVM (the +whole test run), so the container stays up for every class. + +### Consequences + +- Good: the full suite is reliable; the container starts once and is reused, so + later test classes see it already up (faster). +- Good: no @DynamicPropertySource boilerplate; @ServiceConnection still works + because it only needs a started container. +- Trade-off: no explicit stop() in code; cleanup relies on JVM exit, and on Ryuk + in CI. This is acceptable. +- Refines ADR-0006 (test against a real PostgreSQL via Testcontainers). + +## Pros and Cons of the Options + +### @Testcontainers and @Container (extension-managed) + +- 👍 Declarative; automatic start and stop +- 👎 Lifecycle is per test class: it stops the shared container after the first + class, breaking later classes in the same run (connection refused, 30 second timeout) + +### Static-initializer singleton (chosen) + +- 👍 One container for the whole run, shared by all classes; reliable; faster; + less boilerplate +- 👎 No explicit stop in code; relies on JVM shutdown and on Ryuk (CI) for cleanup diff --git a/docs/adr/0009-run-tests-under-surefire-not-failsafe.md b/docs/adr/0009-run-tests-under-surefire-not-failsafe.md new file mode 100644 index 0000000..54a7f42 --- /dev/null +++ b/docs/adr/0009-run-tests-under-surefire-not-failsafe.md @@ -0,0 +1,65 @@ +# Run all tests under Surefire with the *Test suffix, not Failsafe/*IT + +- Status: accepted +- Date: 2026-06-30 +- Deciders: Eric Bouchut + +## Context and Problem Statement + +Some tests are fast unit tests (mocked, no I/O); others boot a Spring context +and talk to a real PostgreSQL via Testcontainers. Maven offers two conventions: +Surefire runs `*Test`/`*Tests` in the `test` phase, while Failsafe runs `*IT` +in the `verify` phase to separate slow integration tests from unit tests. + +An end-to-end test was first named `AuthFlowIT`. Because no Failsafe plugin is +configured, `mvn test` (and `make test`) silently skipped it: the suite reported +success while never exercising the flow. How should integration-style tests be +named and run so they are not skipped by accident? + +## Decision Drivers + +- Avoid silently skipped tests (a green build must mean every test ran). +- Keep one simple command (`make test`) that runs everything. +- Match the project's existing, de-facto convention. +- Low configuration and cognitive overhead for a solo capstone project. + +## Considered Options + +- Option A: Name every test `*Test`/`*Tests`; run all under Surefire in `mvn test`. +- Option B: Add the Failsafe plugin; name integration tests `*IT`; run them in `mvn verify`. + +## Decision Outcome + +Chosen: "Option A", because the container-backed tests already in the project +(`UserRepositoryTest`, `RoleRepositoryTest`, `LearnDevApplicationTests`) all run +under Surefire and use the `*Test`/`*Tests` suffix. Adding Failsafe would split +the suite across two phases and two commands for little benefit at this scale, +and the `*IT` suffix without Failsafe is the exact trap that caused a test to be +skipped. The `IT` suffix is reserved for non-test support classes such as +`AbstractPostgresIT` (a base class, never collected as a test). + +### Consequences + +- Good: `make test` runs the entire suite, including container-backed and + end-to-end tests; a green build genuinely covers everything. +- Good: no new build plugin or second command to remember. +- Trade-off: no phase-level separation of fast unit tests from slow integration + tests; the whole suite runs together. Acceptable while the suite is small. If + it grows enough that this hurts, revisit by introducing Failsafe (a new ADR + superseding this one). + +## Pros and Cons of the Options + +### Option A: All tests under Surefire (`*Test`) + +- 👍 Single command runs everything; nothing is skipped by accident. +- 👍 Consistent with the tests already in the repo. +- 👍 Zero extra build configuration. +- 👎 Slow integration tests are not separated from fast unit tests. + +### Option B: Failsafe plugin with `*IT` + +- 👍 Textbook separation of integration tests from unit tests by Maven phase. +- 👍 `mvn test` stays fast; `mvn verify` adds the heavier tests. +- 👎 Requires plugin configuration and a second command (`make verify`), plus CI wiring. +- 👎 An `*IT` test is silently skipped under `mvn test` (the failure mode that triggered this ADR). diff --git a/docs/adr/README.md b/docs/adr/README.md index 6bd213e..9346b53 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -34,4 +34,9 @@ NNNN-short-title-in-kebab-case.md | [0001](0001-use-server-side-sessions-over-jwt.md) | Use server-side sessions instead of JWT for user authentication | accepted | | [0002](0002-service-to-service-auth-via-service-token.md) | Authenticate service-to-service calls with a service token | proposed | | [0003](0003-uuid-pk-for-users-bigint-elsewhere.md) | Use a UUID primary key for users, BIGINT identity elsewhere | accepted | -| [0004](0004-use-mailpit-as-local-smtp-catcher.md) | Use Mailpit as the local fake SMTP catcher | accepted | \ No newline at end of file +| [0004](0004-use-mailpit-as-local-smtp-catcher.md) | Use Mailpit as the local fake SMTP catcher | accepted | +| [0005](0005-handwrite-liquibase-migrations-over-mcd-ddl.md) | Hand-write the schema as Liquibase migrations, not generated from the MCD | accepted | +| [0006](0006-test-against-real-postgres-testcontainers.md) | Test the persistence layer against a real PostgreSQL (Testcontainers), not H2 | accepted | +| [0007](0007-use-postgresql-over-mysql.md) | Use PostgreSQL as the relational database, not MySQL | accepted | +| [0008](0008-share-singleton-testcontainers-postgres.md) | Share one Testcontainers PostgreSQL as a static singleton, not @Container | accepted | +| [0009](0009-run-tests-under-surefire-not-failsafe.md) | Run all tests under Surefire with the *Test suffix, not Failsafe/*IT | accepted | \ No newline at end of file diff --git a/docs/plans/2026-06-16-jpa-entities-session-auth.md b/docs/plans/2026-06-16-jpa-entities-session-auth.md new file mode 100644 index 0000000..afddf82 --- /dev/null +++ b/docs/plans/2026-06-16-jpa-entities-session-auth.md @@ -0,0 +1,1091 @@ +# JPA Entities + Session Authentication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the JPA persistence layer for `users`/`roles` and a working Spring Security **session-based** login + registration on top of the existing Liquibase schema. + +**Architecture:** Feature-based packages under `com.ericbouchut.learndev` (`user`, `role`, `auth`, `common`). Entities map to the existing tables (Hibernate `ddl-auto: validate`, so mappings must match the DB exactly). Authentication is server-side session form login (ADR-0001), passwords hashed with BCrypt, default role `STUDENT` assigned at registration. Repository tests use Testcontainers PostgreSQL (real Postgres + Liquibase), so they match production types (UUID, timestamptz). + +**Tech Stack:** Java 21, Spring Boot 3.5, Spring Security, Spring Data JPA, Thymeleaf, Lombok, Liquibase, Testcontainers, PostgreSQL 17. + +**Scope:** `User` + `Role` entities, repositories, `UserDetailsService`, `SecurityConfig`, registration, login/logout, one protected page. **Out of scope** (deferred to their features): `email_tokens`/`reset_tokens` entities (password-reset epic #51–#56), `audit_logs` entity (audit feature), account-lockout counting logic, the `user_roles` extra columns `assigned_at`/`assigned_by` (a plain `@ManyToMany` is used; DB defaults populate `assigned_at`). + +--- + +## Version Control (GitButler — applies to every "Commit" step) + +This repository is on the `gitbutler/workspace` branch, so **use GitButler (`but`), not raw `git`** (per CLAUDE.md): + +- Each task's **Commit** step shows a `git commit -m ""` for readability. **Execute it as `but commit -m ""`** instead (GitButler auto-stages the worktree changes). Do **not** run `git add` / `git commit`. +- **NEVER push** — no `but push`, no `git push`. The user reviews in GitButler and pushes manually. +- All non-VCS verifications (`mvn …`, `docker …`) are unchanged. + +--- + +## File Structure + +``` +src/main/java/com/ericbouchut/learndev/ +├── role/ +│ ├── entity/Role.java # maps roles table +│ └── repository/RoleRepository.java +├── user/ +│ ├── entity/User.java # maps users table (+ @ManyToMany roles) +│ └── repository/UserRepository.java +├── auth/ +│ ├── CustomUserDetailsService.java # loads User for Spring Security +│ ├── RegistrationService.java # create account, hash pwd, default role +│ ├── AuthController.java # GET /register, POST /register, GET /login +│ ├── dto/RegisterForm.java # validated form-backing record +│ └── exception/ +│ ├── DuplicateUsernameException.java +│ └── DuplicateEmailException.java +└── common/config/SecurityConfig.java # filter chain, PasswordEncoder + +src/main/resources/ +├── templates/{home,login,register,dashboard}.html +└── db/changelog/changes/V20260616090000-seed-roles.sql + +src/test/java/com/ericbouchut/learndev/ +├── support/AbstractPostgresIT.java # Testcontainers base +├── role/repository/RoleRepositoryTest.java +├── user/repository/UserRepositoryTest.java +├── auth/RegistrationServiceTest.java +└── auth/AuthFlowIT.java # end-to-end MockMvc +``` + +--- + +## Task 1: Add dependencies (validation + Testcontainers) + +**Files:** +- Modify: `pom.xml` (inside ``) + +- [x] **Step 1: Add the dependencies** + +Add these inside `` in `pom.xml`: + +```xml + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + +``` + +- [x] **Step 2: Verify resolution** + +Run: `mvn -q dependency:resolve` +Expected: BUILD SUCCESS (versions come from the Spring Boot parent BOM; no explicit versions needed). + +- [x] **Step 3: Commit** + +```bash +git add pom.xml +git commit -m "chore(deps): add validation and Testcontainers for auth feature" +``` + +--- + +## Task 2: Testcontainers base class for repository tests + +**Files:** +- Create: `src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java` + +- [x] **Step 1: Write the base class** + +```java +package com.ericbouchut.learndev.support; + +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Base for tests that need a real PostgreSQL (UUID, timestamptz, Liquibase). + * The container is started once and shared (static); @ServiceConnection wires + * Spring Boot's datasource to it automatically. + */ +@Testcontainers +public abstract class AbstractPostgresIT { + + @Container + @ServiceConnection + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:17"); +} +``` + +- [x] **Step 2: Commit** + +```bash +git add src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java +git commit -m "test: add Testcontainers PostgreSQL base class" +``` + +--- + +## Task 3: Role entity + repository + +**Files:** +- Create: `src/main/java/com/ericbouchut/learndev/role/entity/Role.java` +- Create: `src/main/java/com/ericbouchut/learndev/role/repository/RoleRepository.java` +- Test: `src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java` + +- [x] **Step 1: Write the failing test** + +```java +package com.ericbouchut.learndev.role.repository; + +import com.ericbouchut.learndev.role.entity.Role; +import com.ericbouchut.learndev.support.AbstractPostgresIT; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class RoleRepositoryTest extends AbstractPostgresIT { + + @Autowired + RoleRepository roles; + + @Test + void seeded_STUDENT_role_is_found_by_name() { + Optional student = roles.findByRoleName("STUDENT"); + assertThat(student).isPresent(); + assertThat(student.get().getRoleId()).isNotNull(); + } +} +``` + +> Note: the seed rows come from the Liquibase migration created in Task 5; running +> this test before Task 5 fails on the assertion, which is the expected red state. + +- [x] **Step 2: Write the entity** + +```java +package com.ericbouchut.learndev.role.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "roles") +@Getter +@Setter +@NoArgsConstructor +public class Role { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "role_id") + private Long roleId; + + @Column(name = "role_name", nullable = false, unique = true) + private String roleName; + + @Column(name = "description") + private String description; + + @Column(name = "is_active", nullable = false) + private boolean active = true; +} +``` + +- [x] **Step 3: Write the repository** + +```java +package com.ericbouchut.learndev.role.repository; + +import com.ericbouchut.learndev.role.entity.Role; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RoleRepository extends JpaRepository { + Optional findByRoleName(String roleName); +} +``` + +- [x] **Step 4: Run the test (will pass after Task 5 seeds roles)** + +Run: `mvn -q -Dtest=RoleRepositoryTest test` +Expected after Task 5: PASS. (If run now: FAIL on `isPresent()` — proceed to Task 5.) + +- [x] **Step 5: Commit** + +```bash +git add src/main/java/com/ericbouchut/learndev/role +git add src/test/java/com/ericbouchut/learndev/role +git commit -m "feat(role): add Role entity and repository" +``` + +--- + +## Task 4: User entity + repository + +**Files:** +- Create: `src/main/java/com/ericbouchut/learndev/user/entity/User.java` +- Create: `src/main/java/com/ericbouchut/learndev/user/repository/UserRepository.java` +- Test: `src/test/java/com/ericbouchut/learndev/user/repository/UserRepositoryTest.java` + +- [x] **Step 1: Write the failing test** + +```java +package com.ericbouchut.learndev.user.repository; + +import com.ericbouchut.learndev.support.AbstractPostgresIT; +import com.ericbouchut.learndev.user.entity.User; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class UserRepositoryTest extends AbstractPostgresIT { + + @Autowired + UserRepository users; + + @Test + void saves_user_and_generates_uuid_and_finds_by_username() { + User u = new User(); + u.setUsername("alice"); + u.setEmail("alice@example.com"); + u.setPassword("hashed"); + users.saveAndFlush(u); + + assertThat(u.getUserId()).isNotNull(); // UUID generated + assertThat(users.findByUsername("alice")).isPresent(); + assertThat(users.existsByEmail("alice@example.com")).isTrue(); + assertThat(users.existsByUsername("bob")).isFalse(); + } +} +``` + +- [x] **Step 2: Run the test to verify it fails** + +Run: `mvn -q -Dtest=UserRepositoryTest test` +Expected: FAIL (compilation error: `User` / `UserRepository` do not exist). + +- [x] **Step 3: Write the entity** + +```java +package com.ericbouchut.learndev.user.entity; + +import com.ericbouchut.learndev.role.entity.Role; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.OffsetDateTime; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "user_id") + private UUID userId; + + @Column(name = "username", nullable = false, unique = true) + private String username; + + @Column(name = "email", nullable = false, unique = true) + private String email; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "first_name") + private String firstName; + + @Column(name = "last_name") + private String lastName; + + @Column(name = "is_active", nullable = false) + private boolean active = true; + + @Column(name = "is_verified", nullable = false) + private boolean verified = false; + + @Column(name = "is_locked", nullable = false) + private boolean locked = false; + + @Column(name = "failed_login_attempts", nullable = false) + private int failedLoginAttempts = 0; + + @Column(name = "last_login_at") + private OffsetDateTime lastLoginAt; + + @Column(name = "password_changed_at") + private OffsetDateTime passwordChangedAt; + + // Plain many-to-many: Hibernate inserts (user_id, role_id); the extra + // user_roles columns (assigned_at) are populated by their DB defaults. + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id")) + private Set roles = new HashSet<>(); +} +``` + +- [x] **Step 4: Write the repository** + +```java +package com.ericbouchut.learndev.user.repository; + +import com.ericbouchut.learndev.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + Optional findByEmail(String email); + boolean existsByUsername(String username); + boolean existsByEmail(String email); +} +``` + +- [x] **Step 5: Run the test to verify it passes** + +Run: `mvn -q -Dtest=UserRepositoryTest test` +Expected: PASS. + +- [x] **Step 6: Commit** + +```bash +git add src/main/java/com/ericbouchut/learndev/user +git add src/test/java/com/ericbouchut/learndev/user +git commit -m "feat(user): add User entity and repository" +``` + +--- + +## Task 5: Seed the roles (Liquibase migration) + +**Files:** +- Create: `src/main/resources/db/changelog/changes/V20260616090000-seed-roles.sql` + +- [x] **Step 1: Write the migration** + +```sql +--liquibase formatted sql + +-- Seed the fixed set of application roles. +--changeset ebouchut:V20260616090000 +INSERT INTO roles (role_name, description) VALUES + ('STUDENT', 'Learner who follows courses and does exercises'), + ('INSTRUCTOR', 'Author of courses, lessons and exercises'), + ('ADMIN', 'Platform administrator'); +--rollback DELETE FROM roles WHERE role_name IN ('STUDENT', 'INSTRUCTOR', 'ADMIN'); +``` + +- [x] **Step 2: Apply to the running dev DB and verify** + +Run: +```bash +docker compose up -d +mvn -q spring-boot:run & # starts app, Liquibase applies the seed; Ctrl-C after "Started LearnDevApplication" +``` +Then: +```bash +docker exec learn-dev-postgres-1 psql -U postgres -d learndev -At -c "SELECT role_name FROM roles ORDER BY role_name;" +``` +Expected: `ADMIN`, `INSTRUCTOR`, `STUDENT`. + +- [x] **Step 3: Run the Role test (now green)** + +Run: `mvn -q -Dtest=RoleRepositoryTest test` +Expected: PASS. + +- [x] **Step 4: Commit** + +```bash +git add src/main/resources/db/changelog/changes/V20260616090000-seed-roles.sql +git commit -m "feat(role): seed STUDENT, INSTRUCTOR, ADMIN roles" +``` + +--- + +## Task 6: Security configuration + password encoder + +**Files:** +- Create: `src/main/java/com/ericbouchut/learndev/common/config/SecurityConfig.java` + +- [x] **Step 1: Write the config** + +```java +package com.ericbouchut.learndev.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/register", "/login", "/css/**", "/js/**").permitAll() + .anyRequest().authenticated()) + .formLogin(form -> form + .loginPage("/login") + .defaultSuccessUrl("/dashboard", true) + .permitAll()) + .logout(logout -> logout + .logoutSuccessUrl("/login?logout") + .permitAll()); + // CSRF protection is ON by default; Thymeleaf adds the token to
automatically. + return http.build(); + } +} +``` + +- [x] **Step 2: Build to verify it compiles** + +Run: `mvn -q -DskipTests compile` +Expected: BUILD SUCCESS. + +- [x] **Step 3: Commit** + +```bash +git add src/main/java/com/ericbouchut/learndev/common/config/SecurityConfig.java +git commit -m "feat(security): session form-login filter chain and BCrypt encoder" +``` + +--- + +## Task 7: CustomUserDetailsService + +**Files:** +- Create: `src/main/java/com/ericbouchut/learndev/auth/CustomUserDetailsService.java` +- Test: `src/test/java/com/ericbouchut/learndev/auth/CustomUserDetailsServiceTest.java` + +- [x] **Step 1: Write the failing test** + +```java +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.role.entity.Role; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CustomUserDetailsServiceTest { + + private final UserRepository users = mock(UserRepository.class); + private final CustomUserDetailsService service = new CustomUserDetailsService(users); + + @Test + void maps_roles_to_ROLE_authorities() { + Role student = new Role(); + student.setRoleName("STUDENT"); + User u = new User(); + u.setUsername("alice"); + u.setPassword("hash"); + u.setRoles(Set.of(student)); + when(users.findByUsername("alice")).thenReturn(Optional.of(u)); + + UserDetails details = service.loadUserByUsername("alice"); + + assertThat(details.getPassword()).isEqualTo("hash"); + assertThat(details.getAuthorities()) + .extracting(Object::toString) + .containsExactly("ROLE_STUDENT"); + } + + @Test + void throws_when_user_missing() { + when(users.findByUsername("ghost")).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.loadUserByUsername("ghost")) + .isInstanceOf(UsernameNotFoundException.class); + } +} +``` + +- [x] **Step 2: Run to verify it fails** + +Run: `mvn -q -Dtest=CustomUserDetailsServiceTest test` +Expected: FAIL (compilation: `CustomUserDetailsService` does not exist). + +- [x] **Step 3: Write the service** + +```java +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository users; + + public CustomUserDetailsService(UserRepository users) { + this.users = users; + } + + @Override + public UserDetails loadUserByUsername(String username) { + var user = users.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("Unknown user: " + username)); + + List authorities = user.getRoles().stream() + .map(r -> new SimpleGrantedAuthority("ROLE_" + r.getRoleName())) + .toList(); + + return org.springframework.security.core.userdetails.User.builder() + .username(user.getUsername()) + .password(user.getPassword()) + .authorities(authorities) + .accountLocked(user.isLocked()) + .disabled(!user.isActive()) + .build(); + } +} +``` + +- [x] **Step 4: Run to verify it passes** + +Run: `mvn -q -Dtest=CustomUserDetailsServiceTest test` +Expected: PASS. + +- [x] **Step 5: Commit** + +```bash +git add src/main/java/com/ericbouchut/learndev/auth/CustomUserDetailsService.java +git add src/test/java/com/ericbouchut/learndev/auth/CustomUserDetailsServiceTest.java +git commit -m "feat(auth): load users into Spring Security via CustomUserDetailsService" +``` + +--- + +## Task 8: Registration service + form DTO + exceptions + +**Files:** +- Create: `src/main/java/com/ericbouchut/learndev/auth/dto/RegisterForm.java` +- Create: `src/main/java/com/ericbouchut/learndev/auth/exception/DuplicateUsernameException.java` +- Create: `src/main/java/com/ericbouchut/learndev/auth/exception/DuplicateEmailException.java` +- Create: `src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java` +- Test: `src/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java` + +- [x] **Step 1: Write the failing test** + +```java +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.auth.dto.RegisterForm; +import com.ericbouchut.learndev.auth.exception.DuplicateEmailException; +import com.ericbouchut.learndev.role.entity.Role; +import com.ericbouchut.learndev.role.repository.RoleRepository; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +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.*; + +class RegistrationServiceTest { + + private final UserRepository users = mock(UserRepository.class); + private final RoleRepository roles = mock(RoleRepository.class); + private final PasswordEncoder encoder = mock(PasswordEncoder.class); + private final RegistrationService service = new RegistrationService(users, roles, encoder); + + @Test + void hashes_password_and_assigns_STUDENT_role() { + Role student = new Role(); + student.setRoleName("STUDENT"); + when(users.existsByUsername("alice")).thenReturn(false); + when(users.existsByEmail("alice@example.com")).thenReturn(false); + when(roles.findByRoleName("STUDENT")).thenReturn(Optional.of(student)); + when(encoder.encode("secret12")).thenReturn("HASHED"); + when(users.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + var form = new RegisterForm("alice", "alice@example.com", "secret12"); + User created = service.register(form); + + assertThat(created.getPassword()).isEqualTo("HASHED"); + assertThat(created.getRoles()).extracting(Role::getRoleName).containsExactly("STUDENT"); + verify(users).save(any(User.class)); + } + + @Test + void rejects_duplicate_email() { + when(users.existsByUsername("alice")).thenReturn(false); + when(users.existsByEmail("alice@example.com")).thenReturn(true); + + var form = new RegisterForm("alice", "alice@example.com", "secret12"); + assertThatThrownBy(() -> service.register(form)) + .isInstanceOf(DuplicateEmailException.class); + verify(users, never()).save(any()); + } +} +``` + +- [x] **Step 2: Run to verify it fails** + +Run: `mvn -q -Dtest=RegistrationServiceTest test` +Expected: FAIL (compilation: types do not exist). + +- [x] **Step 3: Write the DTO** + +```java +package com.ericbouchut.learndev.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RegisterForm( + @NotBlank @Size(min = 3, max = 50) String username, + @NotBlank @Email @Size(max = 255) String email, + @NotBlank @Size(min = 8, max = 100) String password) { +} +``` + +- [x] **Step 4: Write the exceptions** + +```java +package com.ericbouchut.learndev.auth.exception; + +public class DuplicateUsernameException extends RuntimeException { + public DuplicateUsernameException(String username) { + super("Username already taken: " + username); + } +} +``` + +```java +package com.ericbouchut.learndev.auth.exception; + +public class DuplicateEmailException extends RuntimeException { + public DuplicateEmailException(String email) { + super("Email already registered: " + email); + } +} +``` + +- [x] **Step 5: Write the service** + +```java +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.auth.dto.RegisterForm; +import com.ericbouchut.learndev.auth.exception.DuplicateEmailException; +import com.ericbouchut.learndev.auth.exception.DuplicateUsernameException; +import com.ericbouchut.learndev.role.entity.Role; +import com.ericbouchut.learndev.role.repository.RoleRepository; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class RegistrationService { + + private final UserRepository users; + private final RoleRepository roles; + private final PasswordEncoder encoder; + + public RegistrationService(UserRepository users, RoleRepository roles, PasswordEncoder encoder) { + this.users = users; + this.roles = roles; + this.encoder = encoder; + } + + @Transactional + public User register(RegisterForm form) { + if (users.existsByUsername(form.username())) { + throw new DuplicateUsernameException(form.username()); + } + if (users.existsByEmail(form.email())) { + throw new DuplicateEmailException(form.email()); + } + Role student = roles.findByRoleName("STUDENT") + .orElseThrow(() -> new IllegalStateException("STUDENT role not seeded")); + + User user = new User(); + user.setUsername(form.username()); + user.setEmail(form.email()); + user.setPassword(encoder.encode(form.password())); + user.getRoles().add(student); + return users.save(user); + } +} +``` + +- [x] **Step 6: Run to verify it passes** + +Run: `mvn -q -Dtest=RegistrationServiceTest test` +Expected: PASS. + +- [x] **Step 7: Commit** + +```bash +git add src/main/java/com/ericbouchut/learndev/auth +git add src/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java +git commit -m "feat(auth): registration service with hashing, default role, duplicate checks" +``` + +--- + +## Task 9: Auth controller + Thymeleaf templates + +**Files:** +- Create: `src/main/java/com/ericbouchut/learndev/auth/AuthController.java` +- Create: `src/main/resources/templates/home.html` +- Create: `src/main/resources/templates/login.html` +- Create: `src/main/resources/templates/register.html` +- Create: `src/main/resources/templates/dashboard.html` + +- [x] **Step 1: Write the controller** + +```java +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.auth.dto.RegisterForm; +import com.ericbouchut.learndev.auth.exception.DuplicateEmailException; +import com.ericbouchut.learndev.auth.exception.DuplicateUsernameException; +import jakarta.validation.Valid; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; + +@Controller +public class AuthController { + + private final RegistrationService registration; + + public AuthController(RegistrationService registration) { + this.registration = registration; + } + + @GetMapping("/") + public String home() { + return "home"; + } + + @GetMapping("/login") + public String login() { + return "login"; + } + + @GetMapping("/dashboard") + public String dashboard() { + return "dashboard"; + } + + @GetMapping("/register") + public String registerForm(Model model) { + model.addAttribute("form", new RegisterForm("", "", "")); + return "register"; + } + + @PostMapping("/register") + public String register(@Valid @ModelAttribute("form") RegisterForm form, + BindingResult binding) { + if (binding.hasErrors()) { + return "register"; + } + try { + registration.register(form); + } catch (DuplicateUsernameException e) { + binding.rejectValue("username", "duplicate", "Username already taken"); + return "register"; + } catch (DuplicateEmailException e) { + binding.rejectValue("email", "duplicate", "Email already registered"); + return "register"; + } + return "redirect:/login?registered"; + } +} +``` + +- [x] **Step 2: Write `home.html`** + +```html + + +learn-dev + +

learn-dev

+

Login · Register

+ + +``` + +- [x] **Step 3: Write `login.html`** + +```html + + +Login + +

Login

+

Account created — please log in.

+

You have been logged out.

+

Invalid username or password.

+ +
+
+ + +

Create an account

+ + +``` + +- [x] **Step 4: Write `register.html`** + +```html + + +Register + +

Create your account

+
+ +
+ +
+ +
+ +
+

Already have an account? Log in

+ + +``` + +- [x] **Step 5: Write `dashboard.html`** + +```html + + +Dashboard + +

Dashboard

+

Signed in as user.

+
+ +
+ + +``` + +- [x] **Step 6: Compile** + +Run: `mvn -q -DskipTests compile` +Expected: BUILD SUCCESS. + +- [x] **Step 7: Commit** + +```bash +git add src/main/java/com/ericbouchut/learndev/auth/AuthController.java +git add src/main/resources/templates +git commit -m "feat(auth): registration/login/dashboard pages and controller" +``` + +--- + +## Task 10: End-to-end auth flow integration test + +**Files:** +- Create: `src/test/java/com/ericbouchut/learndev/auth/AuthFlowIT.java` + +- [x] **Step 1: Write the integration test** + +```java +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.support.AbstractPostgresIT; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(properties = { + // This feature does not use MongoDB; keep the test context Postgres-only. + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration" +}) +@org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +class AuthFlowIT extends AbstractPostgresIT { + + @Autowired + MockMvc mvc; + + @Test + void register_then_login_then_reach_dashboard() throws Exception { + // protected page redirects to login when anonymous + mvc.perform(get("/dashboard")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrlPattern("**/login")); + + // register + mvc.perform(post("/register").with(csrf()) + .param("username", "carol") + .param("email", "carol@example.com") + .param("password", "secret12")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?registered")); + + // wrong password is rejected + mvc.perform(formLogin("/login").user("carol").password("wrong")) + .andExpect(unauthenticated()); + + // correct login succeeds + mvc.perform(formLogin("/login").user("carol").password("secret12")) + .andExpect(authenticated().withUsername("carol")) + .andExpect(redirectedUrl("/dashboard")); + } +} +``` + +- [x] **Step 2: Run the test** + +Run: `mvn -q -Dtest=AuthFlowIT test` +Expected: PASS. + +- [x] **Step 3: Commit** + +```bash +git add src/test/java/com/ericbouchut/learndev/auth/AuthFlowIT.java +git commit -m "test(auth): end-to-end register, login, protected-page flow" +``` + +--- + +## Task 11: Harden the session cookie + full verification + +**Files:** +- Modify: `src/main/resources/application.yaml` (add under `server:` at the root level) + +- [x] **Step 1: Add session-cookie hardening** + +Append to `application.yaml` (top-level key, sibling of `spring:`): + +```yaml +server: + servlet: + session: + cookie: + http-only: true + same-site: lax + # secure: true # enable once served over HTTPS +``` + +- [x] **Step 2: Run the whole suite** + +Run: `mvn -q test` +Expected: BUILD SUCCESS, all tests green. + +- [ ] **Step 3: Manual smoke test** _(not yet performed — run `make run` and click through in a browser)_ + +Run: +```bash +docker compose up -d +mvn spring-boot:run +``` +Then in a browser: visit `http://localhost:8080/` → Register → log in → land on `/dashboard` → Log out. Confirm `/dashboard` redirects to `/login` when logged out. + +- [x] **Step 4: Commit** + +```bash +git add src/main/resources/application.yaml +git commit -m "feat(security): harden session cookie (HttpOnly, SameSite=Lax)" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** EF-1 (registration) → Tasks 8–9; EF-3 (session login/logout, HttpOnly/SameSite cookie) → Tasks 6, 11; RBAC foundation (roles → authorities) → Tasks 3, 5, 7; persistence layer → Tasks 3–4. ADR-0001 (sessions, not JWT) honored throughout. ADR-0003 (UUID `user_id`) reflected in the `User` mapping. +- **Deferred (not gaps):** `email_tokens`/`reset_tokens` entities (#51–#56), `audit_logs` entity, failed-login lockout counting, `assigned_by` tracking on `user_roles`. +- **Type consistency:** `RegisterForm(username,email,password)` used identically in service, controller, and tests; `findByRoleName`, `findByUsername`, `existsByEmail` names match across repository, service, and tests. diff --git a/docs/tech-stacks.md b/docs/tech-stacks.md new file mode 100644 index 0000000..207a993 --- /dev/null +++ b/docs/tech-stacks.md @@ -0,0 +1,101 @@ +# Tech Stack + +A catalogue of the tools, languages, frameworks, and libraries used in learn-dev, +with versions and a one-line reason for each. This file answers **"what do we +use?"** For **"how do the pieces fit together?"** see [ARCHITECTURE.md](../ARCHITECTURE.md); +for **"why this over the alternative?"** see the [ADRs](adr/README.md); for term +definitions see [GLOSSARY.md](../GLOSSARY.md). + +> Versions come from `pom.xml`, `.sdkmanrc`, and `docker-compose.yaml`. Update this +> file when those change. + +## Language and runtime + +| Technology | Version | Why here | +|------------|---------|----------| +| Java | 21 (`21.0.8-tem`) | LTS runtime; project language. Pinned via `.sdkmanrc`. | + +## Application framework + +| Technology | Version | Why here | +|------------|---------|----------| +| Spring Boot | 3.5.14 | Application framework and dependency management (parent POM). | +| Spring Web (MVC) | via Boot | Server-side MVC controllers and view rendering. | +| Spring Security | via Boot | Authentication and authorization (session form login). | +| Spring Data JPA | via Boot | Repository abstraction over the relational store. | +| Spring Boot Actuator | via Boot | Operational endpoints (health, info). | +| Bean Validation (Hibernate Validator) | via Boot | Declarative form/DTO constraints enforced with `@Valid`. | + +## View layer + +| Technology | Version | Why here | +|------------|---------|----------| +| Thymeleaf | via Boot | Server-side HTML template engine. | +| thymeleaf-extras-springsecurity6 | via Boot | `sec:` dialect to read the authenticated user in templates. | +| HTML / CSS / JavaScript | — | Front-end markup, styling, and behaviour. | + +## Persistence and data + +| Technology | Version | Why here | +|------------|---------|----------| +| Hibernate ORM | via Boot (JPA) | Maps Java entities to relational tables. | +| PostgreSQL | 17 | Relational core (users, roles, courses). See [ADR-0007](adr/0007-use-postgresql-over-mysql.md). | +| PostgreSQL JDBC driver | via Boot | Database connectivity. | +| Liquibase | via Boot | Schema migrations, applied at startup. See [ADR-0005](adr/0005-handwrite-liquibase-migrations-over-mcd-ddl.md). | +| MongoDB | 8 | Provisioned (Docker) and configured (URI) for future content storage; not yet wired to a feature (Mongo auto-config is excluded in tests). | + +## Configuration and secrets + +| Technology | Version | Why here | +|------------|---------|----------| +| spring-dotenv (`springboot3-dotenv`) | BOM-managed | Loads `./.env` at startup from the working directory. | +| 1Password Environments | — | Provisions `.env` (a FIFO) and the `gh` token; never edited by hand. | + +## Build and dependency management + +| Technology | Version | Why here | +|------------|---------|----------| +| Maven | 3.9.16 | Build and dependency management. Pinned via `.sdkmanrc`. | +| Maven Wrapper (`./mvnw`) | — | Reproducible Maven invocation without a global install. | +| spring-boot-maven-plugin | via Boot | Runs the app (`spring-boot:run`) and builds the executable jar. | +| Lombok | via Boot | Compile-time boilerplate generation (getters, constructors). | +| SDKMAN | — | Activates the pinned Java and Maven versions (`sdk env`). | + +## Testing + +| Technology | Version | Why here | +|------------|---------|----------| +| JUnit 5 (Jupiter) | via Boot | Test framework. | +| Mockito | via Boot | Mocking for unit tests. | +| AssertJ | via Boot | Fluent assertions. | +| Spring Boot Test | via Boot | Context-loading and slice-test support. | +| Spring Security Test | via Boot | `formLogin()`, `csrf()`, and auth assertions in MockMvc. | +| MockMvc | via Boot | Drives HTTP requests without a live server. | +| Testcontainers (`junit-jupiter`, `postgresql`) | via Boot | Real PostgreSQL for tests. See [ADR-0006](adr/0006-test-against-real-postgres-testcontainers.md), [ADR-0008](adr/0008-share-singleton-testcontainers-postgres.md). | + +All tests run under the Maven Surefire plugin (no Failsafe); see [ADR-0009](adr/0009-run-tests-under-surefire-not-failsafe.md). + +## Containers and local infrastructure + +| Technology | Version | Why here | +|------------|---------|----------| +| Podman | — | Daemonless container engine; the `docker` drop-in on the dev machine. | +| Docker Compose | — | Runs Postgres and Mongo locally (`docker compose up -d`). | +| Mailpit | — | Planned local fake SMTP catcher for the email flow. See [ADR-0004](adr/0004-use-mailpit-as-local-smtp-catcher.md). | + +## Documentation and modelling tooling + +| Technology | Version | Why here | +|------------|---------|----------| +| Mocodo | 4.3.3+ | Generates Merise MCD and MLD diagrams from a single `.mcd` source. | +| tbls | — | Generates the MPD from the live PostgreSQL schema. | +| Mermaid | — | Renders the ERD in Markdown. | +| MADR | — | ADR template format in `docs/adr/`. | + +## Quality, security, and version control + +| Technology | Version | Why here | +|------------|---------|----------| +| Semgrep | 1.16x | Static analysis run as a tooling hook. | +| Git / GitButler | — | Version control; `but` CLI when on `gitbutler/workspace`. | +| GitHub Actions | — | CI (schema-drift check; more planned). | diff --git a/pom.xml b/pom.xml index 2e9adcd..74210db 100644 --- a/pom.xml +++ b/pom.xml @@ -111,6 +111,29 @@ me.paulschwarz springboot3-dotenv + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test +
diff --git a/src/main/java/com/ericbouchut/learndev/auth/AuthController.java b/src/main/java/com/ericbouchut/learndev/auth/AuthController.java new file mode 100644 index 0000000..b134e44 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/AuthController.java @@ -0,0 +1,103 @@ +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.auth.dto.RegisterForm; +import com.ericbouchut.learndev.auth.exception.DuplicateEmailException; +import com.ericbouchut.learndev.auth.exception.DuplicateUsernameException; +import jakarta.validation.Valid; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; + +/** + * Web endpoints for authentication pages: + * home, login and dashboard views, and the registration form + * (display and submission). + * Spring Security handles the login POST and logout itself. + * This controller renders the pages around them. + */ +@Controller +public class AuthController { + + private final RegistrationService registration; + + public AuthController(RegistrationService registration) { + this.registration = registration; + } + + /** + * GET / + * Display the home page. + * @return the name of the Thymeleaf template (aka. View name) for the home page + * The View will read and render this template. + */ + @GetMapping("/") + public String home() { + return "home"; + } + + /** + * Display the login form. + * @return the name of the login template + */ + @GetMapping("/auth/login") + public String login() { + return "login"; + } + + /** + * Display the dashboard page. + * @return the name of the dashboard template + */ + @GetMapping("/dashboard") + public String dashboard() { + return "dashboard"; + } + + /** + * Display the User registration form. + * @param model + * @return the key of the registration Thymeleaf template + */ + @GetMapping("/auth/register") + public String registerForm(Model model) { + model.addAttribute("form", new RegisterForm("", "", "")); + return "register"; + } + + /** + * Processes a submitted registration form. + * Bean-validation failures and duplicate username/email + * are turned into field errors so the form is re-rendered with the user's input. + * A successful registration redirects to the login page + * with an {@code registered} URL query parameter. + * + * @param form the submitted form, validated by {@code @Valid} + * @param binding collects validation and duplicate-field errors + * @return the view name to render, or a redirect on success + */ + @PostMapping("/auth/register") + public String register( + @Valid + @ModelAttribute("form") + RegisterForm form, + + BindingResult binding + ) { + if (binding.hasErrors()) { + return "register"; + } + try { + registration.register(form); + } catch (DuplicateUsernameException e) { + binding.rejectValue("username", "duplicate", "Username already taken"); + return "register"; + } catch (DuplicateEmailException e) { + binding.rejectValue("email", "duplicate", "Email already registered"); + return "register"; + } + return "redirect:/auth/login?registered"; + } +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/CustomUserDetailsService.java b/src/main/java/com/ericbouchut/learndev/auth/CustomUserDetailsService.java new file mode 100644 index 0000000..86cdb72 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/CustomUserDetailsService.java @@ -0,0 +1,38 @@ +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository users; + + public CustomUserDetailsService(UserRepository users) { + this.users = users; + } + + @Override + public UserDetails loadUserByUsername(String username) { + var user = users.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("Unknown user: " + username)); + + List authorities = user.getRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getRoleName())) + .toList(); + + return org.springframework.security.core.userdetails.User.builder() + .username(user.getUsername()) + .password(user.getPassword()) + .authorities(authorities) + .accountLocked(user.isLocked()) + .disabled(!user.isActive()) + .build(); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java b/src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java new file mode 100644 index 0000000..07d6801 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java @@ -0,0 +1,93 @@ +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.auth.dto.RegisterForm; +import com.ericbouchut.learndev.auth.exception.DuplicateEmailException; +import com.ericbouchut.learndev.auth.exception.DuplicateUsernameException; +import com.ericbouchut.learndev.role.entity.Role; +import com.ericbouchut.learndev.role.repository.RoleRepository; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Creates new user accounts: enforces unique username and email, hashes the + * password, and assigns the default {@code STUDENT} role. + */ +@Service +public class RegistrationService { + + private final UserRepository users; + private final RoleRepository roles; + private final PasswordEncoder encoder; + + public RegistrationService(UserRepository users, RoleRepository roles, PasswordEncoder encoder) { + this.users = users; + this.roles = roles; + this.encoder = encoder; + } + + /** + * Registers a new account with the default {@code STUDENT} role. The password + * is hashed before being stored; the raw password is never persisted. + * + * @param form the validated registration form + * @return the saved user, including its generated id + * @throws DuplicateUsernameException if the username is already taken + * @throws DuplicateEmailException if the email is already registered + */ + @Transactional + public User register(RegisterForm form) { + if (users.existsByUsername(form.username())) { + throw new DuplicateUsernameException(form.username()); + } + if (users.existsByEmail(form.email())) { + throw new DuplicateEmailException(form.email()); + } + Role student = roles.findByRoleName("STUDENT") + .orElseThrow(() -> new IllegalStateException("STUDENT role not seeded")); + + User user = new User(); + user.setUsername(form.username()); + user.setEmail(form.email()); + user.setPassword(encoder.encode(form.password())); + user.getRoles().add(student); + + // The existsBy* pre-checks above race under concurrency: two requests can + // both pass them, and the loser hits the users_username_key/users_email_key + // UNIQUE constraint. Flush inside this method (saveAndFlush, not save) so + // the violation is catchable here, and map it back to the domain exception + // by constraint name: after a failed statement PostgreSQL aborts the + // transaction, so re-querying existsBy* in the catch would also fail. + try { + return users.saveAndFlush(user); + } catch (DataIntegrityViolationException e) { + String constraint = constraintName(e); + if ("users_username_key".equalsIgnoreCase(constraint)) { + throw new DuplicateUsernameException(form.username()); + } + if ("users_email_key".equalsIgnoreCase(constraint)) { + throw new DuplicateEmailException(form.email()); + } + throw e; + } + } + + /** + * Extracts the database constraint name from a data-integrity failure, or + * {@code null} when the cause chain has no {@link ConstraintViolationException}. + * @param e a data integrity violation exception + * @return the name of violated constraint name if any or null otherwise. + */ + private static String constraintName(DataIntegrityViolationException e) { + for (Throwable cause = e.getCause(); cause != null; cause = cause.getCause()) { + if (cause instanceof ConstraintViolationException violation) { + return violation.getConstraintName(); + } + } + return null; + } +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/dto/RegisterForm.java b/src/main/java/com/ericbouchut/learndev/auth/dto/RegisterForm.java new file mode 100644 index 0000000..0cc42f7 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/dto/RegisterForm.java @@ -0,0 +1,31 @@ +package com.ericbouchut.learndev.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * {@code RegisterForm} holds the data submitted by a browser HTML form for user registration. + * It is validated with Bean Validation. + * The constraints are enforced when a controller binds it with {@code @Valid}, + * so invalid input is rejected before it reaches the service. + * It is an inbound Data Transfer Object (DTO): Web Browser => Controller. + * + * @param username chosen username (3 to 50 characters) + * @param email email address (valid format, up to 255 characters) + * @param password raw password (8 to 100 characters) (MUST be hashed before storage) + */ +public record RegisterForm( + @NotBlank + @Size(min = 3, max = 50) + String username, + + @NotBlank + @Email @Size(max = 255) + String email, + + @NotBlank + @Size(min = 8, max = 100) + String password +) { +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/exception/DuplicateEmailException.java b/src/main/java/com/ericbouchut/learndev/auth/exception/DuplicateEmailException.java new file mode 100644 index 0000000..fd7cb92 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/exception/DuplicateEmailException.java @@ -0,0 +1,10 @@ +package com.ericbouchut.learndev.auth.exception; + +/** + * Thrown when registration is attempted with an email that is already registered. + */ +public class DuplicateEmailException extends RuntimeException { + public DuplicateEmailException(String email) { + super("Email already registered: " + email); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/exception/DuplicateUsernameException.java b/src/main/java/com/ericbouchut/learndev/auth/exception/DuplicateUsernameException.java new file mode 100644 index 0000000..a50ae9b --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/exception/DuplicateUsernameException.java @@ -0,0 +1,10 @@ +package com.ericbouchut.learndev.auth.exception; + +/** + * Thrown when registration is attempted with a username that already exists. + */ +public class DuplicateUsernameException extends RuntimeException { + public DuplicateUsernameException(String username) { + super("Username already taken: " + username); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/common/config/SecurityConfig.java b/src/main/java/com/ericbouchut/learndev/common/config/SecurityConfig.java new file mode 100644 index 0000000..b3e0a48 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/common/config/SecurityConfig.java @@ -0,0 +1,38 @@ +package com.ericbouchut.learndev.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/auth/**", "/css/**", "/js/**").permitAll() + .anyRequest().authenticated()) + .formLogin(form -> form + .loginPage("/auth/login") // GET: show the login form + .loginProcessingUrl("/auth/login") // POST: Spring Security processes the login + .defaultSuccessUrl("/dashboard", true) + .permitAll()) + .logout(logout -> logout + .logoutUrl("/auth/logout") + .logoutSuccessUrl("/auth/login?logout") + .permitAll()); + // CSRF protection is ON by default; Thymeleaf adds the token to forms automatically. + return http.build(); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/role/entity/Role.java b/src/main/java/com/ericbouchut/learndev/role/entity/Role.java new file mode 100644 index 0000000..54a46b9 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/role/entity/Role.java @@ -0,0 +1,28 @@ +package com.ericbouchut.learndev.role.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "roles") +@Getter +@Setter +@NoArgsConstructor +public class Role { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "role_id") + private Long roleId; + + @Column(name = "role_name", nullable = false, unique = true) + private String roleName; + + @Column(name = "description") + private String description; + + @Column(name = "is_active", nullable = false) + private boolean active = true; +} diff --git a/src/main/java/com/ericbouchut/learndev/role/repository/RoleRepository.java b/src/main/java/com/ericbouchut/learndev/role/repository/RoleRepository.java new file mode 100644 index 0000000..948fb52 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/role/repository/RoleRepository.java @@ -0,0 +1,10 @@ +package com.ericbouchut.learndev.role.repository; + +import com.ericbouchut.learndev.role.entity.Role; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RoleRepository extends JpaRepository { + Optional findByRoleName(String roleName); +} diff --git a/src/main/java/com/ericbouchut/learndev/user/entity/User.java b/src/main/java/com/ericbouchut/learndev/user/entity/User.java new file mode 100644 index 0000000..1f3b3c9 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/user/entity/User.java @@ -0,0 +1,79 @@ +package com.ericbouchut.learndev.user.entity; + +import com.ericbouchut.learndev.role.entity.Role; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.OffsetDateTime; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +public class User { + + /** + * The user's primary key. + * The user id is exposed outside the database, + * as the subject in the server session today + * and in the JWT in the next major release. + *

Using UUID instead of an BIGINT because this mitigates IDOR + * (Insecure Direct Object Reference) where the app trusts + * a client-supplied user id reference without checking the user + * is authorized to access it. + */ + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "user_id") + private UUID userId; + + @Column(name = "username", nullable = false, unique = true) + private String username; + + @Column(name = "email", nullable = false, unique = true) + private String email; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "first_name") + private String firstName; + + @Column(name = "last_name") + private String lastName; + + @Column(name = "is_active", nullable = false) + private boolean active = true; + + @Column(name = "is_verified", nullable = false) + private boolean verified = false; + + @Column(name = "is_locked", nullable = false) + private boolean locked = false; + + @Column(name = "failed_login_attempts", nullable = false) + private int failedLoginAttempts = 0; + + @Column(name = "last_login_at") + private OffsetDateTime lastLoginAt; + + @Column(name = "password_changed_at") + private OffsetDateTime passwordChangedAt; + + // Plain many-to-many: Hibernate inserts (user_id, role_id); the extra + // user_roles columns (assigned_at) are populated by their DB defaults. + // LAZY (the @ManyToMany default): callers that need the roles fetch them + // per query, e.g. the @EntityGraph on UserRepository.findByUsername. + @ManyToMany + @JoinTable( + name = "user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id")) + private Set roles = new HashSet<>(); +} diff --git a/src/main/java/com/ericbouchut/learndev/user/repository/UserRepository.java b/src/main/java/com/ericbouchut/learndev/user/repository/UserRepository.java new file mode 100644 index 0000000..d5a2e40 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/user/repository/UserRepository.java @@ -0,0 +1,23 @@ +package com.ericbouchut.learndev.user.repository; + +import com.ericbouchut.learndev.user.entity.User; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository extends JpaRepository { + + /** + * Loads a user with their roles fetched in the same query. Roles are LAZY on + * the entity; authentication is the one path that always needs them, so this + * finder opts in via an entity graph (a single join, no lazy-init risk). + */ + @EntityGraph(attributePaths = "roles") + Optional findByUsername(String username); + + Optional findByEmail(String email); + boolean existsByUsername(String username); + boolean existsByEmail(String email); +} diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index f8ee083..6293cdf 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -26,11 +26,18 @@ spring: # create-source: metadata # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - # Set the execution context for Liquibase changeset + # Liquibase Database Migrations # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ liquibase: + # Set the execution context for Liquibase changeset contexts: dev + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Thymeleaf: Server-Side Rendering Engine + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + thymeleaf: + # Disable caching during development for hot reload + cache: false # ~~~~~~~~~~~~~~~~~~ # Logging Level diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 083cf1f..0f8e4d4 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -35,6 +35,18 @@ spring: drop-first: false +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# HTTP session cookie hardening +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +server: + servlet: + session: + cookie: + http-only: true # JS cannot read the cookie (mitigates XSS session theft) + same-site: lax # cookie withheld on cross-site requests (CSRF defense in depth) + # secure: true # enable once served over HTTPS (cookie sent only over TLS) + + # ~~~~~~~~~~~~~~~~~~ # Logging Level # ~~~~~~~~~~~~~~~~~~ diff --git a/src/main/resources/db/changelog/changes/V20260623105538-seed-roles.sql b/src/main/resources/db/changelog/changes/V20260623105538-seed-roles.sql new file mode 100644 index 0000000..a8b3309 --- /dev/null +++ b/src/main/resources/db/changelog/changes/V20260623105538-seed-roles.sql @@ -0,0 +1,12 @@ +--liquibase formatted sql + +-- Seed the fixed set of application roles. +-- The SUPERADMIN role is intentionally deferred (YAGNI); it is planned for a +-- later release and tracked in issue #65. When added, use a new migration +-- (do not edit this one: migrations are append-only). +--changeset ebouchut:V20260623105538 +INSERT INTO roles (role_name, description) VALUES + ('STUDENT', 'Learner who follows courses and does exercises'), + ('INSTRUCTOR', 'Author of courses, lessons and exercises'), + ('ADMIN', 'Platform administrator'); +--rollback DELETE FROM roles WHERE role_name IN ('STUDENT', 'INSTRUCTOR', 'ADMIN'); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html new file mode 100644 index 0000000..7c4c060 --- /dev/null +++ b/src/main/resources/templates/dashboard.html @@ -0,0 +1,15 @@ + + + + + Dashboard + + +

Dashboard

+

Signed in as user.

+
+ +
+ + diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 0000000..aefeee0 --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,14 @@ + + + + + learn-dev + + +

learn-dev

+ + + diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..80dd507 --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,20 @@ + + + + + Login + + +

Login

+ +

Account created, please log in.

+

You have been logged out.

+

Invalid username or password.

+
+
+
+ +
+

Create an account

+ + diff --git a/src/main/resources/templates/register.html b/src/main/resources/templates/register.html new file mode 100644 index 0000000..bb9b196 --- /dev/null +++ b/src/main/resources/templates/register.html @@ -0,0 +1,20 @@ + + + + + Register + + +

Create your account

+
+ +
+ +
+ +
+ +
+

Already have an account? Log in

+ + diff --git a/src/test/java/com/ericbouchut/learndev/LearnDevApplicationTests.java b/src/test/java/com/ericbouchut/learndev/LearnDevApplicationTests.java index fbcb839..9551042 100644 --- a/src/test/java/com/ericbouchut/learndev/LearnDevApplicationTests.java +++ b/src/test/java/com/ericbouchut/learndev/LearnDevApplicationTests.java @@ -1,10 +1,24 @@ package com.ericbouchut.learndev; +import com.ericbouchut.learndev.support.AbstractPostgresIT; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest -class LearnDevApplicationTests { +/** + * This smoke test verifies the Spring application context starts. + *
+ * It runs against a real PostgreSQL database started in a container (via + * {@link AbstractPostgresIT}) instead of the dev database that may be down, + * so it is self-contained. + *
+ * MongoDB is not used by this feature and is not running in + * tests, so its auto-configuration is excluded. + */ +@SpringBootTest(properties = + "spring.autoconfigure.exclude=" + + "org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration") +class LearnDevApplicationTests extends AbstractPostgresIT { @Test void contextLoads() { diff --git a/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java b/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java new file mode 100644 index 0000000..d7205c4 --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java @@ -0,0 +1,65 @@ +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.support.AbstractPostgresIT; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * End-to-end integration test for the authentication flow. Boots the full + * application context against the shared Postgres container and drives the + * journey through MockMvc: a protected page redirects when anonymous, a new + * account can register, wrong credentials are rejected, and correct credentials + * authenticate and land on the dashboard. + * + *

Named with the {@code Test} suffix (not {@code IT}) so Surefire runs it as + * part of {@code mvn test}; this project does not use the Failsafe plugin. + */ +@SpringBootTest(properties = { + // This feature does not use MongoDB; keep the test context Postgres-only. + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration" +}) +@AutoConfigureMockMvc +class AuthFlowTest extends AbstractPostgresIT { + + @Autowired + MockMvc mvc; + + @Test + void register_then_login_then_reach_dashboard() throws Exception { + // A protected page redirects to the login page when anonymous. + mvc.perform(get("/dashboard")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrlPattern("**/auth/login")); + + // Register a new account (CSRF token required for the POST). + mvc.perform(post("/auth/register").with(csrf()) + .param("username", "carol") + .param("email", "carol@example.com") + .param("password", "secret12")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/auth/login?registered")); + + // A wrong password is rejected. + mvc.perform(formLogin("/auth/login").user("carol").password("wrong")) + .andExpect(unauthenticated()); + + // Correct credentials authenticate and redirect to the dashboard. + mvc.perform(formLogin("/auth/login").user("carol").password("secret12")) + .andExpect(authenticated().withUsername("carol")) + .andExpect(redirectedUrl("/dashboard")); + } +} diff --git a/src/test/java/com/ericbouchut/learndev/auth/CustomUserDetailsServiceTest.java b/src/test/java/com/ericbouchut/learndev/auth/CustomUserDetailsServiceTest.java new file mode 100644 index 0000000..54764fc --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/auth/CustomUserDetailsServiceTest.java @@ -0,0 +1,52 @@ +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.role.entity.Role; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CustomUserDetailsServiceTest { + + private final UserRepository users = mock(UserRepository.class); + private final CustomUserDetailsService service = new CustomUserDetailsService(users); + + @Test + void maps_roles_to_ROLE_authorities() { + // Arrange (Given): a user with the STUDENT role + Role student = new Role(); + student.setRoleName("STUDENT"); + User user = new User(); + user.setUsername("lea"); + user.setPassword("hash"); + user.setRoles(Set.of(student)); + + when(users.findByUsername("lea")).thenReturn(Optional.of(user)); + + // Act (When) + UserDetails details = service.loadUserByUsername("lea"); + + // Assert (Then) + assertThat(details.getPassword()).isEqualTo("hash"); + assertThat(details.getAuthorities()) + .extracting(Object::toString) + .containsExactly("ROLE_STUDENT"); + } + + @Test + void throws_when_user_is_unknown() { + when(users.findByUsername("ghost")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.loadUserByUsername("ghost")) + .isInstanceOf(UsernameNotFoundException.class); + } +} diff --git a/src/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java b/src/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java new file mode 100644 index 0000000..18b2d00 --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java @@ -0,0 +1,107 @@ +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.auth.dto.RegisterForm; +import com.ericbouchut.learndev.auth.exception.DuplicateEmailException; +import com.ericbouchut.learndev.auth.exception.DuplicateUsernameException; +import com.ericbouchut.learndev.role.entity.Role; +import com.ericbouchut.learndev.role.repository.RoleRepository; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.sql.SQLException; +import java.util.Optional; + +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.*; + +class RegistrationServiceTest { + + private final UserRepository users = mock(UserRepository.class); + private final RoleRepository roles = mock(RoleRepository.class); + private final PasswordEncoder encoder = mock(PasswordEncoder.class); + private final RegistrationService service = new RegistrationService(users, roles, encoder); + + @Test + void hashes_the_password_and_assigns_the_STUDENT_role() { + // Arrange (Given) + stubHappyPath(); + + // Act (When) + User created = service.register(new RegisterForm("lea", "lea@example.com", "secret12")); + + // Assert (Then) + assertThat(created.getPassword()).isEqualTo("HASHED"); // hashed, not raw + assertThat(created.getRoles()).extracting(Role::getRoleName).containsExactly("STUDENT"); + verify(users).saveAndFlush(any(User.class)); + } + + @Test + void rejects_a_duplicate_email() { + when(users.existsByUsername("lea")).thenReturn(false); + when(users.existsByEmail("lea@example.com")).thenReturn(true); + + assertThatThrownBy(() -> service.register(new RegisterForm("lea", "lea@example.com", "secret12"))) + .isInstanceOf(DuplicateEmailException.class); + verify(users, never()).saveAndFlush(any()); + } + + @Test + void translates_a_username_constraint_violation_raced_past_the_pre_checks() { + // Arrange (Given): pre-checks pass, but a concurrent insert wins the race + // and the INSERT hits the username UNIQUE constraint. + stubHappyPath(); + when(users.saveAndFlush(any(User.class))) + .thenThrow(integrityViolation("users_username_key")); + + // Act + Assert (When/Then) + assertThatThrownBy(() -> service.register(new RegisterForm("lea", "lea@example.com", "secret12"))) + .isInstanceOf(DuplicateUsernameException.class); + } + + @Test + void translates_an_email_constraint_violation_raced_past_the_pre_checks() { + stubHappyPath(); + when(users.saveAndFlush(any(User.class))) + .thenThrow(integrityViolation("users_email_key")); + + assertThatThrownBy(() -> service.register(new RegisterForm("lea", "lea@example.com", "secret12"))) + .isInstanceOf(DuplicateEmailException.class); + } + + @Test + void rethrows_an_unrelated_integrity_violation_unmasked() { + stubHappyPath(); + when(users.saveAndFlush(any(User.class))) + .thenThrow(integrityViolation("some_other_constraint")); + + assertThatThrownBy(() -> service.register(new RegisterForm("lea", "lea@example.com", "secret12"))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private void stubHappyPath() { + Role student = new Role(); + student.setRoleName("STUDENT"); + when(users.existsByUsername("lea")).thenReturn(false); + when(users.existsByEmail("lea@example.com")).thenReturn(false); + when(roles.findByRoleName("STUDENT")).thenReturn(Optional.of(student)); + when(encoder.encode("secret12")).thenReturn("HASHED"); + when(users.saveAndFlush(any(User.class))).thenAnswer(call -> call.getArgument(0)); + } + + /** + * Builds the exception Spring raises when an INSERT violates a UNIQUE + * constraint: a DataIntegrityViolationException wrapping Hibernate's + * ConstraintViolationException, which carries the constraint name. + */ + private static DataIntegrityViolationException integrityViolation(String constraintName) { + return new DataIntegrityViolationException( + "duplicate key", + new ConstraintViolationException("duplicate key", new SQLException("23505"), constraintName)); + } +} diff --git a/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java b/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java new file mode 100644 index 0000000..06837a1 --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java @@ -0,0 +1,40 @@ +package com.ericbouchut.learndev.role.repository; + +import com.ericbouchut.learndev.support.AbstractPostgresIT; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * + * {@code @AutoConfigureTestDatabase(replace = NONE} prevents + * the test from using a H2 embedded database. + * We use containerized PostgresSQL database via {@link AbstractPostgresIT#POSTGRES} + * because we need to test against the real database schema and datatypes + * that H2 does not support. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class RoleRepositoryTest extends AbstractPostgresIT { + + @Autowired + RoleRepository roleRepository; + + @Test + void finds_a_seeded_role_by_its_name() { + // Arrange (Given): Roles are a fixed set seeded by Liquibase. + // The Liquibase migration script (V20260623105538-seed-roles.sql) + // has already inserted the roles. Nothing to set up here. + + // Act (When): look up a seeded role by name + var maybeRole = roleRepository.findByRoleName("STUDENT"); + + // Assert (Then): Verify that it exists and is populated with the default values + assertThat(maybeRole).isPresent(); + assertThat(maybeRole.get().getRoleId()).isNotNull(); // BIGINT identity from the DB + assertThat(maybeRole.get().isActive()).isTrue(); // is_active defaults to true + } +} diff --git a/src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java b/src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java new file mode 100644 index 0000000..eaffc86 --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java @@ -0,0 +1,38 @@ +package com.ericbouchut.learndev.support; + +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.testcontainers.containers.PostgreSQLContainer; + +/** + * Base class for integration tests that need a real PostgreSQL + * to test against the real database schema, because the H2 in-memory database + * does not have some data types such as UUID and TIMESTAMPTZ, and because of Liquibase. + * + *

Why a singleton container (and not {@code @Container} / {@code @Testcontainers})

+ * The PostgreSQL container is a JVM-wide singleton: it is started once in a static + * initializer and shared by every test class that extends this base. + * + * We deliberately do NOT use {@code @Testcontainers} + {@code @Container} here. + * Those tie the container lifecycle to a single test class: the container is + * stopped after that class finishes. Because this base class is shared by several + * test classes, the container would be stopped after the first class, and the next + * class would reuse a dead container, failing with "connection refused" after a + * 30-second Hikari timeout. The static initializer instead keeps the container + * alive for the whole test run (the JVM owns the lifecycle); it is reaped when the + * JVM exits, or by Ryuk in CI. + * + * {@link ServiceConnection @ServiceConnection} still wires Spring Boot's + * {@code DataSource} to the container automatically (JDBC URL, username, password), + * with no manual {@code @DynamicPropertySource}. It only needs a started container, + * so it works with the singleton pattern. + */ +public abstract class AbstractPostgresIT { + + @ServiceConnection + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:17"); + + static { + POSTGRES.start(); + } +} diff --git a/src/test/java/com/ericbouchut/learndev/user/repository/UserRepositoryTest.java b/src/test/java/com/ericbouchut/learndev/user/repository/UserRepositoryTest.java new file mode 100644 index 0000000..8638d3c --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/user/repository/UserRepositoryTest.java @@ -0,0 +1,45 @@ +package com.ericbouchut.learndev.user.repository; + +import com.ericbouchut.learndev.support.AbstractPostgresIT; +import com.ericbouchut.learndev.user.entity.User; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import static org.assertj.core.api.Assertions.assertThat; + +// @DataJpaTest is used for Slice Testing the data layer. +// - It loads only entities, repositories, `EntityManager`, `DataSource`. +// - It does NOT load controllers, services, security. +// - Tests are transactional and roll back by default +@DataJpaTest +// Do not perform tests against an in-memory H2 database but use the real one +// defined in AbstractPostgresIT.POSTGRES annotated with @ServiceConnection and @Container +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class UserRepositoryTest extends AbstractPostgresIT { + + /** + * Spring injects the bean: implementation generated from the UserRepository interface. + */ + @Autowired + UserRepository userRepository; + + @Test + void saves_a_user_generates_a_uuid_and_finds_it_by_username() { + // Arrange (Given): a new user + User user = new User(); + user.setUsername("alice"); + user.setEmail("alice@example.com"); + user.setPassword("hashed"); + + // Act (When): persist it + userRepository.saveAndFlush(user); + + // Assert (Then): UUID generated and lookups work + assertThat(user.getUserId()).isNotNull(); // UUID generated + assertThat(userRepository.findByUsername("alice")).isPresent(); + assertThat(userRepository.existsByEmail("alice@example.com")).isTrue(); + assertThat(userRepository.existsByUsername("bob")).isFalse(); + } +}