Cours Java Spring Boot

Projet CGB — Crédit Général Bank · API REST de virements bancaires

Java 21 Spring Boot 3.4.2 Spring Data JPA Spring Security H2 Database JUnit 5 + MockMvc JWT

🤖 Prompt IA — Apprendre Java Spring Boot avec CGB

Copie ce prompt et donne-le à une IA (Claude, ChatGPT…) avec le projet CGB pour apprendre de zéro.

Prompt prêt à copier :
Tu es un expert Java Spring Boot et formateur bienveillant. Je vais t'envoyer le code source complet d'un projet pédagogique nommé CGB (Crédit Général Bank). C'est une API REST de gestion de virements bancaires, construite avec : - Spring Boot 3.4.2 - Spring Data JPA (H2 en mémoire/fichier) - Spring Security (Basic Auth puis JWT) - JUnit 5 + MockMvc pour les tests Tu vas m'accompagner ÉTAPE PAR ÉTAPE, depuis zéro. PHASE 1 — Structure du projet Explique l'architecture Spring Boot (couches Controller / Service / Repository / Entity). Montre-moi comment lire le pom.xml et comprendre les dépendances. PHASE 2 — Premier endpoint REST Explique @RestController, @RequestMapping, @PostMapping, @DeleteMapping. Explique la différence entre un DTO (TransferRequest) et une Entity (Transfer). Explique ResponseEntity et les codes HTTP (200, 400, 404…). PHASE 3 — Persistance avec JPA Explique @Entity, @Id, @GeneratedValue. Explique JpaRepository et ses méthodes (findById, save, deleteById). Explique pourquoi findById retourne Optional et comment l'utiliser. PHASE 4 — Base de données H2 Explique le fichier application.properties (datasource, console H2). Explique @PostConstruct pour initialiser les données de test. Montre comment accéder à la console H2 dans le navigateur. PHASE 5 — Transactions et règles métier Explique @Transactional et pourquoi c'est crucial pour les virements. Explique les exceptions métier (TransferException, DeleteTransferException, enum FailureTransfert). PHASE 6 — Sécurité Explique Spring Security, SecurityFilterChain, httpBasic(). Explique l'algorithme IBAN (format FR, 27 caractères, modulo 97). Explique le pattern Singleton pour CGBIbanValidator. Explique JWT (header.payload.signature, JJWT library, @PreAuthorize). PHASE 7 — Tests Explique @SpringBootTest, @AutoConfigureMockMvc, @WithMockUser. Explique mockMvc.perform().andExpect() avec status(), jsonPath(). Montre comment tester un endpoint POST et DELETE. PHASE 8 — Missions avancées Explique @Async, @EnableAsync, CompletableFuture pour le traitement en lot. Explique les états d'un virement (waiting, success, failure, delayed, canceled). Explique les relations JPA entre Account, UserCGB, Customer, Role. Après chaque phase, pose-moi 2-3 questions pour valider ma compréhension. Si je bloque, donne-moi des indices progressifs plutôt que la solution directe. Commence par la Phase 1.

🏗 Architecture Spring Boot Fondations

Spring Boot applique le patron MVC en couches. Chaque couche a une responsabilité unique :

CoucheAnnotationRôleFichier CGB
Controller@RestControllerReçoit les requêtes HTTP, renvoie JSONTransferRestController.java
Service@ServiceLogique métier, transactionsTransferService.java
Repository@RepositoryAccès BDD via JPATransferRepository.java
Entity@EntityClasse mappée sur une table SQLTransfer.java, Account.java
// Flux d'une requête POST /api/transfers :
HTTP Request
  → TransferRestController.createTransfer()   // reçoit le JSONTransferService.createTransfer()          // applique les règlesAccountRepository.findById()             // lit la BDDTransferRepository.save()               // écrit la BDD
    ← renvoie Transfer (entity)
  ← ResponseEntity.ok(transfer)              // HTTP 200 + JSON
Injection de dépendances : Spring crée lui-même les instances (beans) et les injecte via @Autowired. Tu ne fais jamais new TransferService() dans le controller — Spring s'en charge.
Exercice 1

Dans quel fichier se trouve la logique qui vérifie que le solde est suffisant avant un virement ? Dans quel fichier se fait le stockage du virement en base de données ?

📦 Maven & pom.xml Build

Maven est l'outil de build Java de CGB. Il gère les dépendances (fichiers .jar) et compile le projet.

<!-- pom.xml — extrait des dépendances clés CGB -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.4.2</version>
</parent>

<dependencies>
    <!-- Spring Web : @RestController, ResponseEntity... -->
    <dependency>spring-boot-starter-web</dependency>

    <!-- JPA : @Entity, JpaRepository... -->
    <dependency>spring-boot-starter-data-jpa</dependency>

    <!-- H2 : base de données en mémoire/fichier -->
    <dependency>h2</dependency>

    <!-- Security : Basic Auth, JWT... -->
    <dependency>spring-boot-starter-security</dependency>

    <!-- Tests : JUnit 5, MockMvc -->
    <dependency>spring-boot-starter-test</dependency>
</dependencies>

Les commandes Maven essentielles :

CommandeEffet
mvn spring-boot:runLance l'application
mvn testLance les tests JUnit
mvn packageCompile + crée le JAR exécutable
mvn cleanSupprime le dossier target/

