From 31a277a53c077c59a33dc5782fa82e6041a58c07 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Thu, 18 Jun 2026 10:02:55 +0200 Subject: [PATCH 01/36] chore(deps): Add validation and testcontainers dependencies --- pom.xml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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 + From 2076d2cb54c679fcb933a7e3ced789b6f19e41f7 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Thu, 18 Jun 2026 10:29:25 +0200 Subject: [PATCH 02/36] docs(plan): Add dev implementation plan for session authentication --- .../2026-06-16-jpa-entities-session-auth.md | 1091 +++++++++++++++++ 1 file changed, 1091 insertions(+) create mode 100644 docs/plans/2026-06-16-jpa-entities-session-auth.md 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..53675ce --- /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 ``) + +- [ ] **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 + +``` + +- [ ] **Step 2: Verify resolution** + +Run: `mvn -q dependency:resolve` +Expected: BUILD SUCCESS (versions come from the Spring Boot parent BOM; no explicit versions needed). + +- [ ] **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` + +- [ ] **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"); +} +``` + +- [ ] **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` + +- [ ] **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. + +- [ ] **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; +} +``` + +- [ ] **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); +} +``` + +- [ ] **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.) + +- [ ] **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` + +- [ ] **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(); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `mvn -q -Dtest=UserRepositoryTest test` +Expected: FAIL (compilation error: `User` / `UserRepository` do not exist). + +- [ ] **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<>(); +} +``` + +- [ ] **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); +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `mvn -q -Dtest=UserRepositoryTest test` +Expected: PASS. + +- [ ] **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` + +- [ ] **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'); +``` + +- [ ] **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`. + +- [ ] **Step 3: Run the Role test (now green)** + +Run: `mvn -q -Dtest=RoleRepositoryTest test` +Expected: PASS. + +- [ ] **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` + +- [ ] **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(); + } +} +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `mvn -q -DskipTests compile` +Expected: BUILD SUCCESS. + +- [ ] **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` + +- [ ] **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); + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `mvn -q -Dtest=CustomUserDetailsServiceTest test` +Expected: FAIL (compilation: `CustomUserDetailsService` does not exist). + +- [ ] **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(); + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `mvn -q -Dtest=CustomUserDetailsServiceTest test` +Expected: PASS. + +- [ ] **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` + +- [ ] **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()); + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `mvn -q -Dtest=RegistrationServiceTest test` +Expected: FAIL (compilation: types do not exist). + +- [ ] **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) { +} +``` + +- [ ] **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); + } +} +``` + +- [ ] **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); + } +} +``` + +- [ ] **Step 6: Run to verify it passes** + +Run: `mvn -q -Dtest=RegistrationServiceTest test` +Expected: PASS. + +- [ ] **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` + +- [ ] **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"; + } +} +``` + +- [ ] **Step 2: Write `home.html`** + +```html + + +learn-dev + +

learn-dev

+

Login · Register

