1. Quand la sécurité des méthodes est nécessaire
La frontière HTTP nous a déjà montré qui reçoit un 401, qui reçoit un 403, et où apparaît un refus basé sur le propriétaire. Mais toutes les règles d'accès ne vivent pas sur les URL : certaines se trouvent directement sur les opérations métier.
Si l'on regarde l'application avec les yeux d'un débutant (et parfois même avec les yeux d'un développeur fatigué le vendredi soir), il semble logique : « Eh bien, puisque toutes les requêtes passent par les contrôleurs, et que Spring Security est là à l'entrée, alors à l'intérieur tout est sécurisé. » Ce raisonnement ressemble à l'idée que « s'il y a un agent de sécurité à l'entrée du bureau, alors le coffre peut rester ouvert ». Parfois c'est vrai… jusqu'à ce que la réalité rattrape.
Dans une application Spring Boot, la couche service n'est pas « l'intérieur que personne ne touche à part le contrôleur ». Avec le temps, de nouvelles entrées apparaissent pour le même cas d'utilisation : un autre contrôleur, une interface d'administration, une opération batch, un endpoint de commande pour le support, un gestionnaire d'événements, parfois même un simple appel interne « temporaire » depuis un autre service. Si la règle d'accès ne vit que sur la frontière web, vous risquez d'obtenir une situation où une entrée est protégée, et une autre (par hasard) ne l'est pas.
C'est là que la sécurité des méthodes prend son sens : vous placez la règle non pas sur la « porte du bâtiment », mais sur la « porte de la pièce ». Ainsi, peu importe qui vous a amené dans cette pièce — un contrôleur, un planificateur ou un collègue enthousiaste qui « voulait juste réutiliser le service ».
Une autre raison est la discipline architecturale. Les contrôleurs dans ContentHub (comme dans la plupart des services MVC normaux) sont conçus pour être fins : ils traduisent les appels HTTP en appels de service, le service effectue l'opération métier. Si la règle d'accès fait partie de l'opération métier (par exemple, approuver/rejeter est précisément une action administrative), il est logique de la garder près de cette opération, plutôt que de la disperser sur plusieurs contrôleurs.
Et une remarque importante : la sécurité des méthodes ne signifie pas « dupliquons toutes les vérifications deux fois partout ». Si vous commencez à tout dupliquer à la fois au niveau des endpoints et au niveau des méthodes, vous obtiendrez des tests qui vérifient la même chose, mais à deux endroits différents. Une telle suite sera verte, mais pas forcément utile. Nous vivons toujours selon la règle du test minimalement suffisant.
2. Comment fonctionne la sécurité des méthodes dans Spring
Avant d'écrire des tests, il est utile de comprendre la mécanique au niveau « ce qui se passe réellement ». La sécurité des méthodes dans Spring n'est pas une « magie à l'intérieur de la méthode ». C'est une enveloppe autour du bean, généralement via un proxy. Vous autowirez un service — et en réalité vous obtenez un objet proxy. Lorsque vous appelez la méthode, le proxy demande d'abord : « cet utilisateur a-t-il le droit d'entrer ? », et seulement ensuite (si c'est permis) il appelle l'implémentation réelle.
Schématiquement, on peut le représenter ainsi :
flowchart TD
Caller["Code (contrôleur/autre service/test)"] --> Proxy["Proxy Spring (sécurité des méthodes)"]
Proxy --> Decision["Vérification @PreAuthorize / @PostAuthorize"]
Decision -->|autorisé| Target["ArticleWorkflowService réel"]
Decision -->|refusé| Denied["Exception : AccessDeniedException"]
Target --> Repo["Repository / autres dépendances"]
La conclusion pratique clé de ce schéma est très terre-à-terre : si vous créez le service manuellement avec new ArticleWorkflowService(...), vous contournerez le proxy. Par conséquent, aucun @PreAuthorize ne fonctionnera, le test sera « vert », et vous serez heureux jusqu'au premier incident de sécurité. C'est pourquoi la sécurité des méthodes se teste uniquement sur un bean géré par Spring.
Ensuite, parlons de ce qui « se passe » exactement lors d'un refus. Sur la couche web, nous sommes habitués aux statuts 401 et 403. Sur la couche service, il n'y a pas de statuts HTTP, il y a des exceptions. Et il est utile de se mettre d'accord à l'avance : dans un test de sécurité des méthodes, vous vérifiez le plus souvent le type d'exception, pas le texte du message.
On rencontre généralement deux situations principales :
| Contexte | Ce qu'il y a dans le SecurityContext | Ce qui se passe généralement avec @PreAuthorize |
|---|---|---|
| Utilisateur non défini du tout | Authentication absente | souvent une AuthenticationCredentialsNotFoundException |
| Utilisateur défini mais droits insuffisants | Authentication présente (anonymous/editor/…) | une AccessDeniedException |
Pour éviter les surprises, il est préférable de rendre l'acteur explicite dans les tests : soit avec @WithMockUser, soit avec @WithAnonymousUser. Ainsi vous contrôlez vous-même quelle branche vous testez, sans compter sur « l'authentification que Spring fournira par défaut ».
3. Choix du niveau de vérification de la sécurité
Après les premières leçons du jour, vous avez peut-être eu l'impression : « OK, nous vérifions les 401/403 avec MockMvc — donc la sécurité est testée ». C'est vrai, mais seulement pour la partie des règles qui vit réellement sur la frontière HTTP : dans SecurityFilterChain, dans les règles sur les URL, dans la configuration de l'authentification de base, dans les filtres et dans les gestionnaires d'échec.
La sécurité des méthodes répond à une autre question. Si on le formule directement, on obtient une paire très simple :
Le test de sécurité de la couche web répond à la question : « cet acteur peut-il accéder à l'endpoint et obtenir une réponse ? »
Le test de sécurité des méthodes répond à la question : « cet acteur peut-il invoquer une opération métier, même s'il est déjà « à l'intérieur » de l'application ? »
Pour ne pas dupliquer les tests inutilement, il est pratique de garder en tête ce tableau de décision :
| Si votre vérification… | Où vit-elle dans le code | Où la tester |
|---|---|---|
| « /api/admin/** accessible uniquement à ADMIN » | SecurityFilterChain (request matcher) | Test MVC/intégration via HTTP (MockMvc, RestTestClient) |
| « approuver est une opération admin » | @PreAuthorize sur ArticleWorkflowService.approve(...) | Test de sécurité des méthodes par appel direct du service |
| « l'éditeur ne peut modifier que son article » | souvent @PreAuthorize + vérification de propriété (via bean/SpEL) ou vérification explicite dans le service | soit par test de sécurité des méthodes (si la règle est dans l'annotation), soit par test unitaire/service (si la règle est dans le code) |
| « si non connecté, reçois un 401 » | frontière web (entry point) | Test de la couche web (car 401 est une sémantique HTTP) |
Il est important de ne pas confondre : les statuts 401 et 403 relèvent de HTTP, et cela a sa place au niveau du contrôleur/intégration. Dans un test de niveau méthode, vous prouvez non pas les statuts, mais le fait d'autoriser ou d'interdire via des exceptions.
4. Exemple de @PreAuthorize dans un service
Pour tester la sécurité des méthodes, le projet doit avoir un endroit où elle est réellement utilisée. Dans ContentHub, cela se présente généralement comme une méthode de service protégée par une annotation @PreAuthorize. Pour l'exemple, prenons « approuver un article » comme opération purement admin.
Voici un fragment simplifié du service :
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class ArticleWorkflowService {
// La règle d'accès vit à côté de l'opération métier, pas seulement au niveau du contrôleur
@PreAuthorize("hasRole('ADMIN')")
public void approve(long articleId) {
// Ici se trouverait la logique métier réelle d'approbation (changement de statut, audit, événements, etc.)
}
}
Et le contrôleur qui transmet simplement l'appel (le contrôleur reste « fin », comme prévu) :
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class AdminArticleController {
private final ArticleWorkflowService articleWorkflowService;
AdminArticleController(ArticleWorkflowService articleWorkflowService) {
// Dans le contrôleur, il n'y a pas de logique métier — seulement de la délégation au service
this.articleWorkflowService = articleWorkflowService;
}
@PostMapping("/api/admin/articles/{id}/approve")
void approve(@PathVariable long id) {
// Le contrôleur se contente d'appeler le service, la vérification des droits se fait au niveau de la méthode
articleWorkflowService.approve(id);
}
}
Le sens de la sécurité des méthodes ici est que la règle « seul ADMIN peut approuver » est attachée à l'opération d'approbation elle-même. Cela réduit le risque que quelqu'un crée un nouvel endpoint, appelle le service par hasard au mauvais endroit et obtienne une « action admin » via le rôle editor. La frontière web est importante, mais la sécurité des méthodes est une couche de sécurité interne.
Et oui, pour que la sécurité des méthodes fonctionne, elle doit être activée. Dans un vrai projet, cela fait généralement partie de la configuration de sécurité globale. Dans l'exemple pédagogique, on peut le représenter ainsi :
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity // Active le traitement de @PreAuthorize/@PostAuthorize via le proxy
class MethodSecurityConfig {
}
Nous n'écrivons pas la configuration from scratch (ce n'est pas la tâche du jour), mais il est important de se rappeler : si @EnableMethodSecurity est oublié, @PreAuthorize deviendra simplement un joli commentaire. Du point de vue de la sécurité, un mauvais commentaire.
5. Test d'interdiction d'accès (mauvais rôle)
Il est agréable de commencer par les cas négatifs : ils sont souvent plus simples. Nous n'avons pas besoin de préparer des données, ni que approve s'exécute réellement ; nous devons prouver que l'acteur incorrect ne peut pas invoquer la méthode. Et ici, la sécurité des méthodes est particulièrement pratique : la vérification a lieu avant le corps de la méthode, donc nous pouvons tester l'interdiction presque sans infrastructure.
Le squelette minimal du test ressemble généralement à ceci : on lève un contexte Spring sans serveur web, on prend un service géré par Spring, on définit l'acteur avec @WithMockUser et on vérifie l'exception.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.test.context.support.WithMockUser;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) // Lève le contexte sans couche web
class ArticleWorkflowServiceMethodSecurityTest {
@Autowired
ArticleWorkflowService articleWorkflowService; // On prend bien un bean géré par Spring (important pour le proxy)
@Test
@WithMockUser(username = "alice", roles = "EDITOR") // On définit explicitement le rôle : on teste l'interdiction pour EDITOR
void editorCannotApprove() {
// La vérification a lieu AVANT l'entrée dans la méthode approve()
assertThatThrownBy(() -> articleWorkflowService.approve(42L))
.isInstanceOf(AccessDeniedException.class);
}
}
Notez deux choses. Premièrement, nous avons explicitement défini « alice en tant que EDITOR ». Cela rend le test lisible : il ne s'agit pas d'« un utilisateur quelconque », mais d'un personnage compréhensible du domaine. Deuxièmement, nous vérifions bien AccessDeniedException. C'est-à-dire que nous disons : « l'utilisateur était présent, mais n'avait pas assez de droits ». C'est l'équivalent sémantique du 403 au niveau de la méthode.
Si vous voulez encore plus confirmer que le corps de la méthode n'a pas été exécuté du tout (donc aucun effet de bord ne s'est produit), vous pouvez compléter le test par une vérification « aucune interaction avec le repository ». Ce n'est pas toujours obligatoire, mais c'est parfois une bonne assurance : la sécurité doit interrompre l'appel avant la logique métier.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.then;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) // Contexte sans couche web
class ArticleWorkflowServiceMethodSecurityNoSideEffectsTest {
@Autowired ArticleWorkflowService articleWorkflowService; // Service géré par Spring sous proxy
@MockitoBean ArticleRepository articleRepository; // On mocke la dépendance pour vérifier l'absence d'effets de bord
@Test
@WithMockUser(roles = "EDITOR") // Rôle qui n'a pas accès à approve
void forbiddenCallDoesNotTouchRepository() {
// L'accès doit être refusé avant toute interaction avec le repository
assertThatThrownBy(() -> articleWorkflowService.approve(42L))
.isInstanceOf(AccessDeniedException.class);
// Si la sécurité a bien fonctionné — le repository n'a pas été touché
then(articleRepository).shouldHaveNoInteractions();
}
}
Ici, nous ne vérifions pas l'« ordre des appels » ni « combien de fois qui a appelé qui », mais une chose sémantique : si l'accès est refusé, l'opération métier ne doit même pas essayer de lire les données.
6. Test d'autorisation d'accès (bon rôle)
Le cas positif semble inoffensif : « admin peut approuver ». Mais il y a un piège. Lorsque l'accès est autorisé, la méthode s'exécute réellement et peut échouer pour des raisons non liées à la sécurité : article introuvable, statut incorrect, politique de transition n'autorise pas l'approbation, champs obligatoires non remplis, base de données indisponible… Au final, le test qui devait vérifier la sécurité se transforme en test du monde entier en même temps.
Une stratégie très simple aide ici : dans un test positif de sécurité des méthodes, nous préparons un état minimal valide spécifiquement pour cette opération. Pas « un article quelconque », mais un article dans un état à partir duquel l'approbation est autorisée. Sinon, la sécurité nous aura déjà laissés passer, mais le test échouera pour une erreur métier, après quoi il est très facile de confondre la cause de l'échec. Pour cela, on utilise souvent @MockitoBean sur les dépendances du service, pour ne pas lever une vraie base de données et ne pas tomber accidentellement dans un test d'intégration.
Un exemple possible (simplifié, l'idée est plus importante que les champs spécifiques de Article) :
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.BDDMockito.given;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) // Ne lève pas de serveur : on teste la sécurité au niveau méthode
class ArticleWorkflowServiceMethodSecurityAllowedTest {
@Autowired ArticleWorkflowService articleWorkflowService; // Important : on obtient le bean depuis le contexte
@MockitoBean ArticleRepository articleRepository; // On mocke l'infrastructure pour que le test ne devienne pas un test d'intégration
@Test
@WithMockUser(roles = "ADMIN") // Rôle auquel l'accès est autorisé
void adminCanApprove() {
// Pas « un article quelconque », mais une fixture minimalement valide pour le scénario d'approbation
Article article = TestArticles.approvableArticle();
given(articleRepository.findById(42L)).willReturn(Optional.of(article));
// Ici nous vérifions une seule chose : la sécurité ne bloque pas ADMIN sous des préconditions métier valides
assertThatCode(() -> articleWorkflowService.approve(42L))
.doesNotThrowAnyException();
}
}
Si votre approve(), avec un accès valide, peut encore lancer une exception métier, formulez l'attente selon votre modèle. Ici, c'est la frontière qui nous importe : avec un rôle correct et une fixture valide, le test ne doit pas échouer au niveau de la sécurité.
Si vous remarquez que pour que approve ne « tombe pas », vous devez mocker la moitié du projet, c'est un signal. Soit vous essayez de rendre le test trop « positif », et il vaut mieux prouver l'accès d'une autre manière (par exemple, attendre une exception métier mais pas un accès refusé), soit la méthode est trop complexe et vous devez choisir un point de vérification plus étroit. Un test de sécurité des méthodes doit être petit, sinon il perd son sens.
7. Règle basée sur le propriétaire dans @PreAuthorize
Les règles basées sur le propriétaire sont la source de confusion la plus fréquente, car le rôle EDITOR ressemble à un « laissez-passer pour la zone éditeur », mais ne répond pas à la question « est-ce ton article ou celui d'un autre ? ». Parfois, une telle règle est implémentée dans le corps du service, parfois directement dans @PreAuthorize via un appel à un composant spécial (conditionnellement ArticleAccess).
Un exemple simplifié ressemble à ceci :
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
class EditorArticleService {
// On vérifie à la fois le rôle et la « propriété » de l'article via un composant séparé (bean)
@PreAuthorize("hasRole('EDITOR') and @articleAccess.isOwner(#articleId, authentication)")
public void updateDraft(long articleId) {
// Ici se trouverait la logique métier de mise à jour du brouillon
}
}
Et le composant qui décide si l'utilisateur est propriétaire ou non :
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
@Component
class ArticleAccess {
boolean isOwner(long articleId, Authentication authentication) {
// En réalité, il y a généralement une requête au repository et une comparaison ownerId/username
return false;
}
}
Comment tester cela ? Dans le test de sécurité des méthodes, nous voulons prouver que la combinaison « rôle + propriétaire » fonctionne réellement. En même temps, nous ne voulons pas toujours lever une vraie base de données juste pour la vérification de propriété. Dans une approche pédagogique, on peut mocker ArticleAccess et contrôler sa réponse : dans un cas, il dit « non propriétaire », et l'appel doit être refusé ; dans l'autre, « propriétaire », et la méthode doit passer la sécurité.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) // Contexte sans couche web
class EditorArticleServiceOwnerMethodSecurityTest {
@Autowired EditorArticleService editorArticleService; // On appelle directement la méthode (couche service)
@MockitoBean ArticleAccess articleAccess; // On mocke la vérification de propriété pour contrôler la condition dans @PreAuthorize
@Test
@WithMockUser(username = "bob", roles = "EDITOR") // Le rôle est présent, mais le propriétaire sera "false"
void editorCannotUpdateForeignDraft() {
// On dit à la règle de sécurité : l'utilisateur N'EST PAS propriétaire de l'article
given(articleAccess.isOwner(eq(42L), any())).willReturn(false);
// Alors l'accès doit être refusé au niveau de @PreAuthorize
assertThatThrownBy(() -> editorArticleService.updateDraft(42L))
.isInstanceOf(AccessDeniedException.class);
}
}
Cette technique semble dire « nous avons mocké ce qui vérifie le propriétaire, donc nous ne testons pas la vérification de propriété ». Et c'est normal si vous séparez les responsabilités. La logique d'ArticleAccess (qui est en fait une vérification applicative ordinaire) peut être testée séparément — que ce soit par un test unitaire sans Spring, ou par un test de données avec le repository, selon l'implémentation. Avec le test de sécurité des méthodes, vous constatez que @PreAuthorize est réellement connecté, appelle réellement le composant nécessaire et interdit réellement l'exécution en cas de false.
La discipline pratique ici est : si la vérification de propriété vit dans l'annotation, testez que l'annotation fonctionne. Si la vérification de propriété vit dans le code du service, testez le comportement du service. Il n'est pas nécessaire de vérifier la même chose de trois manières, sinon la suite se transforme rapidement en « album de photos répétitives d'un même chat ».
8. Erreurs typiques lors des tests de sécurité des méthodes
Dans les tests de sécurité des méthodes, il est très facile d'obtenir des tests verts qui « existent », mais ne prouvent rien. Le plus souvent, le problème ne vient pas de Spring Security, mais du fait que nous avons accidentellement contourné le mécanisme de proxy ou mélangé trop de causes d'échec dans un seul test.
Erreur n°1 : tester @PreAuthorize sur un objet créé avec new.
La sécurité des méthodes repose sur un proxy géré par Spring. Dès que vous faites new ArticleWorkflowService(...), vous ne testez pas la sécurité, mais seulement le fait que Java sait appeler des méthodes. Un tel test peut être vert même avec une configuration de sécurité complètement cassée.
Erreur n°2 : oublier d'activer la sécurité des méthodes et se réjouir d'une suite verte.
Si @EnableMethodSecurity n'est pas dans la configuration, les annotations @PreAuthorize ne fonctionneront pas. Le plus sournois est que les tests « de logique métier » peuvent continuer à passer, et vous découvrirez le problème trop tard. Le test de sécurité des méthodes est justement utile pour détecter une telle faille de configuration.
Erreur n°3 : essayer de chercher les statuts 401 et 403 dans un test de niveau méthode.
Au niveau de l'appel du service, il n'y a pas de HTTP. Il n'y a pas de statuts — il y a des exceptions. Il faut vérifier AccessDeniedException (et parfois AuthenticationCredentialsNotFoundException), et laisser les significations de statuts pour la couche web.
Erreur n°4 : le test positif échoue à cause de la logique métier, et vous pensez que « la sécurité ne fonctionne pas ».
Lorsque l'accès est autorisé, la méthode commence à exécuter un travail réel. Si vous ne préparez pas un état minimal ou ne mockez pas les dépendances lourdes, le test échouera « parce que l'article est introuvable » ou « le statut n'est pas le bon ». Ce n'est pas une régression de sécurité, c'est simplement un autre risque. Soit préparez l'état, soit formulez l'attente différemment (par exemple, « pas d'accès refusé »).
Erreur n°5 : self-invocation et « pourquoi l'annotation n'a pas fonctionné ».
Si à l'intérieur d'un même service la méthode A() appelle la méthode B() de la même classe, l'appel passe souvent à côté du proxy (c'est le problème classique de self-invocation dans l'AOP basé sur des proxies). En conséquence, @PreAuthorize sur B() peut ne pas fonctionner. Ce n'est pas « Spring est cassé », c'est « nous avons contourné le proxy ». Dans ces endroits, soit on modifie la structure du code, soit on conçoit explicitement une frontière entre les beans.
GO TO FULL VERSION