🚀 Point d'entrée Spring Boot

Toute application Spring Boot démarre par une classe annotée @SpringBootApplication.

@SpringBootApplication
public class CgbApplication {
    public static void main(String[] args) {
        SpringApplication.run(CgbApplication.class, args);
    }
}
@SpringBootApplication est un raccourci pour 3 annotations :
  • @Configuration — cette classe peut déclarer des beans
  • @EnableAutoConfiguration — Spring détecte et configure automatiquement H2, JPA, Security…
  • @ComponentScan — Spring scanne le package et ses sous-packages pour trouver tous les @Component, @Service, @Repository, @Controller

@RestController Couche Web

@RestController combine @Controller (Spring MVC) et @ResponseBody (sérialise automatiquement les retours en JSON via Jackson).

@RestController
@RequestMapping("/api/transfers")   // préfixe de toutes les routes
public class TransferRestController {

    @Autowired
    private TransferService transferService;

    @GetMapping                         // GET  /api/transfers
    public List<Transfer> getAll() { ... }

    @PostMapping                        // POST /api/transfers
    public ResponseEntity<?> create(@RequestBody TransferRequest req) { ... }

    @DeleteMapping                      // DELETE /api/transfers
    public ResponseEntity<?> delete(@RequestBody Long id) { ... }
}
AnnotationMéthode HTTPUsage
@GetMappingGETLire des données
@PostMappingPOSTCréer une ressource
@PutMappingPUTRemplacer une ressource
@PatchMappingPATCHModifier partiellement
@DeleteMappingDELETESupprimer une ressource
Exercice 2

Quelle annotation utilises-tu pour lire un objet JSON envoyé dans le corps d'une requête POST ? Et pour lire un paramètre d'URL comme /api/transfers/42 ?

DTO vs Entity Couche Web

CGB utilise deux types d'objets pour les virements. Il ne faut pas les confondre :

DTO (Data Transfer Object)Entity
ClasseTransferRequestTransfer
Annotationaucune (POJO)@Entity
RôleReçoit les données du clientReprésente une ligne en BDD
En BDD ?NonOui, table transfer
// DTO — ce que le client envoie dans le JSON
public class TransferRequest {
    private String sourceAccountNumber;
    private String destinationAccountNumber;
    private Double amount;
    private LocalDate transferDate;
    private String description;
    // getters / setters
}

// Entity — ce qui est stocké en base
@Entity
public class Transfer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;                       // auto-incrémenté par H2
    private String sourceAccountNumber;
    private String destinationAccountNumber;
    private Double amount;
    private LocalDate transferDate;
    private String description;
}
Pourquoi ne pas exposer l'Entity directement ? Parce que l'Entity peut contenir des champs sensibles (mot de passe haché, colonnes internes), et parce que le format attendu par le client peut différer du schéma BDD. Le DTO fait office de contrat d'API stable.

ResponseEntity Couche Web

ResponseEntity<T> permet de contrôler à la fois le code HTTP et le corps de la réponse.

// Succès — HTTP 200 avec le virement créé
return ResponseEntity.ok(transfer);

// Erreur client — HTTP 400 avec message d'erreur
return ResponseEntity
    .status(HttpStatus.BAD_REQUEST)
    .body(new TransferResponse("FAILURE", e.getMessage()));

// Ressource non trouvée — HTTP 404
return ResponseEntity.notFound().build();

// Créé avec succès — HTTP 201
return ResponseEntity.status(HttpStatus.CREATED).body(transfer);
Exercice 3

Dans TransferRestController.createTransfer(), que se passe-t-il si le solde du compte source est insuffisant ? Quel code HTTP est renvoyé et pourquoi ?

JPA & Repository Persistance

Spring Data JPA génère automatiquement les requêtes SQL à partir d'interfaces Java. Tu n'écris plus de SQL.

// Interface vide — Spring génère toutes les requêtes SQL !
public interface TransferRepository extends JpaRepository<Transfer, Long> {
    // Méthodes héritées : findAll(), findById(), save(), deleteById()...

    // Convention de nommage : Spring génère le SQL automatiquement
    List<Transfer> findBySourceAccountNumber(String accountNumber);
    List<Transfer> findByAmountGreaterThan(Double amount);
}

// Account — clé primaire String (numéro de compte)
public interface AccountRepository extends JpaRepository<Account, String> { }
Méthode JpaRepositorySQL généré
findById(id)SELECT * FROM transfer WHERE id = ?
findAll()SELECT * FROM transfer
save(entity)INSERT ou UPDATE selon si l'id existe
deleteById(id)DELETE FROM transfer WHERE id = ?
count()SELECT COUNT(*) FROM transfer
Exercice 4

Comment déclarer dans TransferRepository une méthode qui retrouve tous les virements dont le montant est exactement 100.0 ? Et tous ceux d'un mois donné ?

H2 & @PostConstruct Persistance

application.properties

# Base H2 sur fichier (données persistées entre les redémarrages)
spring.datasource.url=jdbc:h2:file:./db/db_cgb;AUTO_SERVER=TRUE
spring.datasource.username=sio
spring.datasource.password=slam

# Console H2 accessible dans le navigateur
spring.h2.console.enabled=true
spring.h2.console.path=/console

# JPA : affiche les requêtes SQL dans la console
spring.jpa.show-sql=true

La console H2 est accessible à http://localhost:8080/console (avec les credentials sio/slam).