+ + +``` + +- [ ] **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

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

Create your account

+
+ +
+ +
+ +
+ +
+

Already have an account? Log in

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

Dashboard

+

Signed in as user.

+
+ +
+ + +``` + +- [ ] **Step 6: Compile** + +Run: `mvn -q -DskipTests compile` +Expected: BUILD SUCCESS. + +- [ ] **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` + +- [ ] **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")); + } +} +``` + +- [ ] **Step 2: Run the test** + +Run: `mvn -q -Dtest=AuthFlowIT test` +Expected: PASS. + +- [ ] **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) + +- [ ] **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 +``` + +- [ ] **Step 2: Run the whole suite** + +Run: `mvn -q test` +Expected: BUILD SUCCESS, all tests green. + +- [ ] **Step 3: Manual smoke test** + +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. + +- [ ] **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. From 5baacdcda42da667e8ca18a6ab513711802fa719 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Thu, 18 Jun 2026 15:38:09 +0200 Subject: [PATCH 03/36] docs(adr): Add ADR-0005 hand-written Liquibase database migrations Hand-write Liquibase database migrations instead of usiing the useless MCD-generated DDL --- .gitignore | 4 -- ...write-liquibase-migrations-over-mcd-ddl.md | 59 +++++++++++++++++++ docs/adr/README.md | 3 +- 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0005-handwrite-liquibase-migrations-over-mcd-ddl.md 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/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/README.md b/docs/adr/README.md index 6bd213e..2506e96 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -34,4 +34,5 @@ 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 | \ No newline at end of file From 269493b3cfef717952f0355f54ad9d037623fce8 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Thu, 18 Jun 2026 20:09:04 +0200 Subject: [PATCH 04/36] test(persistence): Add Testcontainers PostgreSQL base class --- .../learndev/support/AbstractPostgresIT.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java 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..b6491a1 --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java @@ -0,0 +1,24 @@ +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 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, TIMESTAMPTZ, and because of Liquibase. + * + * The container is started once and shared (static). + * {@link ServiceConnection @ServiceConnection} wires Spring Boot's datasource to it + * automatically. + */ +@Testcontainers +public abstract class AbstractPostgresIT { + + @Container + @ServiceConnection + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:17"); +} From cacc67389a845238eec8a2b545dbaa68c6efa568 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Thu, 18 Jun 2026 20:11:05 +0200 Subject: [PATCH 05/36] docs(adr): Add ADR-0006 to test against real PostgreSQL instead of H2 --- ...st-against-real-postgres-testcontainers.md | 75 +++++++++++++++++++ docs/adr/README.md | 3 +- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0006-test-against-real-postgres-testcontainers.md 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/README.md b/docs/adr/README.md index 2506e96..e6c893d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,4 +35,5 @@ NNNN-short-title-in-kebab-case.md | [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 | -| [0005](0005-handwrite-liquibase-migrations-over-mcd-ddl.md) | Hand-write the schema as Liquibase migrations, not generated from the MCD | accepted | \ No newline at end of file +| [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 | \ No newline at end of file From 2488d8d9916a75499ac1f10e9d22e6eeb3c66ebd Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Fri, 19 Jun 2026 09:34:45 +0200 Subject: [PATCH 06/36] docs(adr): Add ADR-0007 to use PostgreSQL instead of MySQL --- docs/adr/0007-use-postgresql-over-mysql.md | 62 ++++++++++++++++++++++ docs/adr/README.md | 3 +- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0007-use-postgresql-over-mysql.md 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/README.md b/docs/adr/README.md index e6c893d..fc01b73 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -36,4 +36,5 @@ NNNN-short-title-in-kebab-case.md | [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 | | [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 | \ No newline at end of file +| [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 | \ No newline at end of file From 801b7675178ce1b94cb855f854e6f9571a12c19b Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Fri, 19 Jun 2026 15:34:50 +0200 Subject: [PATCH 07/36] feat(role): Add Role entity and repository --- .../learndev/role/entity/Role.java | 28 +++++++++++++ .../role/repository/RoleRepository.java | 10 +++++ .../role/repository/RoleRepositoryTest.java | 42 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 src/main/java/com/ericbouchut/learndev/role/entity/Role.java create mode 100644 src/main/java/com/ericbouchut/learndev/role/repository/RoleRepository.java create mode 100644 src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java 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/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..0ac6c54 --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java @@ -0,0 +1,42 @@ +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 static org.assertj.core.api.Assertions.assertThat; + +/** + * + * @AutoConfigureTestDatabase(Replace = NONE prevents + * replacement with an embedded database (H2). + * We keep the Testcontainers Postgres via {@link AbstractPostgresIT} + * because we need to test against the real DB schema and datatypes that H2 does not support. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class RoleRepositoryTest extends AbstractPostgresIT { + + @Autowired + RoleRepository roleRepository; + + @Test + void saves_a_role_and_finds_it_by_name() { + // Arrange (Given): Test setup + Role role = new Role(); + role.setRoleName("STUDENT"); + role.setDescription("Learner"); + roleRepository.saveAndFlush(role); + + // Act (When): Run the code under test + var maybeRole = roleRepository.findByRoleName("STUDENT"); + + // Assert (Then): Verify the result + assertThat(maybeRole).isPresent(); + assertThat(maybeRole.get().getRoleId()).isNotNull(); // BIGINT identity generated by the DB + assertThat(maybeRole.get().isActive()).isTrue(); // default true + } +} From 5c3044559700f2aca98ba62cfada1010fb8c900e Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Fri, 19 Jun 2026 15:42:03 +0200 Subject: [PATCH 08/36] docs(contributing): Document running tests with Podman and Testcontainers --- CONTRIBUTING.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d29b497..90f8d08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -925,11 +925,39 @@ TODO: Explain how to write tests, what naming convention and best practices ### 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 the Makefile target, 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. Two environment variables make it work: + +```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`) to 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 From 960cbbd8c6c59f24b99490545c3f7dbe296c4757 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Fri, 19 Jun 2026 16:14:09 +0200 Subject: [PATCH 09/36] refactor(test): Start the smoke test on its own Testcontainers PostgreSQL The smoke test now behaves the same locally and in CI . It no longer needs the dev environment running: - Run LearnDevApplicationTests against an ephemeral PostgreSQL started by Testcontainers. - Exclude the MongoDB auto-configuration. --- .../learndev/LearnDevApplicationTests.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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() { From cb43bd00a58de1adf54d6acb086b3d191271e624 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Fri, 19 Jun 2026 16:14:09 +0200 Subject: [PATCH 10/36] chore(make): Add make test target with Podman Testcontainers support --- Makefile | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 4172e12..b1f4688 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 # Default make target used if none specified .DEFAULT_GOAL := help @@ -13,11 +13,25 @@ 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" # 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: From 71893f79eb5b78b61668260f8cf0fa2bca2c1961 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 23 Jun 2026 09:17:10 +0200 Subject: [PATCH 11/36] docs(adr): Add ADR-0008 to share a singleton Testcontainers PostgreSQL --- ...share-singleton-testcontainers-postgres.md | 62 +++++++++++++++++++ docs/adr/README.md | 3 +- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0008-share-singleton-testcontainers-postgres.md 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/README.md b/docs/adr/README.md index fc01b73..c253775 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -37,4 +37,5 @@ NNNN-short-title-in-kebab-case.md | [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 | \ No newline at end of file +| [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 | \ No newline at end of file From ecb7d930544111eebf21c6daf5fffd4e66f8bcb1 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 23 Jun 2026 09:17:13 +0200 Subject: [PATCH 12/36] test(persistence): Share a singleton Testcontainers PostgreSQL across test classes --- .../learndev/support/AbstractPostgresIT.java | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java b/src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java index b6491a1..eaffc86 100644 --- a/src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java +++ b/src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java @@ -2,23 +2,37 @@ 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 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, TIMESTAMPTZ, and because of Liquibase. + * 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. * - * The container is started once and shared (static). - * {@link ServiceConnection @ServiceConnection} wires Spring Boot's datasource to it - * automatically. + *

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. */ -@Testcontainers public abstract class AbstractPostgresIT { - @Container @ServiceConnection static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:17"); + + static { + POSTGRES.start(); + } } From f310421088e60098f235b59c6384468175008f18 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 23 Jun 2026 09:17:13 +0200 Subject: [PATCH 13/36] feat(user): Add User entity and repository --- .../learndev/user/entity/User.java | 72 +++++++++++++++++++ .../user/repository/UserRepository.java | 14 ++++ .../user/repository/UserRepositoryTest.java | 45 ++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/main/java/com/ericbouchut/learndev/user/entity/User.java create mode 100644 src/main/java/com/ericbouchut/learndev/user/repository/UserRepository.java create mode 100644 src/test/java/com/ericbouchut/learndev/user/repository/UserRepositoryTest.java 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..8f6c8d8 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/user/entity/User.java @@ -0,0 +1,72 @@ +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. + * I chose UUID instead of an BIGINT because I expose the user id outside of the database + * as the subject in the session today and in the JWT in the next major release. + */ + @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<>(); +} 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..252e0f3 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/user/repository/UserRepository.java @@ -0,0 +1,14 @@ +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); +} 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(); + } +} From 132274a23c67fcc9c4303a74c0ae105209e9a4ff Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 23 Jun 2026 09:21:18 +0200 Subject: [PATCH 14/36] docs(contributing): clarify ADR guidelines --- CONTRIBUTING.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90f8d08..2144e83 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 From 78979abe1ba971dd83695d84f5e121730e7b62eb Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 23 Jun 2026 11:16:57 +0200 Subject: [PATCH 15/36] feat(role): Seed the STUDENT, INSTRUCTOR and ADMIN roles --- .../changes/V20260623105538-seed-roles.sql | 12 ++++++++++++ .../role/repository/RoleRepositoryTest.java | 15 +++++---------- 2 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/V20260623105538-seed-roles.sql 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/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java b/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java index 0ac6c54..a9b5e5c 100644 --- a/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java +++ b/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java @@ -1,6 +1,5 @@ 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; @@ -24,19 +23,15 @@ class RoleRepositoryTest extends AbstractPostgresIT { RoleRepository roleRepository; @Test - void saves_a_role_and_finds_it_by_name() { - // Arrange (Given): Test setup - Role role = new Role(); - role.setRoleName("STUDENT"); - role.setDescription("Learner"); - roleRepository.saveAndFlush(role); + void finds_a_seeded_role_by_its_name() { + // Arrange (Given): roles are a fixed set seeded by Liquibase (V*-seed-roles.sql) - // Act (When): Run the code under test + // Act (When): look up a seeded role by name var maybeRole = roleRepository.findByRoleName("STUDENT"); // Assert (Then): Verify the result assertThat(maybeRole).isPresent(); - assertThat(maybeRole.get().getRoleId()).isNotNull(); // BIGINT identity generated by the DB - assertThat(maybeRole.get().isActive()).isTrue(); // default true + assertThat(maybeRole.get().getRoleId()).isNotNull(); // BIGINT identity from the DB + assertThat(maybeRole.get().isActive()).isTrue(); // is_active defaults to true } } From c46b7014982b0f81040d24352fa87709ce2a9cdc Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 23 Jun 2026 14:34:03 +0200 Subject: [PATCH 16/36] docs(user): Reword javadoc for userId --- .../java/com/ericbouchut/learndev/user/entity/User.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ericbouchut/learndev/user/entity/User.java b/src/main/java/com/ericbouchut/learndev/user/entity/User.java index 8f6c8d8..6d7990e 100644 --- a/src/main/java/com/ericbouchut/learndev/user/entity/User.java +++ b/src/main/java/com/ericbouchut/learndev/user/entity/User.java @@ -20,8 +20,13 @@ public class User { /** * The user's primary key. - * I chose UUID instead of an BIGINT because I expose the user id outside of the database - * as the subject in the session today and in the JWT in the next major release. + * 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) From 4a48ae511865654d0f9c34f68a786f992dfbe60a Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 23 Jun 2026 14:36:37 +0200 Subject: [PATCH 17/36] test(role): Clarify tests use containerized Postgres DB instead of H2 --- .../role/repository/RoleRepositoryTest.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java b/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java index a9b5e5c..2976e6f 100644 --- a/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java +++ b/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java @@ -11,9 +11,10 @@ /** * * @AutoConfigureTestDatabase(Replace = NONE prevents - * replacement with an embedded database (H2). - * We keep the Testcontainers Postgres via {@link AbstractPostgresIT} - * because we need to test against the real DB schema and datatypes that H2 does not support. + * 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) @@ -24,12 +25,14 @@ class RoleRepositoryTest extends AbstractPostgresIT { @Test void finds_a_seeded_role_by_its_name() { - // Arrange (Given): roles are a fixed set seeded by Liquibase (V*-seed-roles.sql) + // 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 the result + // 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 From ab22e496fdeff5a904dbf476c55f95d7be254115 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 23 Jun 2026 16:43:27 +0200 Subject: [PATCH 18/36] feat(security): Add session form login config under the /auth prefix Configure Spring Security for session-based form login with a BCrypt password encoder. Authentication endpoints are grouped under /auth, and that URL convention is documented in CONTRIBUTING. --- CONTRIBUTING.md | 11 ++++++ .../common/config/SecurityConfig.java | 38 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/main/java/com/ericbouchut/learndev/common/config/SecurityConfig.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2144e83..733b291 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -337,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. 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(); + } +} From 7a7ba116b12281681ec7fffbfd9917237aad3a9d Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Thu, 25 Jun 2026 16:16:47 +0200 Subject: [PATCH 19/36] feat(auth): Add CustomUserDetailsService to load users for Spring Security --- .../auth/CustomUserDetailsService.java | 38 ++++++++++++++ .../auth/CustomUserDetailsServiceTest.java | 52 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/main/java/com/ericbouchut/learndev/auth/CustomUserDetailsService.java create mode 100644 src/test/java/com/ericbouchut/learndev/auth/CustomUserDetailsServiceTest.java 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/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); + } +} From db0f7ba0bb10d0c934f1edc91c12c7ccab224961 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Thu, 25 Jun 2026 20:22:39 +0200 Subject: [PATCH 20/36] feat(auth): Add registration service with default STUDENT role --- .../learndev/auth/RegistrationService.java | 59 +++++++++++++++++++ .../learndev/auth/dto/RegisterForm.java | 31 ++++++++++ .../exception/DuplicateEmailException.java | 10 ++++ .../exception/DuplicateUsernameException.java | 10 ++++ .../auth/RegistrationServiceTest.java | 55 +++++++++++++++++ 5 files changed, 165 insertions(+) create mode 100644 src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java create mode 100644 src/main/java/com/ericbouchut/learndev/auth/dto/RegisterForm.java create mode 100644 src/main/java/com/ericbouchut/learndev/auth/exception/DuplicateEmailException.java create mode 100644 src/main/java/com/ericbouchut/learndev/auth/exception/DuplicateUsernameException.java create mode 100644 src/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java 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..9f4e79d --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java @@ -0,0 +1,59 @@ +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; + +/** + * 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); + + return users.save(user); + } +} 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/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java b/src/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java new file mode 100644 index 0000000..e5d59e0 --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java @@ -0,0 +1,55 @@ +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_the_password_and_assigns_the_STUDENT_role() { + // Arrange (Given) + 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.save(any(User.class))).thenAnswer(call -> call.getArgument(0)); + + // 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).save(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()).save(any()); + } +} From b101a3bdc1da383ba16cbdedbbc2c7ca88aba939 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Fri, 26 Jun 2026 15:27:30 +0200 Subject: [PATCH 21/36] docs(readme): Fix formatting of contributing section --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3aa92f5..20c05e4 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ This starts all the Docker services for the application: **For each service** (`postgres`, `mongo`) declared in the *Docker Compose* configuration file -(`docker-compose.yaml`), *Docker Compose*: +(`[docker-compose.yaml](docker-compose.yaml)`), *Docker Compose*: 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, @@ -165,7 +165,7 @@ 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. @@ -268,25 +268,26 @@ See the [GitHub Project](https://github.com/users/ebouchut/projects/7/views/3) f ## Contributing -See the [CONTRIBUTING.md](CONTRIBUTING.md) file for how to help out. -It contains detailed guidelines, including: +**[CONTRIBUTING.md](CONTRIBUTING.md)** contains: + +- How to help - Architecture overview - Code: - Documentation - - Directory structure - - Naming conventions + - **Directory structure** + - Class and file **naming conventions** - Database: - - Database schema, ERD (Entity Relationships Diagram) + - **Database schema (MCD, MLD, MPD, ERD)** - Running database migrations - Git: - - Git branching strategy - - Git commit message conventions + - **Git branching strategy** + - Git **commit message convention** - Dependencies: - Adding dependencies - Installing dependencies - Running tests -- Submitting pull requests +- Submitting Pull Requests - ... From 60c25fcd8d6786d99af50021e5dc6faf11e06b51 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 30 Jun 2026 12:18:00 +0200 Subject: [PATCH 22/36] feat(auth): Add auth controller and Thymeleaf pages Add AuthController serving the home, login and dashboard views and handling the registration form (display and submission). Duplicate username/email and validation errors are surfaced as field errors; a successful registration redirects to /auth/login?registered. Add the four Thymeleaf templates under the /auth/ URL prefix. Forms post through th:action, so Spring Security's CSRF token is injected automatically. --- .../learndev/auth/AuthController.java | 103 ++++++++++++++++++ src/main/resources/templates/dashboard.html | 15 +++ src/main/resources/templates/home.html | 14 +++ src/main/resources/templates/login.html | 20 ++++ src/main/resources/templates/register.html | 20 ++++ 5 files changed, 172 insertions(+) create mode 100644 src/main/java/com/ericbouchut/learndev/auth/AuthController.java create mode 100644 src/main/resources/templates/dashboard.html create mode 100644 src/main/resources/templates/home.html create mode 100644 src/main/resources/templates/login.html create mode 100644 src/main/resources/templates/register.html 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/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

+ + From 0cee5aaff8fd5006b74514e6f23e31a323649702 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 30 Jun 2026 12:25:08 +0200 Subject: [PATCH 23/36] chore(config): Disable Thymeleaf cache in dev for hot reload Set spring.thymeleaf.cache to false in the dev profile so template edits are picked up without restarting the app. --- src/main/resources/application-dev.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 From aa3df5d6c879a8361074a0bded2cc1160ddd0920 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 30 Jun 2026 15:45:52 +0200 Subject: [PATCH 24/36] docs(adr): Add ADR-0009 to run tests under Surefire, not Failsafe Record the decision to name every test *Test/*Tests and run the whole suite under Surefire in mvn test, rather than adding the Failsafe plugin with an *IT suffix. Prompted by an AuthFlowIT test that mvn test silently skipped because no Failsafe plugin is configured. --- ...9-run-tests-under-surefire-not-failsafe.md | 65 +++++++++++++++++++ docs/adr/README.md | 3 +- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0009-run-tests-under-surefire-not-failsafe.md 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 c253775..9346b53 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -38,4 +38,5 @@ NNNN-short-title-in-kebab-case.md | [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 | \ No newline at end of file +| [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 From 66b769337384ed2c564902e0ade6fd14dafb3f35 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 30 Jun 2026 15:46:02 +0200 Subject: [PATCH 25/36] test(auth): Add end-to-end register, login, dashboard flow test Drive the full auth journey through MockMvc against the shared Postgres container: an anonymous request to /dashboard redirects to login, a new account registers, a wrong password is rejected, and correct credentials authenticate and land on /dashboard. The register POST uses a CSRF token, proving CSRF protection is wired correctly. --- .../learndev/auth/AuthFlowTest.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java 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")); + } +} From 8150a8cef3aedf5942603be2d5cae8d71d563f08 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 30 Jun 2026 15:47:21 +0200 Subject: [PATCH 26/36] feat(security): Harden the session cookie (HttpOnly, SameSite=Lax) Set the session cookie to HttpOnly so client-side JavaScript cannot read it (mitigates XSS session theft) and SameSite=Lax so it is withheld on cross-site requests (CSRF defense in depth). The Secure attribute is left commented out until the app is served over HTTPS. --- src/main/resources/application.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 # ~~~~~~~~~~~~~~~~~~ From f270f3634432257322480d90e327d5ab8155aa0f Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Tue, 30 Jun 2026 22:36:42 +0200 Subject: [PATCH 27/36] docs(readme): Add links to CONTRIBUTING sections --- README.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 20c05e4..cd556de 100644 --- a/README.md +++ b/README.md @@ -271,18 +271,26 @@ See the [GitHub Project](https://github.com/users/ebouchut/projects/7/views/3) f **[CONTRIBUTING.md](CONTRIBUTING.md)** contains: - How to help - -- Architecture overview -- Code: +- [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) +- [Architexture Decision Records](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#architecture-decision-records-adr) (ADRs) +- Codebase: - Documentation - - **Directory structure** - - Class and file **naming conventions** + - [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 schema (MCD, MLD, MPD, ERD)** - - Running database migrations + - [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 convention** + - 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 From 55016a6c62f366008284af10afd68c4dc2c4b4d3 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Wed, 1 Jul 2026 14:55:21 +0200 Subject: [PATCH 28/36] docs: Add GLOSSARY, tech-stacks, and ARCHITECTURE Add three cross-linked reference docs: GLOSSARY.md (domain and technical term definitions), docs/tech-stacks.md (catalogue of tools and versions), and ARCHITECTURE.md (layers, request flow, auth, data, and testing). Each points to the others and to the ADRs, keeping what/how/why separate. --- ARCHITECTURE.md | 145 ++++++++++++++++++++++++++++++++++++++++++++ GLOSSARY.md | 127 ++++++++++++++++++++++++++++++++++++++ docs/tech-stacks.md | 101 ++++++++++++++++++++++++++++++ 3 files changed, 373 insertions(+) create mode 100644 ARCHITECTURE.md create mode 100644 GLOSSARY.md create mode 100644 docs/tech-stacks.md 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/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/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). | From 606f5068e128667053de4344b714a7e5ca5fd846 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Wed, 1 Jul 2026 14:55:35 +0200 Subject: [PATCH 29/36] chore(make): Add run target to start databases and the app Add a run target that starts the Podman machine if the socket is unreachable, brings up the Postgres and Mongo containers, then runs the Spring Boot app in the foreground. Also list it in the help output. --- Makefile | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b1f4688..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 test +.PHONY: help diagrams mcd mld mpd clean check-schema-drift test run # Default make target used if none specified .DEFAULT_GOAL := help @@ -14,6 +14,7 @@ help: @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 @@ -37,6 +38,19 @@ test: 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..." From 5b87bfcc81bfec140ded6f27d660a5b642f2788c Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Wed, 1 Jul 2026 14:55:41 +0200 Subject: [PATCH 30/36] docs(plan): Mark session-auth plan steps complete Tick the completed steps for the JPA entities and session-auth plan. The manual browser smoke test (Task 11, Step 3) is left unchecked as it has not been performed yet. --- .../2026-06-16-jpa-entities-session-auth.md | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/docs/plans/2026-06-16-jpa-entities-session-auth.md b/docs/plans/2026-06-16-jpa-entities-session-auth.md index 53675ce..afddf82 100644 --- a/docs/plans/2026-06-16-jpa-entities-session-auth.md +++ b/docs/plans/2026-06-16-jpa-entities-session-auth.md @@ -61,7 +61,7 @@ src/test/java/com/ericbouchut/learndev/ **Files:** - Modify: `pom.xml` (inside ``) -- [ ] **Step 1: Add the dependencies** +- [x] **Step 1: Add the dependencies** Add these inside `` in `pom.xml`: @@ -87,12 +87,12 @@ Add these inside `` in `pom.xml`: ``` -- [ ] **Step 2: Verify resolution** +- [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). -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add pom.xml @@ -106,7 +106,7 @@ git commit -m "chore(deps): add validation and Testcontainers for auth feature" **Files:** - Create: `src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java` -- [ ] **Step 1: Write the base class** +- [x] **Step 1: Write the base class** ```java package com.ericbouchut.learndev.support; @@ -131,7 +131,7 @@ public abstract class AbstractPostgresIT { } ``` -- [ ] **Step 2: Commit** +- [x] **Step 2: Commit** ```bash git add src/test/java/com/ericbouchut/learndev/support/AbstractPostgresIT.java @@ -147,7 +147,7 @@ git commit -m "test: add Testcontainers PostgreSQL base class" - Create: `src/main/java/com/ericbouchut/learndev/role/repository/RoleRepository.java` - Test: `src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java` -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** ```java package com.ericbouchut.learndev.role.repository; @@ -182,7 +182,7 @@ class RoleRepositoryTest extends AbstractPostgresIT { > 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. -- [ ] **Step 2: Write the entity** +- [x] **Step 2: Write the entity** ```java package com.ericbouchut.learndev.role.entity; @@ -215,7 +215,7 @@ public class Role { } ``` -- [ ] **Step 3: Write the repository** +- [x] **Step 3: Write the repository** ```java package com.ericbouchut.learndev.role.repository; @@ -230,12 +230,12 @@ public interface RoleRepository extends JpaRepository { } ``` -- [ ] **Step 4: Run the test (will pass after Task 5 seeds roles)** +- [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.) -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/main/java/com/ericbouchut/learndev/role @@ -252,7 +252,7 @@ git commit -m "feat(role): add Role entity and repository" - Create: `src/main/java/com/ericbouchut/learndev/user/repository/UserRepository.java` - Test: `src/test/java/com/ericbouchut/learndev/user/repository/UserRepositoryTest.java` -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** ```java package com.ericbouchut.learndev.user.repository; @@ -289,12 +289,12 @@ class UserRepositoryTest extends AbstractPostgresIT { } ``` -- [ ] **Step 2: Run the test to verify it fails** +- [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). -- [ ] **Step 3: Write the entity** +- [x] **Step 3: Write the entity** ```java package com.ericbouchut.learndev.user.entity; @@ -366,7 +366,7 @@ public class User { } ``` -- [ ] **Step 4: Write the repository** +- [x] **Step 4: Write the repository** ```java package com.ericbouchut.learndev.user.repository; @@ -385,12 +385,12 @@ public interface UserRepository extends JpaRepository { } ``` -- [ ] **Step 5: Run the test to verify it passes** +- [x] **Step 5: Run the test to verify it passes** Run: `mvn -q -Dtest=UserRepositoryTest test` Expected: PASS. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add src/main/java/com/ericbouchut/learndev/user @@ -405,7 +405,7 @@ git commit -m "feat(user): add User entity and repository" **Files:** - Create: `src/main/resources/db/changelog/changes/V20260616090000-seed-roles.sql` -- [ ] **Step 1: Write the migration** +- [x] **Step 1: Write the migration** ```sql --liquibase formatted sql @@ -419,7 +419,7 @@ INSERT INTO roles (role_name, description) VALUES --rollback DELETE FROM roles WHERE role_name IN ('STUDENT', 'INSTRUCTOR', 'ADMIN'); ``` -- [ ] **Step 2: Apply to the running dev DB and verify** +- [x] **Step 2: Apply to the running dev DB and verify** Run: ```bash @@ -432,12 +432,12 @@ docker exec learn-dev-postgres-1 psql -U postgres -d learndev -At -c "SELECT rol ``` Expected: `ADMIN`, `INSTRUCTOR`, `STUDENT`. -- [ ] **Step 3: Run the Role test (now green)** +- [x] **Step 3: Run the Role test (now green)** Run: `mvn -q -Dtest=RoleRepositoryTest test` Expected: PASS. -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add src/main/resources/db/changelog/changes/V20260616090000-seed-roles.sql @@ -451,7 +451,7 @@ git commit -m "feat(role): seed STUDENT, INSTRUCTOR, ADMIN roles" **Files:** - Create: `src/main/java/com/ericbouchut/learndev/common/config/SecurityConfig.java` -- [ ] **Step 1: Write the config** +- [x] **Step 1: Write the config** ```java package com.ericbouchut.learndev.common.config; @@ -492,12 +492,12 @@ public class SecurityConfig { } ``` -- [ ] **Step 2: Build to verify it compiles** +- [x] **Step 2: Build to verify it compiles** Run: `mvn -q -DskipTests compile` Expected: BUILD SUCCESS. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add src/main/java/com/ericbouchut/learndev/common/config/SecurityConfig.java @@ -512,7 +512,7 @@ git commit -m "feat(security): session form-login filter chain and BCrypt encode - Create: `src/main/java/com/ericbouchut/learndev/auth/CustomUserDetailsService.java` - Test: `src/test/java/com/ericbouchut/learndev/auth/CustomUserDetailsServiceTest.java` -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** ```java package com.ericbouchut.learndev.auth; @@ -564,12 +564,12 @@ class CustomUserDetailsServiceTest { } ``` -- [ ] **Step 2: Run to verify it fails** +- [x] **Step 2: Run to verify it fails** Run: `mvn -q -Dtest=CustomUserDetailsServiceTest test` Expected: FAIL (compilation: `CustomUserDetailsService` does not exist). -- [ ] **Step 3: Write the service** +- [x] **Step 3: Write the service** ```java package com.ericbouchut.learndev.auth; @@ -612,12 +612,12 @@ public class CustomUserDetailsService implements UserDetailsService { } ``` -- [ ] **Step 4: Run to verify it passes** +- [x] **Step 4: Run to verify it passes** Run: `mvn -q -Dtest=CustomUserDetailsServiceTest test` Expected: PASS. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/main/java/com/ericbouchut/learndev/auth/CustomUserDetailsService.java @@ -636,7 +636,7 @@ git commit -m "feat(auth): load users into Spring Security via CustomUserDetails - Create: `src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java` - Test: `src/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java` -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** ```java package com.ericbouchut.learndev.auth; @@ -695,12 +695,12 @@ class RegistrationServiceTest { } ``` -- [ ] **Step 2: Run to verify it fails** +- [x] **Step 2: Run to verify it fails** Run: `mvn -q -Dtest=RegistrationServiceTest test` Expected: FAIL (compilation: types do not exist). -- [ ] **Step 3: Write the DTO** +- [x] **Step 3: Write the DTO** ```java package com.ericbouchut.learndev.auth.dto; @@ -716,7 +716,7 @@ public record RegisterForm( } ``` -- [ ] **Step 4: Write the exceptions** +- [x] **Step 4: Write the exceptions** ```java package com.ericbouchut.learndev.auth.exception; @@ -738,7 +738,7 @@ public class DuplicateEmailException extends RuntimeException { } ``` -- [ ] **Step 5: Write the service** +- [x] **Step 5: Write the service** ```java package com.ericbouchut.learndev.auth; @@ -788,12 +788,12 @@ public class RegistrationService { } ``` -- [ ] **Step 6: Run to verify it passes** +- [x] **Step 6: Run to verify it passes** Run: `mvn -q -Dtest=RegistrationServiceTest test` Expected: PASS. -- [ ] **Step 7: Commit** +- [x] **Step 7: Commit** ```bash git add src/main/java/com/ericbouchut/learndev/auth @@ -812,7 +812,7 @@ git commit -m "feat(auth): registration service with hashing, default role, dupl - Create: `src/main/resources/templates/register.html` - Create: `src/main/resources/templates/dashboard.html` -- [ ] **Step 1: Write the controller** +- [x] **Step 1: Write the controller** ```java package com.ericbouchut.learndev.auth; @@ -878,7 +878,7 @@ public class AuthController { } ``` -- [ ] **Step 2: Write `home.html`** +- [x] **Step 2: Write `home.html`** ```html @@ -891,7 +891,7 @@ public class AuthController { ``` -- [ ] **Step 3: Write `login.html`** +- [x] **Step 3: Write `login.html`** ```html @@ -912,7 +912,7 @@ public class AuthController { ``` -- [ ] **Step 4: Write `register.html`** +- [x] **Step 4: Write `register.html`** ```html @@ -934,7 +934,7 @@ public class AuthController { ``` -- [ ] **Step 5: Write `dashboard.html`** +- [x] **Step 5: Write `dashboard.html`** ```html @@ -951,12 +951,12 @@ public class AuthController { ``` -- [ ] **Step 6: Compile** +- [x] **Step 6: Compile** Run: `mvn -q -DskipTests compile` Expected: BUILD SUCCESS. -- [ ] **Step 7: Commit** +- [x] **Step 7: Commit** ```bash git add src/main/java/com/ericbouchut/learndev/auth/AuthController.java @@ -971,7 +971,7 @@ git commit -m "feat(auth): registration/login/dashboard pages and controller" **Files:** - Create: `src/test/java/com/ericbouchut/learndev/auth/AuthFlowIT.java` -- [ ] **Step 1: Write the integration test** +- [x] **Step 1: Write the integration test** ```java package com.ericbouchut.learndev.auth; @@ -1028,12 +1028,12 @@ class AuthFlowIT extends AbstractPostgresIT { } ``` -- [ ] **Step 2: Run the test** +- [x] **Step 2: Run the test** Run: `mvn -q -Dtest=AuthFlowIT test` Expected: PASS. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add src/test/java/com/ericbouchut/learndev/auth/AuthFlowIT.java @@ -1047,7 +1047,7 @@ git commit -m "test(auth): end-to-end register, login, protected-page flow" **Files:** - Modify: `src/main/resources/application.yaml` (add under `server:` at the root level) -- [ ] **Step 1: Add session-cookie hardening** +- [x] **Step 1: Add session-cookie hardening** Append to `application.yaml` (top-level key, sibling of `spring:`): @@ -1061,12 +1061,12 @@ server: # secure: true # enable once served over HTTPS ``` -- [ ] **Step 2: Run the whole suite** +- [x] **Step 2: Run the whole suite** Run: `mvn -q test` Expected: BUILD SUCCESS, all tests green. -- [ ] **Step 3: Manual smoke test** +- [ ] **Step 3: Manual smoke test** _(not yet performed — run `make run` and click through in a browser)_ Run: ```bash @@ -1075,7 +1075,7 @@ 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. -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add src/main/resources/application.yaml From 531de98d2ddfac92c5ddded8712de58a2d9db23a Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Wed, 1 Jul 2026 14:55:49 +0200 Subject: [PATCH 31/36] docs(readme): Expand run instructions and link project docs Rewrite the run section (sdk env, podman guard, compose, mvnw) and add a note on why Podman needs a Linux VM on macOS and Windows. Add a Documentation section linking ARCHITECTURE, tech-stacks, GLOSSARY, and the ADRs. Use 'podman info' rather than 'podman machine info' for the machine-up check, since the latter succeeds even when the machine is down. --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index cd556de..9092dc7 100644 --- a/README.md +++ b/README.md @@ -140,19 +140,42 @@ 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.yaml)`), *Docker Compose*: +The first command starts the Podman machine if it is not running yet. +Then `docker compoose up -d` starts all the application Docker services + (`postgres`, `mongo`) as declared in `[docker-compose.yaml](docker-compose.yaml)` +(the *Docker Compose* configuration file), like tis: + +1. Download the Docker image (if not cached locally yet) + from the [Docker Hub](https://hub.docker.com/) public registry. +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] +> 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 @@ -181,12 +204,13 @@ A Docker image is pre-packaged piece of software that can work as a standalone o Running the app using `docker compose up -d` starts **all** the application services, including `postgres`. -To only start the `postgres` service: +To start the `postgres` service only: ```shell docker compose start -d postgres docker compose logs postgres ``` + > [!NOTE] > > The above command downloads, installs the `postgres` Docker image @@ -196,6 +220,7 @@ docker compose logs postgres > [!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. Now, check that `postgres` is running: @@ -266,6 +291,14 @@ docker volume rm learn-dev_mongo_data # Remove the named volume See the [GitHub Project](https://github.com/users/ebouchut/projects/7/views/3) for up-to-date information. +## Documentation + +- [ARCHITECTURE.md](ARCHITECTURE.md) — how the pieces fit together (layers, request flow, auth, data, testing). +- [docs/tech-stacks.md](docs/tech-stacks.md) — catalogue of tools, languages, and frameworks with versions. +- [GLOSSARY.md](GLOSSARY.md) — definitions of the domain and technical terms used across the project. +- [Architecture Decision Records](docs/adr/README.md) — the numbered log of design decisions and their trade-offs. + + ## Contributing **[CONTRIBUTING.md](CONTRIBUTING.md)** contains: From e69a293390daee55affacfaed3495b691d3dfa3e Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Wed, 1 Jul 2026 14:58:11 +0200 Subject: [PATCH 32/36] docs(contributing): Refine dependency and testing guidance - Drop the frontend/npm mention (the backend uses Maven only) and the stale 'cd backend' step now that the app is at the project root. - Add a test naming-convention note (test classes end in Test) . - Reword the Podman Testcontainers setup. --- CONTRIBUTING.md | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 733b291..7846b46 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -899,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 @@ -928,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 ``` @@ -936,6 +933,13 @@ 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 Repository and integration tests run against a **real PostgreSQL** started by @@ -944,8 +948,8 @@ engine must be running. This project uses **Podman**. #### Run All Tests -The simplest way is the Makefile target, which configures Testcontainers for -Podman automatically: +The simplest way is to use ` make test`, which configures *Testcontainers* for +*Podman* automatically: ```bash make test @@ -955,22 +959,26 @@ 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. Two environment variables make it work: +*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`) to 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`. +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`. +On real Docker (for example in CI) neither variable is needed; `make test` +falls back to a plain `./mvnw test`. ### Generating the Documentation From 61f9ce3d00569774246ad23b208432fad5b86212 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Thu, 2 Jul 2026 15:17:33 +0200 Subject: [PATCH 33/36] docs:(readme): Fix docker service lifecycle, postgres and a few typos Address PR review comments in README --- README.md | 137 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 80 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 9092dc7..7e2fcb2 100644 --- a/README.md +++ b/README.md @@ -155,17 +155,28 @@ Here is the procedure: ./mvnw spring-boot:run ``` -The first command starts the Podman machine if it is not running yet. +The first command starts the Podman machine if it is not already running. Then `docker compoose up -d` starts all the application Docker services - (`postgres`, `mongo`) as declared in `[docker-compose.yaml](docker-compose.yaml)` -(the *Docker Compose* configuration file), like tis: + 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 (if not cached locally yet) - from the [Docker Hub](https://hub.docker.com/) public registry. +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. > @@ -179,7 +190,8 @@ Then `docker compoose up -d` starts all the application Docker services ## 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 @@ -193,34 +205,36 @@ I use **Docker Compose** (a CLI tool) to describe and handle the lifecycle of se 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`. -To start the `postgres` service only: +#### Stop Postgres + ```shell -docker compose start -d postgres -docker compose logs postgres -``` +docker compose stop 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. +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] -> 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. Now, check that `postgres` is running: @@ -232,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 all Postgres Databases +#### Remove the 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 ``` @@ -267,36 +292,34 @@ 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, auth, data, testing). -- [docs/tech-stacks.md](docs/tech-stacks.md) — catalogue of tools, languages, and frameworks with versions. +- [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) — the numbered log of design decisions and their trade-offs. +- [Architecture Decision Records](docs/adr/README.md) — A list of design decisions and their trade-offs. ## Contributing @@ -306,20 +329,20 @@ See the [GitHub Project](https://github.com/users/ebouchut/projects/7/views/3) f - 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) -- [Architexture Decision Records](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#architecture-decision-records-adr) (ADRs) +- [Architecture Decision Records](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#architecture-decision-records-adr) (ADRs) - Codebase: - Documentation - [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**: - [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 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](https://github.com/ebouchut/learn-dev?tab=contributing-ov-file#git-branching-strategy) @@ -329,7 +352,7 @@ See the [GitHub Project](https://github.com/users/ebouchut/projects/7/views/3) f - Installing dependencies - Running tests - Submitting Pull Requests -- ... +- TODO: ... ## License From c37bd11d63e8c996a72cf8d977a522824a0f5c72 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Thu, 2 Jul 2026 16:13:53 +0200 Subject: [PATCH 34/36] fix(auth): Translate duplicate-user DB constraint violations The existsBy* pre-checks race under concurrency: two registrations can both pass them, and the loser hits the users_username_key or users_email_key UNIQUE constraint, surfacing as an unhandled 500. Flush the INSERT inside the service (saveAndFlush) so the violation is catchable there, and map it to DuplicateUsernameException or DuplicateEmailException by constraint name. The name is used instead of re-querying because PostgreSQL aborts the transaction after a failed statement. Unrelated integrity violations are rethrown unmasked. Addresses PR #66 review comment on the registration race condition. --- .../learndev/auth/RegistrationService.java | 36 +++++++++- .../auth/RegistrationServiceTest.java | 70 ++++++++++++++++--- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java b/src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java index 9f4e79d..07d6801 100644 --- a/src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java +++ b/src/main/java/com/ericbouchut/learndev/auth/RegistrationService.java @@ -7,6 +7,8 @@ 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; @@ -54,6 +56,38 @@ public User register(RegisterForm form) { user.setPassword(encoder.encode(form.password())); user.getRoles().add(student); - return users.save(user); + // 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/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java b/src/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java index e5d59e0..18b2d00 100644 --- a/src/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java +++ b/src/test/java/com/ericbouchut/learndev/auth/RegistrationServiceTest.java @@ -2,13 +2,17 @@ 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; @@ -26,13 +30,7 @@ class RegistrationServiceTest { @Test void hashes_the_password_and_assigns_the_STUDENT_role() { // Arrange (Given) - 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.save(any(User.class))).thenAnswer(call -> call.getArgument(0)); + stubHappyPath(); // Act (When) User created = service.register(new RegisterForm("lea", "lea@example.com", "secret12")); @@ -40,7 +38,7 @@ void hashes_the_password_and_assigns_the_STUDENT_role() { // Assert (Then) assertThat(created.getPassword()).isEqualTo("HASHED"); // hashed, not raw assertThat(created.getRoles()).extracting(Role::getRoleName).containsExactly("STUDENT"); - verify(users).save(any(User.class)); + verify(users).saveAndFlush(any(User.class)); } @Test @@ -50,6 +48,60 @@ void rejects_a_duplicate_email() { assertThatThrownBy(() -> service.register(new RegisterForm("lea", "lea@example.com", "secret12"))) .isInstanceOf(DuplicateEmailException.class); - verify(users, never()).save(any()); + 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)); } } From 59f806ac7f173948741ab375c88731ce7b3b2a66 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Thu, 2 Jul 2026 16:14:06 +0200 Subject: [PATCH 35/36] perf(user): Load roles lazily, fetch them only for authentication Drop the EAGER fetch on User.roles (LAZY is the ManyToMany default), so User loads no longer join roles unconditionally. Authentication is the one path that always needs them, so findByUsername opts in with an entity graph, fetching user and roles in a single query. Addresses PR #66 review comment on the EAGER many-to-many fetch. --- .../java/com/ericbouchut/learndev/user/entity/User.java | 4 +++- .../learndev/user/repository/UserRepository.java | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ericbouchut/learndev/user/entity/User.java b/src/main/java/com/ericbouchut/learndev/user/entity/User.java index 6d7990e..1f3b3c9 100644 --- a/src/main/java/com/ericbouchut/learndev/user/entity/User.java +++ b/src/main/java/com/ericbouchut/learndev/user/entity/User.java @@ -68,7 +68,9 @@ public class User { // 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) + // 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"), diff --git a/src/main/java/com/ericbouchut/learndev/user/repository/UserRepository.java b/src/main/java/com/ericbouchut/learndev/user/repository/UserRepository.java index 252e0f3..d5a2e40 100644 --- a/src/main/java/com/ericbouchut/learndev/user/repository/UserRepository.java +++ b/src/main/java/com/ericbouchut/learndev/user/repository/UserRepository.java @@ -1,13 +1,22 @@ 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); From 53c293bcd200b05d960b01552c4bccfdae15539d Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Thu, 2 Jul 2026 22:53:11 +0200 Subject: [PATCH 36/36] docs(test): Fix javadoc in RoleRepositoryTest Addrress PR #66 review comment --- .../learndev/role/repository/RoleRepositoryTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java b/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java index 2976e6f..06837a1 100644 --- a/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java +++ b/src/test/java/com/ericbouchut/learndev/role/repository/RoleRepositoryTest.java @@ -10,7 +10,7 @@ /** * - * @AutoConfigureTestDatabase(Replace = NONE prevents + * {@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