@PostConstruct — initialisation avec IBAN valides (Mission 2)

@Component
public class DatabaseInitializer {

    private final AccountRepository accountRepository;

    @Autowired
    public DatabaseInitializer(AccountRepository accountRepository) {
        this.accountRepository = accountRepository; // injection par constructeur (recommandée)
    }

    @PostConstruct
    public void init() {
        if (accountRepository.count() == 0) {
            insertSampleData(accountRepository);
        }
    }

    public static void insertSampleData(AccountRepository repo) {
        double[] soldes = { 300.00, 500.00, 2000.00, 1000.00, 1500.00,
                            750.00, 250.00, 4000.00, 800.00,  3000.00,
                            120.00, 600.00, 1200.00, 900.00,  5000.00,
                            450.00, 2500.00, 350.00, 1800.00, 700.00 };

        for (double solde : soldes) {
            Account account = new Account();
            account.setAccountNumber(IbanGenerator.generateValidIban()); // IBAN FR valide
            account.setSolde(solde);
            repo.save(account);
        }
    }
}
Pourquoi @PostConstruct et pas @Bean ? @PostConstruct s'exécute après que Spring a injecté toutes les dépendances (accountRepository est disponible). Un constructeur s'exécuterait avant l'injection, donc accountRepository serait null.

@Transactional Persistance

Une transaction garantit que toutes les opérations réussissent — ou aucune. C'est indispensable pour les virements : débiter sans créditer serait catastrophique.

@Transactional
public Transfer createTransfer(...) {
    // 1. Lire le compte source
    Account source = accountRepository.findById(sourceId).orElseThrow(...);

    // 2. Lire le compte destinataire
    Account dest = accountRepository.findById(destId).orElseThrow(...);

    // 3. Vérifier le solde
    if (source.getSolde() < amount) throw new RuntimeException("Insufficient funds");

    // 4. Débiter / Créditer
    source.setSolde(source.getSolde() - amount);    // ← si exception ici
    dest.setSolde(dest.getSolde() + amount);         // ← ces deux sont annulées
    accountRepository.save(source);
    accountRepository.save(dest);

    // 5. Enregistrer le virement
    return transferRepository.save(transfer);
    // Si tout réussit → COMMIT automatique
    // Si exception → ROLLBACK automatique
}
Sans @Transactional, si le serveur plante après le débit mais avant le crédit, l'argent disparaît. Avec @Transactional, tout est annulé en cas d'erreur.

Optional<T> Persistance

findById() retourne un Optional<T> car la ressource peut ne pas exister. Optional force à gérer ce cas explicitement.

// Méthode 1 : orElseThrow — lève une exception si absent
Account account = accountRepository.findById("123456789")
    .orElseThrow(() -> new RuntimeException("Account not found"));

// Méthode 2 : isPresent / get
Optional<Transfer> opt = transferRepository.findById(42L);
if (opt.isPresent()) {
    Transfer t = opt.get();
}

// Méthode 3 : orElse — valeur par défaut
Transfer t = transferRepository.findById(42L).orElse(null);

// Méthode 4 : ifPresent — lambda si présent
transferRepository.findById(42L).ifPresent(tr -> System.out.println(tr));

// Méthode 5 : isEmpty (Java 11+) — inverse de isPresent
if (opt.isEmpty()) { throw new RuntimeException("Not found"); }
Piège du bug dans TransferService.deleteTransfer() :
transferRepository.deleteById(id);  // ← supprime AVANT de vérifier
if (opt.isEmpty()) throw new DeleteTransferException(...);
Si id n'existe pas, deleteById() ne fait rien (silencieux), puis on lève l'exception — mais sans rollback, c'est incohérent. La correction (Mission 3) : vérifier avant de supprimer.

Basic Auth — Spring Security Sécurité

Spring Security protège tous les endpoints par défaut. CGB utilise d'abord HTTP Basic Authentication.

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // Désactive CSRF (APIs stateless n'en ont pas besoin)
            .csrf(csrf -> csrf.disable())

            // Toutes les requêtes doivent être authentifiées
            .authorizeHttpRequests(req -> req.anyRequest().authenticated())

            // Active HTTP Basic (popup navigateur / header Authorization: Basic ...)
            .httpBasic(Customizer.withDefaults())

            // Autorise la console H2 (qui utilise des iframes)
            .headers(h -> h.frameOptions(f -> f.sameOrigin()));

        return http.build();
    }
}
Basic Auth : le client envoie Authorization: Basic base64(login:password) dans chaque requête. Simple mais il faut toujours HTTPS car base64 n'est pas du chiffrement.
Exercice 5

Que se passe-t-il si on envoie une requête à /api/transfers sans le header Authorization ? Quel code HTTP reçoit-on ? Pourquoi la console H2 nécessite-t-elle frameOptions.sameOrigin() ?

IBAN & Algorithme CRC Mission 2

Un IBAN français a 27 caractères : FR + 2 chiffres de contrôle + 23 caractères BBAN.

// Format :  FR  76  30006  00011  00100000021  00
//           ^^  ^^  ─────────── BBAN (23) ──────
//           pays  CRC

Algorithme de vérification (modulo 97) — technique du cours

// Dans CGBIbanValidator.isIbanValide() — même logique que IbanGenerator
public boolean isIbanValide(String iban) throws ExceptionInvalideIBAN {
    isIbanStructureValide(iban); // lève InvalidIbanFormatException si format KO

    // Étape 1 : déplacer les 4 premiers caractères à la fin
    String rearranged = iban.substring(4) + iban.substring(0, 4);

    // Étape 2 : convertir les lettres en chiffres (A=10, B=11... Z=35)
    StringBuilder numeric = new StringBuilder();
    for (char c : rearranged.toCharArray()) {
        if (Character.isLetter(c)) {
            numeric.append(c - 'A' + 10); // F=15, R=27...
        } else {
            numeric.append(c);
        }
    }

    // Étape 3 : modulo 97 par accumulation chiffre par chiffre
    // Évite l'overflow sans BigInteger : on n'accumule jamais plus de 2 chiffres à la fois
    long remainder = 0;
    for (char c : numeric.toString().toCharArray()) {
        remainder = (remainder * 10 + Character.getNumericValue(c)) % 97;
    }

    if (remainder != 1) {
        throw new InvalidUnCheckableIbanException("CRC invalide pour l'IBAN : " + iban);
    }
    return true;
}
Pourquoi long et pas BigInteger ? En calculant le reste à chaque chiffre (remainder = (remainder × 10 + chiffre) % 97), on ne dépasse jamais la capacité d'un long : la valeur intermédiaire est toujours entre 0 et 96×10+9 = 969. Pas besoin d'arithmétique sur grands entiers.
Exercice 6

Quel est l'avantage de la technique d'accumulation chiffre par chiffre comparée à new BigInteger(numeric).mod(BigInteger.valueOf(97)) ?

Pattern Singleton Mission 2

Le Singleton garantit qu'une classe n'a qu'une seule instance pendant toute l'exécution. Utilisé pour CGBIbanValidator.

public class CGBIbanValidator {

    // 1. Instance unique — static, private
    private static CGBIbanValidator instance;

    // 2. Constructeur privé — interdit le new depuis l'extérieur
    private CGBIbanValidator() {}

    // 3. Méthode d'accès statique — nom imposé par le cahier des charges
    public static synchronized CGBIbanValidator getInstanceValidator() {
        if (instance == null) {
            instance = new CGBIbanValidator();
        }
        return instance;
    }

    // 4a. Vérifie uniquement le format (FR + 25 chiffres) — lève InvalidIbanFormatException
    public boolean isIbanStructureValide(String iban) throws InvalidIbanFormatException {
        if (iban == null || !iban.matches("^FR\\d{25}$")) {
            throw new InvalidIbanFormatException("Format IBAN invalide : " + iban);
        }
        return true;
    }

    // 4b. Vérifie structure + CRC — lève InvalidUnCheckableIbanException si CRC KO
    public boolean isIbanValide(String iban) throws ExceptionInvalideIBAN { ... }
}

// Utilisation :
CGBIbanValidator v = CGBIbanValidator.getInstanceValidator();
v.isIbanValide(IbanGenerator.generateValidIban()); // toujours true
synchronized est nécessaire en environnement multi-thread (comme Spring) : sans lui, deux threads pourraient créer deux instances simultanément si instance est null au même moment.

Exceptions métier Mission 3

CGB utilise deux hiérarchies d'exceptions pour distinguer les erreurs métier des erreurs techniques.

Hiérarchie 1 — Virements

// TransferException est CONCRÈTE (pas abstract) — on peut la lever directement
public class TransferException extends Exception {
    public TransferException(String message) { super(message); }
}

// Sous-classe pour la suppression, avec un enum des causes
public class DeleteTransferException extends TransferException {
    public enum FailureTransfert { OBJECT_NOT_FOUND, REMOVAL_FAILURE }
    public DeleteTransferException(FailureTransfert ft) { super(ft.name()); }
}

// Utilisations dans TransferService :
throw new TransferException("Solde insuffisant sur le compte " + sourceId);
throw new DeleteTransferException(FailureTransfert.OBJECT_NOT_FOUND);

Hiérarchie 2 — Validation IBAN (Mission 2)

// Classe abstraite parente — non instanciable directement
public abstract class ExceptionInvalideIBAN extends Exception {
    public ExceptionInvalideIBAN(String message) { super(message); }
}

// Format structurel invalide (mauvaise longueur, pas FR, lettres dans BBAN...)
public class InvalidIbanFormatException extends ExceptionInvalideIBAN { ... }

// Structure valide mais CRC modulo-97 incorrect
public class InvalidUnCheckableIbanException extends ExceptionInvalideIBAN { ... }
isIbanStructureValide() ne peut lever que InvalidIbanFormatException.
isIbanValide() déclare throws ExceptionInvalideIBAN pour couvrir les deux sous-types.
Exercice 7 — Corriger le bug de deleteTransfer()

Voici le code bugué de TransferService.deleteTransfer(). Identifie le problème et propose la correction.

public Transfer deleteTransfer(Long id) throws DeleteTransferException {
    Optional<Transfer> oTransfer = transferRepository.findById(id);
    transferRepository.deleteById(id);  // BUG
    if (oTransfer.isEmpty()) {
        throw new DeleteTransferException(FailureTransfert.OBJECT_NOT_FOUND);
    }
    return oTransfer.orElse(null);
}

États d'un virement Mission 4

Les lots et virements CGB ont chacun leur cycle de vie, défini par des enums imbriquées dans les entités.

// Entité Lot — cycle : RECU → CLOS
@Entity
public class Lot {
    @Enumerated(EnumType.STRING)
    private EtatLot etat;

    public enum EtatLot {
        RECU,   // lot reçu, traitement asynchrone en attente
        CLOS    // tous les virements ont un état final
    }
}

// Entité Virement — cycle : EN_ATTENTE → SUCCES / ECHEC / RETARDE / ANNULE
@Entity
public class Virement {
    @Enumerated(EnumType.STRING)   // stocké "EN_ATTENTE", "SUCCES"...
    private EtatVirement etat = EtatVirement.EN_ATTENTE;

    public enum EtatVirement {
        EN_ATTENTE, // en file d'attente
        SUCCES,     // virement exécuté
        ECHEC,      // solde insuffisant, compte introuvable…
        RETARDE,    // traitement différé
        ANNULE      // annulé manuellement
    }
}
@Enumerated(EnumType.STRING) stocke la valeur textuelle ("WAITING") plutôt que l'index numérique (0). C'est recommandé : si on réordonne l'enum, les données BDD restent cohérentes.

@Async & Traitement en lots Mission 4

Pour traiter des milliers de virements sans bloquer le thread HTTP, CGB utilise l'exécution asynchrone.

// 1. Activer l'async sur la classe principale
@SpringBootApplication
@EnableAsync
public class ServerTransferApp { ... }

// 2. Service : soumettreLot() persiste et délègue à traiterLot() en async
@Service
public class LotService {

    @Transactional
    public Lot soumettreLot(LotRequest lotRequest) {
        Lot lot = new Lot();
        lot.setEtat(EtatLot.RECU);
        // ... remplir virements avec état EN_ATTENTE
        Lot lotSauvegarde = lotRepository.save(lot);
        traiterLot(lotSauvegarde); // appel async — retour immédiat
        return lotSauvegarde;
    }

    @Async
    @Transactional
    public void traiterLot(Lot lot) {
        for (Virement v : lot.getVirements()) {
            // vérifier compte dest, solde suffisant...
            v.setEtat(EtatVirement.SUCCES); // ou ECHEC
        }
        lot.setEtat(EtatLot.CLOS);
        lotRepository.save(lot);
    }
}

// 3. Controller — POST /send/lot/ répond 202 immédiatement
@PostMapping("/")
public ResponseEntity<Lot> soumettreLot(@RequestBody LotRequest req) {
    Lot lot = lotService.soumettreLot(req);
    return ResponseEntity.status(HttpStatus.ACCEPTED).body(lot); // HTTP 202
}
Code HTTPSignification
200 OKTraitement terminé, résultat inclus
202 AcceptedRequête acceptée, traitement en cours
204 No ContentSuccès, aucun contenu à retourner
Exercice 8

Quelle est la différence entre une méthode @Async qui retourne void et une qui retourne CompletableFuture<T> ? Dans quel cas préfères-tu l'un ou l'autre ?

JUnit 5 & MockMvc Tests

CGB utilise deux niveaux de test selon le pattern du cours :

TypeAnnotationSécuritéBDD
Unitaire (Controller)@WebMvcTestdésactivée (addFilters=false)mock
Intégration@SpringBootTest@WithMockUserH2 mémoire
Unitaire (Service)@ExtendWith(MockitoExtension)mock

Test unitaire Controller — @WebMvcTest

@WebMvcTest(TransferRestController.class)  // charge UNIQUEMENT la couche web
@AutoConfigureMockMvc(addFilters = false)    // désactive Spring Security
public class TransferControllerUnitTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean                        // remplace le vrai service par un mock Mockito
    private TransferService transferService;

    @Test
    public void shouldReturnTransferWhenCreateTransferSucceeds() throws Exception {
        Transfer transfer = new Transfer();
        transfer.setId(1L);
        transfer.setAmount(100.0);

        when(transferService.createTransfer(any(), any(), anyDouble(), any(), any()))
            .thenReturn(transfer);

        mockMvc.perform(post("/api/transfers")
                .contentType(MediaType.APPLICATION_JSON)
                .content(asJsonString(transfer)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1L));
    }

    @Test
    public void shouldReturnBadRequestWhenCreateTransferFails() throws Exception {
        when(transferService.createTransfer(any(), any(), anyDouble(), any(), any()))
            .thenThrow(new TransferException("Solde insuffisant."));

        mockMvc.perform(post("/api/transfers")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{}"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.status").value("FAILURE"));
    }
}

Test d'intégration — @SpringBootTest

@SpringBootTest           // charge tout le contexte Spring
@AutoConfigureMockMvc
@WithMockUser(username = "user")  // simule un utilisateur authentifié
public class TransferControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void createTransferTest_Success() throws Exception {
        mockMvc.perform(post("/api/transfers")
                .contentType(MediaType.APPLICATION_JSON)
                .content(asJsonString(buildTransferRequest())))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").exists());
    }
}

Test unitaire Service/Utilitaire — @ExtendWith(MockitoExtension.class)

@ExtendWith(MockitoExtension.class)   // Mockito sans Spring, ultra rapide
public class CGBIbanValidatorTest {

    @BeforeEach
    void setUp() {
        validator = CGBIbanValidator.getInstanceValidator();
    }

    @Test
    public void shouldReturnSameInstanceWhenCalledMultipleTimes() {
        assertSame(CGBIbanValidator.getInstanceValidator(),
                   CGBIbanValidator.getInstanceValidator());
    }

    @Test
    public void shouldThrowInvalidIbanFormatExceptionWhenIbanIsNull() {
        assertThrows(InvalidIbanFormatException.class,
                () -> validator.isIbanStructureValide(null));
    }

    @Test
    public void shouldThrowInvalidUnCheckableIbanExceptionWhenCrcIsWrong() {
        // FR + "00" (faux CRC) + 23 chiffres — structure OK, CRC KO
        assertThrows(InvalidUnCheckableIbanException.class,
                () -> validator.isIbanValide("FR00" + "12345678901234567890123"));
    }
}

application.properties pour les tests

# src/test/resources/application.properties — H2 en mémoire, pas de fichier
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
spring.jpa.hibernate.ddl-auto=create-drop
spring.sql.init.mode=never
Exercice 9 — Écrire un test de suppression

Écris un test @WebMvcTest qui vérifie que supprimer un virement avec un ID inexistant retourne HTTP 400 avec status: "FAILURE".

JWT — JSON Web Token Mission 8

JWT remplace Basic Auth pour une authentification stateless (sans session serveur). Le token est signé cryptographiquement avec HMAC-SHA256.

Structure d'un JWT

// Format : header.payload.signature (séparés par des points)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← header (base64url)
.eyJzdWIiOiJ1c2VyIiwiZXhwIjoxNzA5MDAwfQ  ← payload (base64url)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ  ← signature HMAC-SHA256

// Header décodé :
{ "alg": "HS256", "typ": "JWT" }

// Payload (claims) décodé :
{ "sub": "comptable", "iat": 1745000000, "exp": 1745036000 }

JwtService CGB — implémentation JJWT 0.11.5

// pom.xml — 3 artefacts jjwt obligatoires
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.11.5</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.11.5</version><scope>runtime</scope></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>0.11.5</version><scope>runtime</scope></dependency>

@Service
public class JwtService {

    @Value("${jwt.secret}")  // clé Base64 dans application.properties
    private String secret;

    private SecretKey secretKey;
    private JwtParser jwtParser;

    @PostConstruct  // s'exécute après injection des @Value
    public void init() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.secretKey = Keys.hmacShaKeyFor(keyBytes);
        this.jwtParser = Jwts.parserBuilder()   // API JJWT 0.11.x
                             .setSigningKey(secretKey)
                             .build();
    }

    public String generateToken(UserDetails userDetails) {
        return Jwts.builder()
                .setClaims(new HashMap<>())
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10)) // 10h
                .signWith(secretKey, SignatureAlgorithm.HS256)  // clé d'abord, algo ensuite
                .compact();
    }

    public String extractUsername(String token) {
        return jwtParser.parseClaimsJws(token).getBody().getSubject();
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        String username = extractUsername(token);
        Date expiration = jwtParser.parseClaimsJws(token).getBody().getExpiration();
        return username.equals(userDetails.getUsername()) && !expiration.before(new Date());
    }
}
Différence JJWT 0.10.x → 0.11.x : Jwts.parser() est déprécié. Il faut utiliser Jwts.parserBuilder().setSigningKey(...).build(). Et dans signWith() : la clé passe en premier, l'algorithme en second.
Exercice 10

Quelle est la différence entre Basic Auth et JWT pour une API REST ? Dans quel cas JWT est-il préférable ?

@PreAuthorize & Rôles Sécurité

// Activer les annotations de sécurité sur les méthodes
@Configuration
@EnableMethodSecurity
public class SecurityConfig { ... }

// Restreindre un endpoint à un rôle
@GetMapping("/admin/stats")
@PreAuthorize("hasRole('ADMIN')")
public StatsResponse getStats() { ... }

// Plusieurs rôles autorisés
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")

// Uniquement l'utilisateur lui-même
@PreAuthorize("#username == authentication.name")
public UserProfile getProfile(String username) { ... }
Conventions Spring Security : les rôles sont stockés avec le préfixe ROLE_ en BDD (ROLE_ADMIN, ROLE_USER). Dans hasRole('ADMIN'), Spring ajoute le préfixe automatiquement. Dans hasAuthority('ROLE_ADMIN'), il faut l'écrire en entier.

Mission 1 — Étude de l'API existante Mission

📋 Mission 1

Objectif : comprendre et tester l'API CGB existante avec Postman ou curl.

Endpoints disponibles

MéthodeURLDescription
GET/api/transfersListe tous les virements
POST/api/transfersCréer un virement
DELETE/api/transfersSupprimer un virement (id dans le body)

Tester avec curl

# Lister les virements (Basic Auth : user / password)
curl -u user:password http://localhost:8080/api/transfers

# Créer un virement
curl -u user:password -X POST http://localhost:8080/api/transfers \
  -H "Content-Type: application/json" \
  -d '{"sourceAccountNumber":"234567891","destinationAccountNumber":"123456789","amount":50.0,"transferDate":"2024-01-15"}'

# Supprimer le virement id=1
curl -u user:password -X DELETE http://localhost:8080/api/transfers \
  -H "Content-Type: application/json" \
  -d '1'
Exercice 11

Lance l'application CGB (mvn spring-boot:run) et teste le POST avec un montant supérieur au solde du compte. Quel message d'erreur obtiens-tu ? Quel code HTTP ?

Mission 2 — Validation IBAN Mission

🏦 Mission 2

Implémenter la validation IBAN dans le service avant de créer un virement.

Exercice 12

Ajoute la validation IBAN dans TransferService.createTransfer() en utilisant CGBIbanValidator.getInstance(). Que faire si l'IBAN est invalide ?

Mission 3 — Correction des bugs Mission

🐛 Mission 3

Identifier et corriger les bugs dans TransferService.

Bug 1 — deleteTransfer (cf. Exercice 7)

deleteById() appelé avant la vérification d'existence. Correction : vérifier d'abord avec findById().

Bug 2 — Solde stocké en Double

Utiliser Double pour de l'argent cause des erreurs d'arrondi (virgule flottante IEEE 754). Exemple : 0.1 + 0.2 = 0.30000000000000004.

Exercice 13

Quel type Java utiliser à la place de Double pour stocker un montant d'argent ? Pourquoi Double est-il dangereux ?

Mission 4 — Traitement asynchrone en lots Mission

⚡ Mission 4

Créer un endpoint POST /api/transfers/batch qui accepte une liste de virements et les traite en arrière-plan.

// Nouveau endpoint :
@PostMapping("/batch")
public ResponseEntity<String> createBatch(@RequestBody List<TransferRequest> requests) {
    batchService.processLot(requests);   // @Async — retour immédiat
    return ResponseEntity
        .status(HttpStatus.ACCEPTED)    // HTTP 202
        .body("Lot de " + requests.size() + " virements en cours de traitement");
}

Mission 5 — Notifications Mission

🔔 Mission 5

Envoyer une notification asynchrone après chaque virement (email, log, webhook…).

@Service
public class NotificationService {

    @Async
    public void notifyTransferCreated(Transfer transfer) {
        // Simulation d'envoi d'email / webhook
        System.out.println("[NOTIF] Virement créé : ID="
            + transfer.getId()
            + " montant=" + transfer.getAmount());
    }
}

// Dans TransferService.createTransfer() :
Transfer saved = transferRepository.save(transfer);
notificationService.notifyTransferCreated(saved);   // async, non bloquant
return saved;

Mission 6 — Rôles et entités Customer / UserCGB Mission

💳 Mission 6

Ajouter les entités Role, Customer et UserCGB dans le package cgb.transfer.security.entity, plus leurs repositories, en suivant exactement le patron du cours CoursJava24.

Enum Role

package cgb.transfer.security.entity;

public enum Role {
    USER,       // utilisateur standard
    COMPTABLE,  // accès aux fonctions comptables
    ADMIN       // accès complet
}

Entité Customer (client bancaire)

@Entity
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @OneToMany(mappedBy = "customer")  // "customer" = champ dans UserCGB
    private Set<UserCGB> users;

    // getters / setters
}

Entité UserCGB (utilisateur de l'application)

@Entity
@Table(name = "userappli")  // évite le mot réservé "user" en SQL
public class UserCGB {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;
    private String password;  // stocké haché (BCrypt)

    @Enumerated(EnumType.STRING)  // stocke le nom (ex: "COMPTABLE"), pas l'ordinal
    private Role role;

    @ManyToOne
    @JoinColumn(name = "customer_id")
    @JsonIgnore  // évite les boucles infines lors de la sérialisation JSON
    private Customer customer;

    // getters / setters
}

Repositories

// UserCGBRepository — findByUsername() pour l'authentification
public interface UserCGBRepository extends JpaRepository<UserCGB, Long> {
    Optional<UserCGB> findByUsername(String username);
}

// CustomerRepository — standard, sans méthode custom
public interface CustomerRepository extends JpaRepository<Customer, Long> { }

Initialisation des données (DatabaseInitializer enrichi)

// Création de 2 clients + 3 utilisateurs au démarrage
private void insertSampleUsersAndCustomers() {
    Customer client1 = new Customer(); client1.setName("Dupont SA");
    customerRepository.save(client1);

    UserCGB comptable = new UserCGB();
    comptable.setUsername("comptable");
    comptable.setPassword("comptable123");  // haché par registerUser()
    comptable.setRole(Role.COMPTABLE);
    comptable.setCustomer(client1);
    userDetailsService.registerUser(comptable);  // encode le mot de passe
}
Pourquoi @Table(name = "userappli") ? Le mot USER est un mot réservé en SQL (H2, MySQL…). Sans ce renommage, Hibernate génère une table USER qui provoque une erreur SQL au démarrage.
Exercice 14

Quelle annotation JPA utilise-t-on pour une relation "un client possède plusieurs utilisateurs" ? Et pour la relation inverse "un utilisateur appartient à un client" ?

Mission 7 — Rejeu des transactions en échec Mission

🔄 Mission 7

Rejouer un nouveau lot à partir des transactions delayed (RETARDE) d'un lot existant.

Un virement est RETARDE quand il n'a pas pu être exécuté faute de fonds suffisants au moment du traitement. L'endpoint génère un nouveau lot prêt à être soumis, dont la description reprend l'ancienne préfixée par "REJEU — ".

Spec 1.7 (extrait) : seul le premier point est implémenté ici.
// POST /send/lot/{id}/rejeu  →  crée et soumet un nouveau lot depuis les RETARDE du lot {id}
@PostMapping("/{id}/rejeu")
public ResponseEntity<?> rejeuLot(@PathVariable Long id) {
    Lot lotOriginal = lotRepository.findById(id)
        .orElseThrow(() -> new RuntimeException("Lot introuvable : " + id));

    // Filtrer uniquement les virements RETARDE
    List<Virement> delayed = lotOriginal.getVirements().stream()
        .filter(v -> v.getEtat() == EtatVirement.RETARDE)
        .toList();

    if (delayed.isEmpty()) {
        return ResponseEntity.badRequest().body("Aucun virement RETARDE dans ce lot.");
    }

    // Construire un nouveau LotRequest à partir des virements delayed
    LotRequest rejeu = new LotRequest();
    rejeu.setSourceAccount(lotOriginal.getSourceAccount());
    rejeu.setDescriptionLot("REJEU — " + lotOriginal.getDescriptionLot());
    rejeu.setRefLot("REJEU-" + lotOriginal.getRefLot());
    rejeu.setVirements(delayed.stream().map(v -> {
        VirementRequest vr = new VirementRequest();
        vr.setDestAccount(v.getDestAccount());
        vr.setAmount(v.getAmount());
        vr.setDescription(v.getDescription());
        return vr;
    }).toList());

    Lot nouveauLot = lotService.soumettreLot(rejeu);
    return ResponseEntity.status(HttpStatus.ACCEPTED).body(nouveauLot);
}

Mission 8 — Authentification JWT Mission

🔐 Mission 8

Remplacer Basic Auth par JWT. L'utilisateur appelle POST /login et reçoit un token à envoyer dans le header Authorization: Bearer ... de chaque requête.

Flux complet

// 1. Login → reçoit le token (string brut)
POST /login
Body: { "username": "comptable", "password": "comptable123" }
← 200 OK : eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjb21wdGFibGUi...

// 2. Toutes les requêtes suivantes portent le token
POST /api/transfers
Header: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
← 200 OK : { "id": 1, ... }

AuthControllerJeton — endpoint de login

@RestController
public class AuthControllerJeton {

    @Autowired private AuthenticationManager authenticationManager;
    @Autowired private MyUserDetailsService userDetailsService;
    @Autowired private JwtService jwtUtil;

    @PostMapping("/login")
    public String login(@RequestBody AuthenticationRequest req) throws Exception {
        // 1. Vérifie username + password via AuthenticationManager
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword())
        );
        // 2. Charge les UserDetails (username + authorities)
        UserDetails userDetails = userDetailsService.loadUserByUsername(req.getUsername());
        // 3. Génère et retourne le token JWT
        return jwtUtil.generateToken(userDetails);
    }
}

JwtAuthenticationFilter — vérifie le token sur chaque requête

// Non @Component — instancié manuellement dans SecurityConfig (évite la circularité)
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private JwtService jwtService;
    private MyUserDetailsService userDetailsService;

    // Setters injectés depuis SecurityConfig
    public void setJwtService(JwtService s) { this.jwtService = s; }
    public void setUserDetailsService(MyUserDetailsService s) { this.userDetailsService = s; }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String jwt = authHeader.substring(7);
            String username = jwtService.extractUsername(jwt);

            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                if (jwtService.isTokenValid(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        }
        filterChain.doFilter(request, response);  // passe au filtre suivant dans la chaîne
    }
}

SecurityConfig — remplace httpBasic() par le filtre JWT

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // active @PreAuthorize sur les méthodes
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
            JwtService jwtService, MyUserDetailsService uds) throws Exception {

        // Instanciation manuelle pour éviter la dépendance circulaire
        JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter();
        jwtFilter.setJwtService(jwtService);
        jwtFilter.setUserDetailsService(uds);

        http.csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/console/**").permitAll()  // console H2 libre
                .requestMatchers("/login/**").permitAll()   // login sans token
                .anyRequest().authenticated())
            .formLogin(form -> form.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(
                SessionCreationPolicy.STATELESS))  // JWT = pas de session
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .headers(h -> h.frameOptions(f -> f.sameOrigin()));
        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception {
        return cfg.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

MyUserDetailsService — pont entre UserCGB et Spring Security

@Service
public class MyUserDetailsService implements UserDetailsService {

    private final UserCGBRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Autowired
    public MyUserDetailsService(UserCGBRepository repo, PasswordEncoder enc) {
        this.userRepository = repo;
        this.passwordEncoder = enc;
    }

    @Override
    public UserDetails loadUserByUsername(String username) {
        UserCGB user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        // Spring exige le préfixe ROLE_ pour que hasRole('COMPTABLE') fonctionne
        List<GrantedAuthority> authorities = List.of(
            new SimpleGrantedAuthority("ROLE_" + user.getRole().name())
        );

        return new org.springframework.security.core.userdetails.User(
            user.getUsername(), user.getPassword(), authorities
        );
    }

    public UserCGB registerUser(UserCGB user) {
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        return userRepository.save(user);
    }
}
Utilisateurs de test créés au démarrage :
UsernamePasswordRôleClient
comptablecomptable123COMPTABLEDupont SA
user1user123USERDupont SA
adminadmin123ADMINMartin SARL
Exercice 15

Pourquoi le JwtAuthenticationFilter est-il instancié manuellement dans SecurityConfig et non déclaré @Component ?