diff --git a/docs/index.html b/docs/index.html index 9e55671b..f2539345 100644 --- a/docs/index.html +++ b/docs/index.html @@ -522,7 +522,7 @@

Spring Auth API Documentation

-
  • 1.5 Set Password +
  • 1.5 Update Password
  • 2 User Endpoints @@ -582,24 +568,24 @@

    Spring Auth API Documentation

    @@ -712,6 +698,7 @@

    1.1 Login

    POST /auth/login HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 64
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -727,7 +714,7 @@ 

    1.1 Login

    Vary: Origin Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers -Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJyZWZyZXNoIiwiaWF0IjoxNzY5NjgzNzgyLCJleHAiOjE3NzIyNzU3ODJ9.mioKx3NhbIoAQ6TQSZ2CaVYBUSJKzBc-0EJ9FubLCqQ; Path=/auth/refresh; Max-Age=2592000; Expires=Sat, 28 Feb 2026 10:49:42 GMT; Secure; HttpOnly; SameSite=Strict +Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJyZWZyZXNoIiwiaWF0IjoxNzcyMDE2MjgyLCJleHAiOjE3NzQ2MDgyODJ9.UtqrJwHjkvd981d6U124fynplGswBooLzsym8ep5h_o; Path=/auth/refresh; Max-Age=2592000; Expires=Fri, 27 Mar 2026 10:44:42 GMT; Secure; HttpOnly; SameSite=Strict Content-Type: application/json X-Content-Type-Options: nosniff X-XSS-Protection: 0 @@ -742,7 +729,7 @@

    1.1 Login

    "firstName" : "Test", "lastName" : "User", "login" : "test.user@test.com", - "token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3ODIsImV4cCI6MTc2OTY4NDA4MiwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.Bo0b8vhglOPLHQr2i9EVwYn4TA3IHgu2yloJR9wZKqk", + "token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMTYyODIsImV4cCI6MTc3MjAxNjU4MiwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.5AGtrdSF7asf8LcH24jKv7sCYB92OgBkzBcaLGTSNKc", "deleted" : false, "mainRole" : "USER", "permissions" : [ "ROLE_USER", "user:read" ] @@ -768,6 +755,7 @@
    1.1.1.1 Missing Login
    POST /auth/login HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 30
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -789,16 +777,16 @@ 
    1.1.1.1 Missing Login
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 203 +Content-Length: 207 { + "message" : "login: ne doit pas être vide", + "timestamp" : "2026-02-25T10:44:42.567098547", + "status" : 400, + "error" : "Bad Request", "fieldErrors" : { - "login" : "Login is required" - }, - "error" : "Validation Failed", - "message" : "login: Login is required", - "timestamp" : "2026-01-29T10:49:41.955519581", - "status" : 400 + "login" : "ne doit pas être vide" + } }
    @@ -817,6 +805,7 @@
    1.1.1.2 Missing Password
    POST /auth/login HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 36
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -838,16 +827,16 @@ 
    1.1.1.2 Missing Password
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 215 +Content-Length: 211 { + "message" : "password: ne doit pas être nul", + "timestamp" : "2026-02-25T10:44:43.217512294", + "status" : 400, + "error" : "Bad Request", "fieldErrors" : { - "password" : "Password is required" - }, - "error" : "Validation Failed", - "message" : "password: Password is required", - "timestamp" : "2026-01-29T10:49:42.617489447", - "status" : 400 + "password" : "ne doit pas être nul" + } }
    @@ -866,6 +855,7 @@
    1.1.1.3 Invalid Email Format
    POST /auth/login HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 66
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -888,16 +878,16 @@ 
    1.1.1.3 Invalid Email Format
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 237 +Content-Length: 283 { + "message" : "login: doit être une adresse électronique syntaxiquement correcte", + "timestamp" : "2026-02-25T10:44:42.022411371", + "status" : 400, + "error" : "Bad Request", "fieldErrors" : { - "login" : "Login must be a valid email format" - }, - "error" : "Validation Failed", - "message" : "login: Login must be a valid email format", - "timestamp" : "2026-01-29T10:49:41.423192565", - "status" : 400 + "login" : "doit être une adresse électronique syntaxiquement correcte" + } }
    @@ -915,6 +905,7 @@
    1.1.1.4 Empty Body
    POST /auth/login HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -932,13 +923,13 @@
    1.1.1.4 Empty Body
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 152 +Content-Length: 339 { + "message" : "Required request body is missing: public org.springframework.http.ResponseEntity<ch.sectioninformatique.auth.user.UserDto> ch.sectioninformatique.auth.auth.AuthController.login(ch.sectioninformatique.auth.auth.CredentialsDto)", + "timestamp" : "2026-02-25T10:44:43.027886446", "status" : 400, - "error" : "Bad Request", - "message" : "Malformed or missing JSON request body", - "timestamp" : "2026-01-29T10:49:42.403085940" + "error" : "Bad Request" }
    @@ -957,6 +948,7 @@
    1.1.1.5 Malformed JSON
    POST /auth/login HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 53
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {"login":"test.user@test.com", "password":"Test1234!"
    @@ -976,13 +968,13 @@
    1.1.1.5 Malformed JSON
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 167 +Content-Length: 304 { + "message" : "JSON parse error: Unexpected end-of-input: expected close marker for Object (start marker at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 1])", + "timestamp" : "2026-02-25T10:44:42.462369198", "status" : 400, - "error" : "Bad Request", - "message" : "JSON is incomplete - missing closing bracket or quote", - "timestamp" : "2026-01-29T10:49:41.851555394" + "error" : "Bad Request" } @@ -1001,6 +993,7 @@
    1.1.1.6 SQL Injection Attempt Logi
    POST /auth/login HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 57
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1023,16 +1016,16 @@ 
    1.1.1.6 SQL Injection Attempt Logi Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 236 +Content-Length: 283 { + "message" : "login: doit être une adresse électronique syntaxiquement correcte", + "timestamp" : "2026-02-25T10:44:41.211724127", + "status" : 400, + "error" : "Bad Request", "fieldErrors" : { - "login" : "Login must be a valid email format" - }, - "error" : "Validation Failed", - "message" : "login: Login must be a valid email format", - "timestamp" : "2026-01-29T10:49:40.51645219", - "status" : 400 + "login" : "doit être une adresse électronique syntaxiquement correcte" + } }
    @@ -1051,6 +1044,7 @@
    1.1.1.7 SQL Injection Attempt P
    POST /auth/login HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 66
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1073,13 +1067,13 @@ 
    1.1.1.7 SQL Injection Attempt P Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 134 +Content-Length: 136 { + "message" : "Identifiants invalides", + "timestamp" : "2026-02-25T10:44:42.93704145", "status" : 401, - "error" : "Unauthorized", - "message" : "Invalid credentials", - "timestamp" : "2026-01-29T10:49:42.292292345" + "error" : "Unauthorized" }
    @@ -1104,6 +1098,7 @@
    1.1.2.1 Wrong Media Type
    POST /auth/login HTTP/1.1
     Content-Type: text/plain;charset=UTF-8
     Content-Length: 64
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1129,10 +1124,10 @@ 
    1.1.2.1 Wrong Media Type
    Content-Length: 181 { - "status" : 415, - "error" : "Unsupported Media Type", "message" : "Content-Type 'text/plain;charset=UTF-8' is not supported", - "timestamp" : "2026-01-29T10:49:41.067528565" + "timestamp" : "2026-02-25T10:44:41.710247734", + "status" : 415, + "error" : "Unsupported Media Type" }
    @@ -1157,6 +1152,7 @@
    1.1.3.1 Wrong Password
    POST /auth/login HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 69
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1179,13 +1175,13 @@ 
    1.1.3.1 Wrong Password
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 134 +Content-Length: 136 { + "message" : "Identifiants invalides", + "timestamp" : "2026-02-25T10:44:42.32523129", "status" : 401, - "error" : "Unauthorized", - "message" : "Invalid credentials", - "timestamp" : "2026-01-29T10:49:41.731677079" + "error" : "Unauthorized" }
    @@ -1204,6 +1200,7 @@
    1.1.3.2 Non-Existent User
    POST /auth/login HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 72
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1226,13 +1223,13 @@ 
    1.1.3.2 Non-Existent User
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 134 +Content-Length: 137 { + "message" : "Identifiants invalides", + "timestamp" : "2026-02-25T10:44:43.173660936", "status" : 401, - "error" : "Unauthorized", - "message" : "Invalid credentials", - "timestamp" : "2026-01-29T10:49:42.550107691" + "error" : "Unauthorized" }
    @@ -1253,6 +1250,7 @@

    1.2 Register

    POST /auth/register HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 120
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1271,7 +1269,7 @@ 

    1.2 Register

    Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Location: /auth/users/test.newuser@test.com -Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0Lm5ld3VzZXJAdGVzdC5jb20iLCJ0eXAiOiJyZWZyZXNoIiwiaWF0IjoxNzY5NjgzNzgzLCJleHAiOjE3NzIyNzU3ODN9.kahZ-jUzqvM-7oOkL-PRRT4dkrU8PglmN267DOgwKsY; Path=/auth/refresh; Max-Age=2592000; Expires=Sat, 28 Feb 2026 10:49:43 GMT; Secure; HttpOnly; SameSite=Strict +Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0Lm5ld3VzZXJAdGVzdC5jb20iLCJ0eXAiOiJyZWZyZXNoIiwiaWF0IjoxNzcyMDE2MjgzLCJleHAiOjE3NzQ2MDgyODN9.l7Ds-xkr94JYnJWuJhhH2lF_F0zVrJT7N3HPHDL2ZpA; Path=/auth/refresh; Max-Age=2592000; Expires=Fri, 27 Mar 2026 10:44:43 GMT; Secure; HttpOnly; SameSite=Strict Content-Type: application/json X-Content-Type-Options: nosniff X-XSS-Protection: 0 @@ -1286,7 +1284,7 @@

    1.2 Register

    "firstName" : "Test", "lastName" : "NewUser", "login" : "test.newuser@test.com", - "token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0Lm5ld3VzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3ODMsImV4cCI6MTc2OTY4NDA4MywiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiTmV3VXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.j7i8IKZu5BGLvTLnfRs3tzMmVOjbmlYH-MId3PO_ers", + "token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0Lm5ld3VzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMTYyODMsImV4cCI6MTc3MjAxNjU4MywiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiTmV3VXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.Un-cjRoqcm4hIats3rjdkrRBByUWKV6mqaa-e9SoZ2A", "deleted" : false, "mainRole" : "USER", "permissions" : [ "ROLE_USER", "user:read" ] @@ -1312,6 +1310,7 @@
    1.2.1.1 Missing First Name
    POST /auth/register HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 96
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1335,16 +1334,16 @@ 
    1.2.1.1 Missing First Name
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 221 +Content-Length: 214 { + "message" : "firstName: ne doit pas être vide", + "timestamp" : "2026-02-25T10:44:41.15362169", + "status" : 400, + "error" : "Bad Request", "fieldErrors" : { - "firstName" : "First name is required" - }, - "error" : "Validation Failed", - "message" : "firstName: First name is required", - "timestamp" : "2026-01-29T10:49:40.445428943", - "status" : 400 + "firstName" : "ne doit pas être vide" + } }
    @@ -1363,6 +1362,7 @@
    1.2.1.2 Missing Last Name
    POST /auth/register HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 94
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1386,16 +1386,16 @@ 
    1.2.1.2 Missing Last Name
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 217 +Content-Length: 242 { + "message" : "lastName: {validation.signup.lastName.required}", + "timestamp" : "2026-02-25T10:44:40.86597445", + "status" : 400, + "error" : "Bad Request", "fieldErrors" : { - "lastName" : "Last name is required" - }, - "error" : "Validation Failed", - "message" : "lastName: Last name is required", - "timestamp" : "2026-01-29T10:49:40.117350696", - "status" : 400 + "lastName" : "{validation.signup.lastName.required}" + } }
    @@ -1414,6 +1414,7 @@
    1.2.1.3 Missing Login
    POST /auth/register HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 83
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1437,16 +1438,16 @@ 
    1.2.1.3 Missing Login
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 203 +Content-Length: 207 { + "message" : "login: ne doit pas être vide", + "timestamp" : "2026-02-25T10:44:42.195991088", + "status" : 400, + "error" : "Bad Request", "fieldErrors" : { - "login" : "Login is required" - }, - "error" : "Validation Failed", - "message" : "login: Login is required", - "timestamp" : "2026-01-29T10:49:41.619613051", - "status" : 400 + "login" : "ne doit pas être vide" + } }
    @@ -1465,6 +1466,7 @@
    1.2.1.4 Missing Password
    POST /auth/register HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 89
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1488,16 +1490,16 @@ 
    1.2.1.4 Missing Password
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 215 +Content-Length: 211 { + "message" : "password: ne doit pas être nul", + "timestamp" : "2026-02-25T10:44:41.970297385", + "status" : 400, + "error" : "Bad Request", "fieldErrors" : { - "password" : "Password is required" - }, - "error" : "Validation Failed", - "message" : "password: Password is required", - "timestamp" : "2026-01-29T10:49:41.366677649", - "status" : 400 + "password" : "ne doit pas être nul" + } }
    @@ -1516,6 +1518,7 @@
    1.2.1.5 Invalid Email Format
    POST /auth/register HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 119
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1540,16 +1543,16 @@ 
    1.2.1.5 Invalid Email Format
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 221 +Content-Length: 283 { + "message" : "login: doit être une adresse électronique syntaxiquement correcte", + "timestamp" : "2026-02-25T10:44:43.741672037", + "status" : 400, + "error" : "Bad Request", "fieldErrors" : { - "login" : "Login must be a valid email" - }, - "error" : "Validation Failed", - "message" : "login: Login must be a valid email", - "timestamp" : "2026-01-29T10:49:43.2095171", - "status" : 400 + "login" : "doit être une adresse électronique syntaxiquement correcte" + } }
    @@ -1567,6 +1570,7 @@
    1.2.1.6 Empty Body
    POST /auth/register HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -1584,13 +1588,13 @@
    1.2.1.6 Empty Body
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 152 +Content-Length: 337 { + "message" : "Required request body is missing: public org.springframework.http.ResponseEntity<ch.sectioninformatique.auth.user.UserDto> ch.sectioninformatique.auth.auth.AuthController.register(ch.sectioninformatique.auth.auth.SignUpDto)", + "timestamp" : "2026-02-25T10:44:43.696812538", "status" : 400, - "error" : "Bad Request", - "message" : "Malformed or missing JSON request body", - "timestamp" : "2026-01-29T10:49:43.150281518" + "error" : "Bad Request" }
    @@ -1609,6 +1613,7 @@
    1.2.1.7 Malformed JSON
    POST /auth/register HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 98
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {"firstName":"Test", "lastName":"User", "login":"test.newuser@test.com", "password":"testPassword"
    @@ -1628,13 +1633,13 @@
    1.2.1.7 Malformed JSON
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 167 +Content-Length: 304 { + "message" : "JSON parse error: Unexpected end-of-input: expected close marker for Object (start marker at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 1])", + "timestamp" : "2026-02-25T10:44:42.072422159", "status" : 400, - "error" : "Bad Request", - "message" : "JSON is incomplete - missing closing bracket or quote", - "timestamp" : "2026-01-29T10:49:41.488470746" + "error" : "Bad Request" } @@ -1653,6 +1658,7 @@
    1.2.1.8 SQL Injection Attempt
    POST /auth/register HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 124
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1677,16 +1683,16 @@ 
    1.2.1.8 SQL Injection Attempt Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 365 +Content-Length: 285 { + "message" : "firstName: doit correspondre à \"^[\\p{L}][\\p{L} '\\-]*[\\p{L}]$\"", + "timestamp" : "2026-02-25T10:44:42.824029173", + "status" : 400, + "error" : "Bad Request", "fieldErrors" : { - "firstName" : "First name contains invalid characters (only letters, spaces, hyphens and apostrophes allowed)" - }, - "error" : "Validation Failed", - "message" : "firstName: First name contains invalid characters (only letters, spaces, hyphens and apostrophes allowed)", - "timestamp" : "2026-01-29T10:49:42.178614802", - "status" : 400 + "firstName" : "doit correspondre à \"^[\\p{L}][\\p{L} '\\-]*[\\p{L}]$\"" + } }
    @@ -1705,6 +1711,7 @@
    1.2.1.9 SQL Injection Attempt
    POST /auth/register HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 124
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1729,16 +1736,16 @@ 
    1.2.1.9 SQL Injection Attempt Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 361 +Content-Length: 283 { + "message" : "lastName: doit correspondre à \"^[\\p{L}][\\p{L} '\\-]*[\\p{L}]$\"", + "timestamp" : "2026-02-25T10:44:43.791725449", + "status" : 400, + "error" : "Bad Request", "fieldErrors" : { - "lastName" : "Last name contains invalid characters (only letters, spaces, hyphens and apostrophes allowed)" - }, - "error" : "Validation Failed", - "message" : "lastName: Last name contains invalid characters (only letters, spaces, hyphens and apostrophes allowed)", - "timestamp" : "2026-01-29T10:49:43.264415675", - "status" : 400 + "lastName" : "doit correspondre à \"^[\\p{L}][\\p{L} '\\-]*[\\p{L}]$\"" + } }
    @@ -1757,6 +1764,7 @@
    1.2.1.10 SQL Injection Attempt Lo
    POST /auth/register HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 107
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1781,16 +1789,16 @@ 
    1.2.1.10 SQL Injection Attempt Lo Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 222 +Content-Length: 283 { + "message" : "login: doit être une adresse électronique syntaxiquement correcte", + "timestamp" : "2026-02-25T10:44:41.354523765", + "status" : 400, + "error" : "Bad Request", "fieldErrors" : { - "login" : "Login must be a valid email" - }, - "error" : "Validation Failed", - "message" : "login: Login must be a valid email", - "timestamp" : "2026-01-29T10:49:40.66584231", - "status" : 400 + "login" : "doit être une adresse électronique syntaxiquement correcte" + } }
    @@ -1815,6 +1823,7 @@
    1.2.2.1 Wrong Media Type
    POST /auth/register HTTP/1.1
     Content-Type: text/plain;charset=UTF-8
     Content-Length: 120
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1842,10 +1851,10 @@ 
    1.2.2.1 Wrong Media Type
    Content-Length: 181 { - "status" : 415, - "error" : "Unsupported Media Type", "message" : "Content-Type 'text/plain;charset=UTF-8' is not supported", - "timestamp" : "2026-01-29T10:49:41.310642586" + "timestamp" : "2026-02-25T10:44:41.917692169", + "status" : 415, + "error" : "Unsupported Media Type" }
    @@ -1870,6 +1879,7 @@
    1.2.3.1 Duplicate Login
    POST /auth/register HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 111
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -1894,13 +1904,13 @@ 
    1.2.3.1 Duplicate Login
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 150 +Content-Length: 158 { + "message" : "L'utilisateur existe déjà: test.user@test.com", + "timestamp" : "2026-02-25T10:44:41.764144785", "status" : 409, - "error" : "Conflict", - "message" : "User already exists: test.user@test.com", - "timestamp" : "2026-01-29T10:49:41.124048632" + "error" : "Conflict" }
    @@ -1920,8 +1930,9 @@

    1.3 Refresh

    POST /auth/refresh HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    -Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJyZWZyZXNoIiwiaWF0IjoxNzY5NjgzNzgxLCJleHAiOjE3NzIyNzU3ODF9.mkvb7pq3DEhbkX7kRnJfJnpbN44SqHVgEVKaReELmQQ
    +Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJyZWZyZXNoIiwiaWF0IjoxNzcyMDE2MjgxLCJleHAiOjE3NzQ2MDgyODF9.UW6znwr7GfXxq0YCng8A7-7d_PJQYj4wUWS-aKY0D2o
    @@ -1931,7 +1942,7 @@

    1.3 Refresh

    Vary: Origin Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers -Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJyZWZyZXNoIiwiaWF0IjoxNzY5NjgzNzgxLCJleHAiOjE3NzIyNzU3ODF9.mkvb7pq3DEhbkX7kRnJfJnpbN44SqHVgEVKaReELmQQ; Path=/auth/refresh; Max-Age=2592000; Expires=Sat, 28 Feb 2026 10:49:41 GMT; Secure; HttpOnly; SameSite=Strict +Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJyZWZyZXNoIiwiaWF0IjoxNzcyMDE2MjgxLCJleHAiOjE3NzQ2MDgyODF9.UW6znwr7GfXxq0YCng8A7-7d_PJQYj4wUWS-aKY0D2o; Path=/auth/refresh; Max-Age=2592000; Expires=Fri, 27 Mar 2026 10:44:41 GMT; Secure; HttpOnly; SameSite=Strict Content-Type: application/json X-Content-Type-Options: nosniff X-XSS-Protection: 0 @@ -1942,7 +1953,7 @@

    1.3 Refresh

    Content-Length: 335 { - "accessToken" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3ODEsImV4cCI6MTc2OTY4NDA4MSwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.egvMQ3ePK3pZ3bqiG0_MJoOw8w7ET_jaAXECGVW4IVI" + "accessToken" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMTYyODEsImV4cCI6MTc3MjAxNjU4MSwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.A7_12_xLBuTIbvCQ5dmWRg4vLQ_ehHWrQ7cNyuBTx44" }
    @@ -1964,6 +1975,7 @@
    1.3.1.1 Missing Token
    GET /auth/refresh HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -1981,10 +1993,10 @@
    1.3.1.1 Missing Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 +Content-Length: 66 { - "message" : "Full authentication is required to access this resource" + "message" : "Jeton d''authentification invalide ou manquant" } @@ -2002,6 +2014,7 @@
    1.3.1.2 Missing Authorization Hea
    GET /auth/refresh HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2019,10 +2032,10 @@
    1.3.1.2 Missing Authorization Hea Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 +Content-Length: 66 { - "message" : "Full authentication is required to access this resource" + "message" : "Jeton d''authentification invalide ou manquant" } @@ -2041,6 +2054,7 @@
    1.3.1.3 Invalid Token
    GET /auth/refresh HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Authorization: Bearer this.is.not.a.valid.token
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2058,11 +2072,11 @@
    1.3.1.3 Invalid Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Invalid JWT token" + "message" : "Jeton JWT invalide", + "error" : "INVALID_TOKEN" } @@ -2086,6 +2100,7 @@
    1.3.2.1 Empty Body
    GET /auth/refresh HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2103,10 +2118,10 @@
    1.3.2.1 Empty Body
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 +Content-Length: 66 { - "message" : "Full authentication is required to access this resource" + "message" : "Jeton d''authentification invalide ou manquant" } @@ -2126,7 +2141,8 @@

    1.4 Logout

    POST /auth/logout HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3ODAsImV4cCI6MTc2OTY4NDA4MCwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.5N6FLRj-MxpiJoHqkX3llIyaYrvvgncTxXDQA0dMby4
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMTYyODEsImV4cCI6MTc3MjAxNjU4MSwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.A7_12_xLBuTIbvCQ5dmWRg4vLQ_ehHWrQ7cNyuBTx44
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2137,7 +2153,7 @@

    1.4 Logout

    Vary: Origin Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers -Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJyZWZyZXNoIiwiaWF0IjoxNzY5NjgzNzgwLCJleHAiOjE3Njk2ODM3ODB9.ptNXKXeTpnqzzwapxhDg5KXQ6aGFNuUYXD5qQN3oRco; Path=/auth/refresh; Max-Age=0; Expires=Thu, 1 Jan 1970 00:00:00 GMT; Secure; HttpOnly; SameSite=Strict +Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJyZWZyZXNoIiwiaWF0IjoxNzcyMDE2MjgxLCJleHAiOjE3NzIwMTYyODF9.qkV6qj7lyQ9hd1pwXqNY3ilz6vhd861sONHqcwQnktM; Path=/auth/refresh; Max-Age=0; Expires=Thu, 1 Jan 1970 00:00:00 GMT; Secure; HttpOnly; SameSite=Strict Content-Type: application/json X-Content-Type-Options: nosniff X-XSS-Protection: 0 @@ -2145,10 +2161,10 @@

    1.4 Logout

    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 43 +Content-Length: 41 { - "message" : "Logged out successfully" + "message" : "Déconnexion réussie" } @@ -2170,6 +2186,7 @@
    1.4.1.1 Missing Token
    POST /auth/logout HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2187,10 +2204,10 @@
    1.4.1.1 Missing Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 +Content-Length: 66 { - "message" : "Full authentication is required to access this resource" + "message" : "Jeton d''authentification invalide ou manquant" } @@ -2209,6 +2226,7 @@
    1.4.1.2 Malformed Token
    POST /auth/logout HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Authorization: Bearer this.is.not.a.valid.token
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2226,11 +2244,11 @@
    1.4.1.2 Malformed Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Invalid JWT token" + "message" : "Jeton JWT invalide", + "error" : "INVALID_TOKEN" } @@ -2248,7 +2266,8 @@
    1.4.1.3 Expired Token
    POST /auth/logout HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2NzY1ODIsImV4cCI6MTc2OTY3Njg4MiwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.lTt_6DLiHAF4mBVXTeD7iUEiyc2zPpURNTc4-RKxBmk
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMDkwODMsImV4cCI6MTc3MjAwOTM4MywiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.6CqYu5boOudzSMYprWlMb7cbMm4I2ENsjuBjJWXOwbY
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2266,11 +2285,11 @@
    1.4.1.3 Expired Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Token has expired" + "message" : "Le jeton a expiré", + "error" : "INVALID_TOKEN" } @@ -2281,7 +2300,7 @@
    1.4.1.3 Expired Token
    -

    1.5 Set Password

    +

    1.5 Update Password

    This is an example output for the PUT /auth/update-password endpoint.

    @@ -2290,8 +2309,9 @@

    1.5 Set Password

    PUT /auth/update-password HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3ODIsImV4cCI6MTc2OTY4NDA4MiwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.Bo0b8vhglOPLHQr2i9EVwYn4TA3IHgu2yloJR9wZKqk
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMTYyODMsImV4cCI6MTc3MjAxNjU4MywiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.DNeKm8L06NRLmzQK7zcE_TMU6NCl1RFaqRKs6xI-6rM
     Content-Length: 70
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -2314,15 +2334,15 @@ 

    1.5 Set Password

    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 49 +Content-Length: 57 { - "message" : "Password updated successfully" + "message" : "Mot de passe mis à jour avec succès" }
    -

    It sets a password for a user account using a token.

    +

    It updates the password for the authenticated user.

    1.5.1 Error Response - 400 - Bad Request

    @@ -2339,7 +2359,8 @@
    1.5.1.1 Missing Body
    PUT /auth/update-password HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3ODAsImV4cCI6MTc2OTY4NDA4MCwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.5N6FLRj-MxpiJoHqkX3llIyaYrvvgncTxXDQA0dMby4
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMTYyODEsImV4cCI6MTc3MjAxNjU4MSwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.A7_12_xLBuTIbvCQ5dmWRg4vLQ_ehHWrQ7cNyuBTx44
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2357,13 +2378,13 @@
    1.5.1.1 Missing Body
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 152 +Content-Length: 312 { + "message" : "Required request body is missing: public org.springframework.http.ResponseEntity<?> ch.sectioninformatique.auth.auth.AuthController.updatePassword(ch.sectioninformatique.auth.auth.PasswordUpdateDto)", + "timestamp" : "2026-02-25T10:44:41.481311361", "status" : 400, - "error" : "Bad Request", - "message" : "Malformed or missing JSON request body", - "timestamp" : "2026-01-29T10:49:40.797899793" + "error" : "Bad Request" } @@ -2388,6 +2409,7 @@
    1.5.2.1 Missing Token
    PUT /auth/update-password HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Content-Length: 70
    +Accept-Language: fr-fr
     Host: localhost:8080
     
     {
    @@ -2410,153 +2432,10 @@ 
    1.5.2.1 Missing Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 - -{ - "message" : "Full authentication is required to access this resource" -}
    - - -
    -

    It returns a 401 Unauthorized error indicating that full authentication is required.

    -
    - - - -
    -

    1.6 Update Password

    -
    -

    This is an example output for the PUT /auth/update-password endpoint.

    -
    -
    -
    request
    -
    -
    PUT /auth/update-password HTTP/1.1
    -Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3ODIsImV4cCI6MTc2OTY4NDA4MiwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.Bo0b8vhglOPLHQr2i9EVwYn4TA3IHgu2yloJR9wZKqk
    -Content-Length: 70
    -Host: localhost:8080
    -
    -{
    -  "oldPassword" : "Test1234!",
    -  "newPassword" : "TestNewPassword"
    -}
    -
    -
    -
    -
    response
    -
    -
    HTTP/1.1 200 OK
    -Vary: Origin
    -Vary: Access-Control-Request-Method
    -Vary: Access-Control-Request-Headers
    -Content-Type: application/json
    -X-Content-Type-Options: nosniff
    -X-XSS-Protection: 0
    -Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    -Pragma: no-cache
    -Expires: 0
    -X-Frame-Options: DENY
    -Content-Length: 49
    -
    -{
    -  "message" : "Password updated successfully"
    -}
    -
    -
    -
    -

    It sets a new password for the authenticated user.

    -
    -
    -

    1.6.1 Error Response - 400 - Bad Request

    -
    -

    These are example outputs for the PUT /auth/update-password endpoint for bad request.

    -
    -
    -
    1.6.1.1 Missing Body
    -
    -

    This is an example output when the request body is missing.

    -
    -
    -
    request
    -
    -
    PUT /auth/update-password HTTP/1.1
    -Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3ODAsImV4cCI6MTc2OTY4NDA4MCwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.5N6FLRj-MxpiJoHqkX3llIyaYrvvgncTxXDQA0dMby4
    -Host: localhost:8080
    -
    -
    -
    -
    response
    -
    -
    HTTP/1.1 400 Bad Request
    -Vary: Origin
    -Vary: Access-Control-Request-Method
    -Vary: Access-Control-Request-Headers
    -Content-Type: application/json
    -X-Content-Type-Options: nosniff
    -X-XSS-Protection: 0
    -Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    -Pragma: no-cache
    -Expires: 0
    -X-Frame-Options: DENY
    -Content-Length: 152
    -
    -{
    -  "status" : 400,
    -  "error" : "Bad Request",
    -  "message" : "Malformed or missing JSON request body",
    -  "timestamp" : "2026-01-29T10:49:40.797899793"
    -}
    -
    -
    -
    -

    It returns a 400 Bad Request error indicating that the request body is missing.

    -
    -
    -
    -
    -

    1.6.2 Error Response - 401 - Unauthorised

    -
    -

    These are example outputs for the PUT /auth/update-password endpoint for unauthorized access.

    -
    -
    -
    1.6.2.1 Missing Token
    -
    -

    This is an example output when the request token is missing.

    -
    -
    -
    request
    -
    -
    PUT /auth/update-password HTTP/1.1
    -Content-Type: application/json;charset=UTF-8
    -Content-Length: 70
    -Host: localhost:8080
    -
    -{
    -  "oldPassword" : "Test1234!",
    -  "newPassword" : "TestNewPassword"
    -}
    -
    -
    -
    -
    response
    -
    -
    HTTP/1.1 401 Unauthorized
    -Vary: Origin
    -Vary: Access-Control-Request-Method
    -Vary: Access-Control-Request-Headers
    -Content-Type: application/json
    -X-Content-Type-Options: nosniff
    -X-XSS-Protection: 0
    -Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    -Pragma: no-cache
    -Expires: 0
    -X-Frame-Options: DENY
    -Content-Length: 75
    +Content-Length: 66
     
     {
    -  "message" : "Full authentication is required to access this resource"
    +  "message" : "Jeton d''authentification invalide ou manquant"
     }
    @@ -2581,7 +2460,8 @@

    2.1 Get Authenticated User

    GET /users/me HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3OTIsImV4cCI6MTc2OTY4NDA5MiwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.ZCpoDDNnCa-Sk-V_GvHw4KSsdRT3BDduAppb6OfJ4cg
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMTYyOTYsImV4cCI6MTc3MjAxNjU5NiwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.yU6Bk5rTJZY4Nw5FBAMsnBf57RvxZVjbC3461c1-IGc
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2631,6 +2511,7 @@
    2.1.1.1 Missing Authorization Hea
    GET /users/me HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2648,10 +2529,10 @@
    2.1.1.1 Missing Authorization Hea Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 +Content-Length: 66 { - "message" : "Full authentication is required to access this resource" + "message" : "Jeton d''authentification invalide ou manquant" }
    @@ -2670,6 +2551,7 @@
    2.1.1.2 Malformed Token
    GET /users/me HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Authorization: Bearer this.is.not.a.valid.token
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2687,11 +2569,11 @@
    2.1.1.2 Malformed Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Invalid JWT token" + "message" : "Jeton JWT invalide", + "error" : "INVALID_TOKEN" } @@ -2709,7 +2591,8 @@
    2.1.1.3 Expired Token
    GET /users/me HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2NzY1OTEsImV4cCI6MTc2OTY3Njg5MSwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.PqLJed9PXLajVfm80MdNoX4fBXg5ACPbAd_afKwyTaQ
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMDkwOTUsImV4cCI6MTc3MjAwOTM5NSwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.wFRtvRHQQX7lJONslrx4e8q_7HnNwrRbHJsaJhseJdg
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2727,11 +2610,11 @@
    2.1.1.3 Expired Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Token has expired" + "message" : "Le jeton a expiré", + "error" : "INVALID_TOKEN" } @@ -2748,7 +2631,8 @@

    2.2 Get All Users

    GET /users/all HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3OTMsImV4cCI6MTc2OTY4NDA5MywiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.FkO--RsRSJjLL4t8DUbz8IapuPNQ_IGcDzZasB-cDG4
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMTYyOTcsImV4cCI6MTc3MjAxNjU5NywiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.6XjrUnM--H3TY69kOPnTJbzVqgpfjpblY38VT_Ber_s
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2825,6 +2709,7 @@
    2.2.1.1 Missing Authorization Hea
    GET /users/all HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2842,10 +2727,10 @@
    2.2.1.1 Missing Authorization Hea Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 +Content-Length: 66 { - "message" : "Full authentication is required to access this resource" + "message" : "Jeton d''authentification invalide ou manquant" } @@ -2864,6 +2749,7 @@
    2.2.1.2 Malformed Token
    GET /users/all HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Authorization: Bearer this.is.not.a.valid.token
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2881,11 +2767,11 @@
    2.2.1.2 Malformed Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Invalid JWT token" + "message" : "Jeton JWT invalide", + "error" : "INVALID_TOKEN" } @@ -2903,7 +2789,8 @@
    2.2.1.3 Expired Token
    GET /users/all HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2NzY1OTEsImV4cCI6MTc2OTY3Njg5MSwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.PqLJed9PXLajVfm80MdNoX4fBXg5ACPbAd_afKwyTaQ
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMDkwOTYsImV4cCI6MTc3MjAwOTM5NiwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.cwzGBitqsqgkLYPFwg2K5Xh1IscPMPqEPxUgr02mm04
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -2921,11 +2808,11 @@
    2.2.1.3 Expired Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Token has expired" + "message" : "Le jeton a expiré", + "error" : "INVALID_TOKEN" } @@ -2945,7 +2832,8 @@

    2.3 Get All Users (Including Delet
    GET /users/all-with-deleted HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkzLCJleHAiOjE3Njk2ODQwOTMsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.NGkozMagAfxoIFExlWjiNaCKP1qAPUlEFj8fmlPjY8Y
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk3LCJleHAiOjE3NzIwMTY1OTcsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.hAGhO5SjEIs-btbbf_WFDNOgY6v2NkCZD8T8vTidM4U
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3018,7 +2906,8 @@

    2.4 Get Deleted Users

    GET /users/deleted HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkxLCJleHAiOjE3Njk2ODQwOTEsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.XROd5lhqRCqXHv1WSupoawFuD1Na0-cz9-1-OfMmLzw
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk1LCJleHAiOjE3NzIwMTY1OTUsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.J5OgTmZEJgl5CaI9O0DOTHqhqI_6HvVLzhX71MUOkzM
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3055,7 +2944,8 @@

    2.5 Promote User to Manager

    PUT /users/1/promote-manager HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkyLCJleHAiOjE3Njk2ODQwOTIsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.aJlUYY4QpoC39zt6PFmLl5p7dpmENM0cjeejr-JeZXg
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk2LCJleHAiOjE3NzIwMTY1OTYsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.vnY8yl-QWnchspXCm9FnMvsOacrzgLDDDhnnEzOdZZg
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3067,7 +2957,7 @@

    2.5 Promote User to Manager

    Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: text/plain;charset=UTF-8 -Content-Length: 37 +Content-Length: 38 X-Content-Type-Options: nosniff X-XSS-Protection: 0 Cache-Control: no-cache, no-store, max-age=0, must-revalidate @@ -3075,7 +2965,7 @@

    2.5 Promote User to Manager

    Expires: 0 X-Frame-Options: DENY -User promoted to manager successfully +Utilisateur promu manager avec succès
    @@ -3087,7 +2977,7 @@

    2.5.1 Error Response - 401 - Una

    These are example outputs for the PUT /users/{userId}/promote-manager endpoint for unauthorized access.

    -
    2.3.1.1 Missing Authorization Header
    +
    2.5.1.1 Missing Authorization Header

    This is an example output when the Authorization header is missing in the request.

    @@ -3096,6 +2986,7 @@
    2.3.1.1 Missing Authorization Hea
    PUT /users/1/promote-manager HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3113,10 +3004,10 @@
    2.3.1.1 Missing Authorization Hea Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 +Content-Length: 66 { - "message" : "Full authentication is required to access this resource" + "message" : "Jeton d''authentification invalide ou manquant" } @@ -3125,7 +3016,7 @@
    2.3.1.1 Missing Authorization Hea
    -
    2.3.1.2 Malformed Token
    +
    2.5.1.2 Malformed Token

    This is an example output when the token provided is malformed.

    @@ -3135,6 +3026,7 @@
    2.3.1.2 Malformed Token
    PUT /users/1/promote-manager HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Authorization: Bearer this.is.not.a.valid.token
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3152,11 +3044,11 @@
    2.3.1.2 Malformed Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Invalid JWT token" + "message" : "Jeton JWT invalide", + "error" : "INVALID_TOKEN" } @@ -3166,12 +3058,12 @@
    2.3.1.2 Malformed Token
    -

    2.3.2 Error Response - 403 - Forbidden

    +

    2.5.2 Error Response - 403 - Forbidden

    These are example outputs for the PUT /users/{userId}/promote-manager endpoint for forbidden access.

    -
    2.3.2.1 Non-Admin User - Promote User to Manager
    +
    2.5.2.1 Non-Admin User - Promote User to Manager

    This is an example output when a non-admin user attempts to promote a user to manager.

    @@ -3180,7 +3072,8 @@
    2.3.2.1 Non-Admin User
    PUT /users/1/promote-manager HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3OTMsImV4cCI6MTc2OTY4NDA5MywiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.FkO--RsRSJjLL4t8DUbz8IapuPNQ_IGcDzZasB-cDG4
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMTYyOTcsImV4cCI6MTc3MjAxNjU5NywiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.6XjrUnM--H3TY69kOPnTJbzVqgpfjpblY38VT_Ber_s
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3198,10 +3091,10 @@
    2.3.2.1 Non-Admin User Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 33 +Content-Length: 88 { - "message" : "Access Denied" + "message" : "Vous n''avez pas les droits nécessaires pour effectuer cette action" }
    @@ -3211,12 +3104,12 @@
    2.3.2.1 Non-Admin User
    -

    2.3.3 Error Response - 404 - Not Found

    +

    2.5.3 Error Response - 404 - Not Found

    These are example outputs for the PUT /users/{userId}/promote-manager endpoint for not found errors.

    -
    2.3.3.1 User Not Found
    +
    2.5.3.1 User Not Found

    This is an example output when the user to be promoted is not found.

    @@ -3225,7 +3118,8 @@
    2.3.3.1 User Not Found
    PUT /users/9999/promote-manager HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkzLCJleHAiOjE3Njk2ODQwOTMsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.NGkozMagAfxoIFExlWjiNaCKP1qAPUlEFj8fmlPjY8Y
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk3LCJleHAiOjE3NzIwMTY1OTcsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.hAGhO5SjEIs-btbbf_WFDNOgY6v2NkCZD8T8vTidM4U
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3243,13 +3137,13 @@
    2.3.3.1 User Not Found
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 132 +Content-Length: 141 { + "message" : "Utilisateur non trouvé: 9999", + "timestamp" : "2026-02-25T10:44:57.385893541", "status" : 404, - "error" : "Not Found", - "message" : "User not found: 9999", - "timestamp" : "2026-01-29T10:49:53.174324740" + "error" : "Not Found" }
    @@ -3259,12 +3153,12 @@
    2.3.3.1 User Not Found
    -

    2.3.4 Error Response - 409 - Conflict

    +

    2.5.4 Error Response - 409 - Conflict

    These are example outputs for the PUT /users/{userId}/promote-manager endpoint for conflict errors.

    -
    2.3.4.1 User Already Manager
    +
    2.5.4.1 User Already Manager

    This is an example output when the user to be promoted is already a manager.

    @@ -3273,7 +3167,8 @@
    2.3.4.1 User Already Manager
    PUT /users/2/promote-manager HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkyLCJleHAiOjE3Njk2ODQwOTIsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.aJlUYY4QpoC39zt6PFmLl5p7dpmENM0cjeejr-JeZXg
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk2LCJleHAiOjE3NzIwMTY1OTYsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.vnY8yl-QWnchspXCm9FnMvsOacrzgLDDDhnnEzOdZZg
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3291,13 +3186,13 @@
    2.3.4.1 User Already Manager
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 154 +Content-Length: 165 { + "message" : "L'utilisateur est déjà manager: test.manager@test.com", + "timestamp" : "2026-02-25T10:44:56.93461292", "status" : 409, - "error" : "Conflict", - "message" : "User already manager: test.manager@test.com", - "timestamp" : "2026-01-29T10:49:52.774503075" + "error" : "Conflict" }
    @@ -3306,7 +3201,7 @@
    2.3.4.1 User Already Manager
    -
    2.3.4.2 User Already Admin
    +
    2.5.4.2 User Already Admin

    This is an example output when the user to be promoted is already an admin.

    @@ -3315,7 +3210,8 @@
    2.3.4.2 User Already Admin
    PUT /users/3/promote-manager HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkyLCJleHAiOjE3Njk2ODQwOTIsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.aJlUYY4QpoC39zt6PFmLl5p7dpmENM0cjeejr-JeZXg
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk2LCJleHAiOjE3NzIwMTY1OTYsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.vnY8yl-QWnchspXCm9FnMvsOacrzgLDDDhnnEzOdZZg
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3333,13 +3229,13 @@
    2.3.4.2 User Already Admin
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 150 +Content-Length: 162 { + "message" : "L'utilisateur est déjà admin: test.admin@test.com", + "timestamp" : "2026-02-25T10:44:56.333871316", "status" : 409, - "error" : "Conflict", - "message" : "User already admin: test.admin@test.com", - "timestamp" : "2026-01-29T10:49:52.183106867" + "error" : "Conflict" } @@ -3359,7 +3255,8 @@

    2.6 Revoke Manager to User

    PUT /users/2/revoke-manager HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkxLCJleHAiOjE3Njk2ODQwOTEsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.XROd5lhqRCqXHv1WSupoawFuD1Na0-cz9-1-OfMmLzw
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk1LCJleHAiOjE3NzIwMTY1OTUsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.J5OgTmZEJgl5CaI9O0DOTHqhqI_6HvVLzhX71MUOkzM
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3371,7 +3268,7 @@

    2.6 Revoke Manager to User

    Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: text/plain;charset=UTF-8 -Content-Length: 33 +Content-Length: 39 X-Content-Type-Options: nosniff X-XSS-Protection: 0 Cache-Control: no-cache, no-store, max-age=0, must-revalidate @@ -3379,7 +3276,7 @@

    2.6 Revoke Manager to User

    Expires: 0 X-Frame-Options: DENY -Manager role revoked successfully +Rôle de manager révoqué avec succès
    @@ -3400,6 +3297,7 @@
    2.6.1.1 Missing Authorization Hea
    PUT /users/2/revoke-manager HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3417,10 +3315,10 @@
    2.6.1.1 Missing Authorization Hea Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 +Content-Length: 66 { - "message" : "Full authentication is required to access this resource" + "message" : "Jeton d''authentification invalide ou manquant" } @@ -3439,6 +3337,7 @@
    2.6.1.2 Malformed Token
    PUT /users/2/revoke-manager HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Authorization: Bearer this.is.not.a.valid.token
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3456,11 +3355,11 @@
    2.6.1.2 Malformed Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Invalid JWT token" + "message" : "Jeton JWT invalide", + "error" : "INVALID_TOKEN" } @@ -3484,7 +3383,8 @@
    2.6.2.1 Non-Admin User
    PUT /users/2/revoke-manager HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3OTEsImV4cCI6MTc2OTY4NDA5MSwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.OcKvGgkQV0iORcACxh5AlaOeZY24OYuffcC0FQoctww
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMTYyOTYsImV4cCI6MTc3MjAxNjU5NiwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.yU6Bk5rTJZY4Nw5FBAMsnBf57RvxZVjbC3461c1-IGc
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3502,10 +3402,10 @@
    2.6.2.1 Non-Admin User
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 33 +Content-Length: 88 { - "message" : "Access Denied" + "message" : "Vous n''avez pas les droits nécessaires pour effectuer cette action" } @@ -3529,7 +3429,8 @@
    2.6.3.1 User Not Found
    PUT /users/9999/revoke-manager HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkxLCJleHAiOjE3Njk2ODQwOTEsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.XROd5lhqRCqXHv1WSupoawFuD1Na0-cz9-1-OfMmLzw
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk1LCJleHAiOjE3NzIwMTY1OTUsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.J5OgTmZEJgl5CaI9O0DOTHqhqI_6HvVLzhX71MUOkzM
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3547,13 +3448,13 @@
    2.6.3.1 User Not Found
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 132 +Content-Length: 141 { + "message" : "Utilisateur non trouvé: 9999", + "timestamp" : "2026-02-25T10:44:55.872352675", "status" : 404, - "error" : "Not Found", - "message" : "User not found: 9999", - "timestamp" : "2026-01-29T10:49:51.654683942" + "error" : "Not Found" } @@ -3573,7 +3474,8 @@

    2.7 Promote to Admin

    PUT /users/2/promote-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkxLCJleHAiOjE3Njk2ODQwOTEsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.XROd5lhqRCqXHv1WSupoawFuD1Na0-cz9-1-OfMmLzw
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk1LCJleHAiOjE3NzIwMTY1OTUsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.J5OgTmZEJgl5CaI9O0DOTHqhqI_6HvVLzhX71MUOkzM
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3585,7 +3487,7 @@

    2.7 Promote to Admin

    Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: text/plain;charset=UTF-8 -Content-Length: 32 +Content-Length: 37 X-Content-Type-Options: nosniff X-XSS-Protection: 0 Cache-Control: no-cache, no-store, max-age=0, must-revalidate @@ -3593,7 +3495,7 @@

    2.7 Promote to Admin

    Expires: 0 X-Frame-Options: DENY -Admin role assigned successfully +Rôle d''admin attribué avec succès
    @@ -3614,6 +3516,7 @@
    2.7.1.1 Missing Authorization Hea
    PUT /users/2/promote-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3631,10 +3534,10 @@
    2.7.1.1 Missing Authorization Hea Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 +Content-Length: 66 { - "message" : "Full authentication is required to access this resource" + "message" : "Jeton d''authentification invalide ou manquant" } @@ -3650,6 +3553,7 @@
    2.7.1.2 Malformed Token
    PUT /users/2/promote-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Authorization: Bearer this.is.not.a.valid.token
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3667,11 +3571,11 @@
    2.7.1.2 Malformed Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Invalid JWT token" + "message" : "Jeton JWT invalide", + "error" : "INVALID_TOKEN" } @@ -3695,7 +3599,8 @@
    2.7.2.1 Non-Admin User
    PUT /users/2/promote-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3OTEsImV4cCI6MTc2OTY4NDA5MSwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.OcKvGgkQV0iORcACxh5AlaOeZY24OYuffcC0FQoctww
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMTYyOTUsImV4cCI6MTc3MjAxNjU5NSwiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.5RA-iUWnlKBq1uFnsE_g-RF5y-C2jTjknNxDy-l4qZ4
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3713,10 +3618,10 @@
    2.7.2.1 Non-Admin User
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 33 +Content-Length: 88 { - "message" : "Access Denied" + "message" : "Vous n''avez pas les droits nécessaires pour effectuer cette action" } @@ -3740,7 +3645,8 @@
    2.7.3.1 User Not Found
    PUT /users/9999/promote-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkyLCJleHAiOjE3Njk2ODQwOTIsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.aJlUYY4QpoC39zt6PFmLl5p7dpmENM0cjeejr-JeZXg
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk2LCJleHAiOjE3NzIwMTY1OTYsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.vnY8yl-QWnchspXCm9FnMvsOacrzgLDDDhnnEzOdZZg
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3758,13 +3664,13 @@
    2.7.3.1 User Not Found
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 132 +Content-Length: 141 { + "message" : "Utilisateur non trouvé: 9999", + "timestamp" : "2026-02-25T10:44:56.870252827", "status" : 404, - "error" : "Not Found", - "message" : "User not found: 9999", - "timestamp" : "2026-01-29T10:49:52.684522605" + "error" : "Not Found" } @@ -3788,7 +3694,8 @@
    2.7.4.1 User Already Admin
    PUT /users/3/promote-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkyLCJleHAiOjE3Njk2ODQwOTIsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.aJlUYY4QpoC39zt6PFmLl5p7dpmENM0cjeejr-JeZXg
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk2LCJleHAiOjE3NzIwMTY1OTYsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.vnY8yl-QWnchspXCm9FnMvsOacrzgLDDDhnnEzOdZZg
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3806,13 +3713,13 @@
    2.7.4.1 User Already Admin
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 150 +Content-Length: 162 { + "message" : "L'utilisateur est déjà admin: test.admin@test.com", + "timestamp" : "2026-02-25T10:44:56.390557614", "status" : 409, - "error" : "Conflict", - "message" : "User already admin: test.admin@test.com", - "timestamp" : "2026-01-29T10:49:52.260161343" + "error" : "Conflict" } @@ -3832,7 +3739,8 @@

    2.8 Revoke Admin to User

    PUT /users/4/revoke-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkyLCJleHAiOjE3Njk2ODQwOTIsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.aJlUYY4QpoC39zt6PFmLl5p7dpmENM0cjeejr-JeZXg
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk2LCJleHAiOjE3NzIwMTY1OTYsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.vnY8yl-QWnchspXCm9FnMvsOacrzgLDDDhnnEzOdZZg
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3844,7 +3752,7 @@

    2.8 Revoke Admin to User

    Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: text/plain;charset=UTF-8 -Content-Length: 31 +Content-Length: 37 X-Content-Type-Options: nosniff X-XSS-Protection: 0 Cache-Control: no-cache, no-store, max-age=0, must-revalidate @@ -3852,7 +3760,7 @@

    2.8 Revoke Admin to User

    Expires: 0 X-Frame-Options: DENY -Admin role revoked successfully +Rôle d''admin révoqué avec succès
    @@ -3870,6 +3778,7 @@
    2.8.1.1 Missing Authorization Hea
    PUT /users/4/revoke-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3887,10 +3796,10 @@
    2.8.1.1 Missing Authorization Hea Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 +Content-Length: 66 { - "message" : "Full authentication is required to access this resource" + "message" : "Jeton d''authentification invalide ou manquant" } @@ -3909,6 +3818,7 @@
    2.8.1.2 Malformed Token
    PUT /users/4/revoke-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Authorization: Bearer this.is.not.a.valid.token
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3926,11 +3836,11 @@
    2.8.1.2 Malformed Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Invalid JWT token" + "message" : "Jeton JWT invalide", + "error" : "INVALID_TOKEN" } @@ -3954,7 +3864,8 @@
    2.8.2.1 Non-Admin User
    PUT /users/4/revoke-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3Njk2ODM3OTMsImV4cCI6MTc2OTY4NDA5MywiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.FkO--RsRSJjLL4t8DUbz8IapuPNQ_IGcDzZasB-cDG4
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LnVzZXJAdGVzdC5jb20iLCJ0eXAiOiJhY2Nlc3MiLCJpYXQiOjE3NzIwMTYyOTcsImV4cCI6MTc3MjAxNjU5NywiZmlyc3ROYW1lIjoiVGVzdCIsImxhc3ROYW1lIjoiVXNlciIsIm1haW5Sb2xlIjoiVVNFUiIsInBlcm1pc3Npb25zIjpbIlJPTEVfVVNFUiIsInVzZXI6cmVhZCJdfQ.6XjrUnM--H3TY69kOPnTJbzVqgpfjpblY38VT_Ber_s
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -3972,10 +3883,10 @@
    2.8.2.1 Non-Admin User
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 33 +Content-Length: 88 { - "message" : "Access Denied" + "message" : "Vous n''avez pas les droits nécessaires pour effectuer cette action" } @@ -3999,7 +3910,8 @@
    2.8.3.1 User Not Found
    PUT /users/9999/revoke-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkyLCJleHAiOjE3Njk2ODQwOTIsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.aJlUYY4QpoC39zt6PFmLl5p7dpmENM0cjeejr-JeZXg
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk2LCJleHAiOjE3NzIwMTY1OTYsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.vnY8yl-QWnchspXCm9FnMvsOacrzgLDDDhnnEzOdZZg
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -4017,13 +3929,13 @@
    2.8.3.1 User Not Found
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 132 +Content-Length: 141 { + "message" : "Utilisateur non trouvé: 9999", + "timestamp" : "2026-02-25T10:44:56.210145942", "status" : 404, - "error" : "Not Found", - "message" : "User not found: 9999", - "timestamp" : "2026-01-29T10:49:52.060797593" + "error" : "Not Found" } @@ -4043,7 +3955,8 @@

    2.9 Downgrade Admin to Manager

    PUT /users/4/downgrade-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkzLCJleHAiOjE3Njk2ODQwOTMsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.NGkozMagAfxoIFExlWjiNaCKP1qAPUlEFj8fmlPjY8Y
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk3LCJleHAiOjE3NzIwMTY1OTcsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.hAGhO5SjEIs-btbbf_WFDNOgY6v2NkCZD8T8vTidM4U
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -4055,7 +3968,7 @@

    2.9 Downgrade Admin to Manager

    Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: text/plain;charset=UTF-8 -Content-Length: 34 +Content-Length: 40 X-Content-Type-Options: nosniff X-XSS-Protection: 0 Cache-Control: no-cache, no-store, max-age=0, must-revalidate @@ -4063,7 +3976,7 @@

    2.9 Downgrade Admin to Manager

    Expires: 0 X-Frame-Options: DENY -Admin role downgraded successfully +Rôle d''admin rétrogradé avec succès
    @@ -4084,6 +3997,7 @@
    2.9.1.1 Missing Authorization Hea
    PUT /users/4/downgrade-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -4101,10 +4015,10 @@
    2.9.1.1 Missing Authorization Hea Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 +Content-Length: 66 { - "message" : "Full authentication is required to access this resource" + "message" : "Jeton d''authentification invalide ou manquant" } @@ -4123,6 +4037,7 @@
    2.9.1.2 Malformed Token
    PUT /users/4/downgrade-admin HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Authorization: Bearer this.is.not.a.valid.token
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -4140,11 +4055,11 @@
    2.9.1.2 Malformed Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Invalid JWT token" + "message" : "Jeton JWT invalide", + "error" : "INVALID_TOKEN" } @@ -4164,7 +4079,8 @@

    2.10 Delete User

    DELETE /users/1 HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkzLCJleHAiOjE3Njk2ODQwOTMsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.NGkozMagAfxoIFExlWjiNaCKP1qAPUlEFj8fmlPjY8Y
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk3LCJleHAiOjE3NzIwMTY1OTcsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.hAGhO5SjEIs-btbbf_WFDNOgY6v2NkCZD8T8vTidM4U
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -4182,11 +4098,11 @@

    2.10 Delete User

    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 90 +Content-Length: 99 { - "deletedUserLogin" : "test.user@test.com", - "message" : "User deleted successfully" + "message" : "Utilisateur supprimé avec succès", + "deletedUserLogin" : "test.user@test.com" } @@ -4208,6 +4124,7 @@
    2.10.1.1 Missing Authorization H
    DELETE /users/1 HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -4225,10 +4142,10 @@
    2.10.1.1 Missing Authorization H Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 75 +Content-Length: 66 { - "message" : "Full authentication is required to access this resource" + "message" : "Jeton d''authentification invalide ou manquant" } @@ -4247,6 +4164,7 @@
    2.10.1.2 Malformed Token
    DELETE /users/1 HTTP/1.1
     Content-Type: application/json;charset=UTF-8
     Authorization: Bearer this.is.not.a.valid.token
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -4264,11 +4182,11 @@
    2.10.1.2 Malformed Token
    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 66 +Content-Length: 67 { - "error" : "INVALID_TOKEN", - "message" : "Invalid JWT token" + "message" : "Jeton JWT invalide", + "error" : "INVALID_TOKEN" } @@ -4288,7 +4206,8 @@

    2.11 Delete User (For Restore)

    DELETE /users/1 HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkzLCJleHAiOjE3Njk2ODQwOTMsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.NGkozMagAfxoIFExlWjiNaCKP1qAPUlEFj8fmlPjY8Y
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk3LCJleHAiOjE3NzIwMTY1OTcsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.hAGhO5SjEIs-btbbf_WFDNOgY6v2NkCZD8T8vTidM4U
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -4306,11 +4225,11 @@

    2.11 Delete User (For Restore)

    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 90 +Content-Length: 99 { - "deletedUserLogin" : "test.user@test.com", - "message" : "User deleted successfully" + "message" : "Utilisateur supprimé avec succès", + "deletedUserLogin" : "test.user@test.com" } @@ -4328,7 +4247,8 @@

    2.12 Permanently Delete User

    DELETE /users/1/permanent HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkzLCJleHAiOjE3Njk2ODQwOTMsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.NGkozMagAfxoIFExlWjiNaCKP1qAPUlEFj8fmlPjY8Y
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk3LCJleHAiOjE3NzIwMTY1OTcsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.hAGhO5SjEIs-btbbf_WFDNOgY6v2NkCZD8T8vTidM4U
    +Accept-Language: fr-fr
     Host: localhost:8080
    @@ -4346,11 +4266,11 @@

    2.12 Permanently Delete User

    Pragma: no-cache Expires: 0 X-Frame-Options: DENY -Content-Length: 89 +Content-Length: 102 { - "deletedUserLogin" : "test.user@test.com", - "message" : "User deleted permanently" + "message" : "Utilisateur supprimé définitivement", + "deletedUserLogin" : "test.user@test.com" } @@ -4368,7 +4288,8 @@

    2.13 Restore Deleted User

    PUT /users/1/restore HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzY5NjgzNzkzLCJleHAiOjE3Njk2ODQwOTMsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.NGkozMagAfxoIFExlWjiNaCKP1qAPUlEFj8fmlPjY8Y
    +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LmFkbWluQHRlc3QuY29tIiwidHlwIjoiYWNjZXNzIiwiaWF0IjoxNzcyMDE2Mjk3LCJleHAiOjE3NzIwMTY1OTcsImZpcnN0TmFtZSI6IlRlc3QiLCJsYXN0TmFtZSI6IkFkbWluIiwibWFpblJvbGUiOiJBRE1JTiIsInBlcm1pc3Npb25zIjpbInVzZXI6ZGVsZXRlIiwidXNlcjpyZWFkIiwidXNlcjp3cml0ZSIsInVzZXI6dXBkYXRlIiwiUk9MRV9BRE1JTiJdfQ.hAGhO5SjEIs-btbbf_WFDNOgY6v2NkCZD8T8vTidM4U
    +Accept-Language: en-us
     Host: localhost:8080
    @@ -4380,7 +4301,7 @@

    2.13 Restore Deleted User

    Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: text/plain;charset=UTF-8 -Content-Length: 26 +Content-Length: 34 X-Content-Type-Options: nosniff X-XSS-Protection: 0 Cache-Control: no-cache, no-store, max-age=0, must-revalidate @@ -4388,7 +4309,7 @@

    2.13 Restore Deleted User

    Expires: 0 X-Frame-Options: DENY -User restored successfully +Utilisateur restauré avec succès
    @@ -4400,7 +4321,7 @@

    2.13 Restore Deleted User

    diff --git a/docs/process-documentation.md b/docs/process-documentation.md index ed0032a4..69a71f2d 100644 --- a/docs/process-documentation.md +++ b/docs/process-documentation.md @@ -16,7 +16,7 @@ - [1.5 Main Java Modules (`main/java`)](#15-main-java-modules-mainjava) - [1.6 Security Module (`main/java/security`)](#16-security-module-mainjavasecurity) - [1.7 Auth Module (`main/java/auth`)](#17-auth-module-mainjavaauth) - - [1.8 Users Module (`main/java/users`)](#18-users-module-mainjavausers) + - [1.8 Users Module (`main/java/user`)](#18-users-module-mainjavauser) - [1.9 Configuration Module (`main/java/config`)](#19-configuration-module-mainjavaconfig) - [1.10 Error and Exception Management (`main/java/app`)](#110-error-and-exception-management-mainjavaapp) - [1.11 Test Structure (`test/java`)](#111-test-structure-testjava) @@ -81,7 +81,7 @@ graph TD **Tools & Dependencies:** - **Java / OpenJDK:** 21 -- **Spring Boot:** 3.3.5 +- **Spring Boot:** 3.5.8 - **Maven:** 3.9+ - **MariaDB:** 11.4 - **Docker Desktop:** Latest @@ -91,9 +91,9 @@ graph TD - **Spring Security:** Authentication and authorization framework - **Spring Data JPA:** Database access and ORM - **Spring OAuth2 Client:** Microsoft Entra ID (Azure AD) integration -- **Auth0 Java-JWT (4.3.0):** JWT token generation and validation -- **MapStruct (1.5.5):** Java bean mappings and DTO conversions -- **Lombok (1.18.36):** Reduces boilerplate code +- **Auth0 Java-JWT (4.4.0):** JWT token generation and validation +- **MapStruct (1.6.3):** Java bean mappings and DTO conversions +- **Lombok (1.18.38):** Reduces boilerplate code - **Spring REST Docs (3.0.1):** API documentation generation - **Jakarta Validation:** Bean validation and custom constraints - **Dotenv Java:** Environment variable management @@ -153,7 +153,7 @@ Contains test classes for unit and integration tests. | `app` | Global error and exception handling used throughout the application. | | `auth` | Handles authorization processes such as login and registration. | | `security` | Security-related classes: JWT filters, password encoding, and authentication management. | -| `users` | Manages user profiles, roles, and permissions. | +| `user` | Manages user profiles, roles, and permissions. | | `AuthApplication.java` | Main Spring Boot entry point containing the `main()` method. Run the project from this class. | --- @@ -189,7 +189,7 @@ sequenceDiagram alt Public endpoint Controller->>Client: Return HTTP response else Protected endpoint - Controller-->>Client: 403 Forbidden (Access Denied) + Controller-->>Client: 401 Unauthorized (Missing or invalid authentication token) end end ``` @@ -246,10 +246,10 @@ sequenceDiagram AuthController->>Client: 200 OK with UserDto + tokens else Password invalid PasswordEncoder->>UserService: false - UserService-->>Client: 401 Unauthorized (Invalid credentials) + UserService-->>Client: 401 Unauthorized (error.authorisation.invalid.credentials) end else User not found - UserRepository-->>Client: 401 Unauthorized (Invalid credentials) + UserRepository-->>Client: 401 Unauthorized (error.authorisation.invalid.credentials) end ``` @@ -309,7 +309,7 @@ _Sequence Diagram showing the logout flow and token invalidation._ --- -### 1.8 Users Module (`main/java/users`) +### 1.8 Users Module (`main/java/user`) ```mermaid classDiagram @@ -380,7 +380,7 @@ classDiagram +List permissions = new ArrayList<>() } - class SignupDto { + class SignUpDto { <> +String firstName +String lastName @@ -393,7 +393,7 @@ classDiagram class UserMapper { <> +UserDto toUserDto(User user) - +User signUpToUser(SignupDto signupDto) + +User signUpToUser(SignUpDto signUpDto) +List authoritiesToPermissions(Collection authorities) } @@ -406,7 +406,7 @@ classDiagram RoleEnum --> "0..*" PermissionEnum : defines UserMapper ..> User : uses UserMapper ..> UserDto : creates - UserMapper ..> SignupDto : uses + UserMapper ..> SignUpDto : uses User ..|> UserDetails ``` @@ -445,7 +445,7 @@ sequenceDiagram UserService->>UserMapper: toUserDto(user) UserMapper-->>UserService: UserDto UserService-->>UserController: UserDto - UserController-->>Client: ResponseEntity("User promoted to manager successfully") + UserController-->>Client: ResponseEntity(message.user.promoted.manager) ``` _Sequence Diagram showing an example of the user management flow._ @@ -699,7 +699,7 @@ Example claims that can be extracted from the Azure token: | ------ | -------------------- | ------------- | ---------------------------------------------- | | POST | `/auth/login` | No | Authenticate user and receive JWT tokens | | POST | `/auth/register` | No | Register a new user account | -| POST | `/auth/refresh` | Yes | Refresh access token using refresh token | +| POST | `/auth/refresh` | No | Refresh access token using refresh token | | PUT | `/auth/update-password` | Yes | Update current user's password | | POST | `/auth/logout` | Yes | Logout and invalidate refresh tokens | @@ -708,7 +708,7 @@ Example claims that can be extracted from the Azure token: | Method | Endpoint | Auth Required | Description | | ------ | ------------------------------ | ------------- | ---------------------------------------- | | GET | `/oauth2/authorization/azure` | No | Redirect to Microsoft login page | -| GET | `/oauth2/success` | No | Callback endpoint after Azure login | +| GET | `/oauth2/success` | Yes | Callback endpoint after Azure login | ### 2.3 User Management Endpoints (`/users`) diff --git a/src/asciidoc/index.adoc b/src/asciidoc/index.adoc index 65a435aa..5fcb4e2c 100644 --- a/src/asciidoc/index.adoc +++ b/src/asciidoc/index.adoc @@ -395,7 +395,7 @@ include::auth/logout-expired-token/http-response.adoc[] It returns a 401 Unauthorized error indicating that the token has expired. -=== 1.5 Set Password +=== 1.5 Update Password This is an example output for the `PUT /auth/update-password` endpoint. .request @@ -404,7 +404,7 @@ include::auth/update-password/http-request.adoc[] .response include::auth/update-password/http-response.adoc[] -It sets a password for a user account using a token. +It updates the password for the authenticated user. ==== 1.5.1 Error Response - 400 - Bad Request These are example outputs for the `PUT /auth/update-password` endpoint for bad request. @@ -434,45 +434,6 @@ include::auth/update-password-missing-token/http-response.adoc[] It returns a 401 Unauthorized error indicating that full authentication is required. -=== 1.6 Update Password -This is an example output for the `PUT /auth/update-password` endpoint. - -.request -include::auth/update-password/http-request.adoc[] - -.response -include::auth/update-password/http-response.adoc[] - -It sets a new password for the authenticated user. - -==== 1.6.1 Error Response - 400 - Bad Request -These are example outputs for the `PUT /auth/update-password` endpoint for bad request. - -===== 1.6.1.1 Missing Body -This is an example output when the request body is missing. - -.request -include::auth/update-password-missing-body/http-request.adoc[] - -.response -include::auth/update-password-missing-body/http-response.adoc[] - -It returns a 400 Bad Request error indicating that the request body is missing. - -==== 1.6.2 Error Response - 401 - Unauthorised -These are example outputs for the `PUT /auth/update-password` endpoint for unauthorized access. - -===== 1.6.2.1 Missing Token -This is an example output when the request token is missing. - -.request -include::auth/update-password-missing-token/http-request.adoc[] - -.response -include::auth/update-password-missing-token/http-response.adoc[] - -It returns a 401 Unauthorized error indicating that full authentication is required. - == 2 User Endpoints === 2.1 Get Authenticated User @@ -603,7 +564,7 @@ It promotes a user to the "MANAGER" role. ==== 2.5.1 Error Response - 401 - Unauthorized These are example outputs for the `PUT /users/{userId}/promote-manager` endpoint for unauthorized access. -===== 2.3.1.1 Missing Authorization Header +===== 2.5.1.1 Missing Authorization Header This is an example output when the Authorization header is missing in the request. .request @@ -614,7 +575,7 @@ include::users/promote-manager-missing-authorization/http-response.adoc[] It returns a 401 Unauthorized error indicating that full authentication is required. -===== 2.3.1.2 Malformed Token +===== 2.5.1.2 Malformed Token This is an example output when the token provided is malformed. .request @@ -625,10 +586,10 @@ include::users/promote-manager-malformed-token/http-response.adoc[] It returns a 401 Unauthorized error indicating that the token is invalid. -==== 2.3.2 Error Response - 403 - Forbidden +==== 2.5.2 Error Response - 403 - Forbidden These are example outputs for the `PUT /users/{userId}/promote-manager` endpoint for forbidden access. -===== 2.3.2.1 Non-Admin User - Promote User to Manager +===== 2.5.2.1 Non-Admin User - Promote User to Manager This is an example output when a non-admin user attempts to promote a user to manager. .request @@ -639,10 +600,10 @@ include::users/promote-manager-non-admin/http-response.adoc[] It returns a 403 Forbidden error indicating that access is denied. -==== 2.3.3 Error Response - 404 - Not Found +==== 2.5.3 Error Response - 404 - Not Found These are example outputs for the `PUT /users/{userId}/promote-manager` endpoint for not found errors. -===== 2.3.3.1 User Not Found +===== 2.5.3.1 User Not Found This is an example output when the user to be promoted is not found. .request @@ -653,10 +614,10 @@ include::users/promote-manager-user-not-found/http-response.adoc[] It returns a 404 Not Found error indicating that the user was not found. -==== 2.3.4 Error Response - 409 - Conflict +==== 2.5.4 Error Response - 409 - Conflict These are example outputs for the `PUT /users/{userId}/promote-manager` endpoint for conflict errors. -===== 2.3.4.1 User Already Manager +===== 2.5.4.1 User Already Manager This is an example output when the user to be promoted is already a manager. .request @@ -667,7 +628,7 @@ include::users/promote-manager-user-already-manager/http-response.adoc[] It returns a 409 Conflict error indicating that the user is already a manager. -===== 2.3.4.2 User Already Admin +===== 2.5.4.2 User Already Admin This is an example output when the user to be promoted is already an admin. .request diff --git a/src/main/java/ch/sectioninformatique/auth/app/exceptions/GlobalExceptionHandler.java b/src/main/java/ch/sectioninformatique/auth/app/exceptions/GlobalExceptionHandler.java index 3062c548..9416499d 100644 --- a/src/main/java/ch/sectioninformatique/auth/app/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/ch/sectioninformatique/auth/app/exceptions/GlobalExceptionHandler.java @@ -1,98 +1,219 @@ package ch.sectioninformatique.auth.app.exceptions; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.Map; - +/** + * Global exception handler for the application. + * Catches both custom AppExceptions and common Spring exceptions to return + * consistent error responses. + * + * For AppExceptions, it uses the provided HTTP status and resolves a localized + * message if available. + * For validation errors, it returns a generic localized message and exposes + * per-field details in a structured fieldErrors object. + * For other exceptions, it returns a generic error message with the appropriate + * HTTP status. + * + * This ensures that clients receive clear and consistent error information for + * all types of exceptions. + */ @ControllerAdvice +// Handlers first, helpers last for a top-down read. public class GlobalExceptionHandler { - // Helper method to format responses - private ResponseEntity buildResponse(HttpStatus status, String message) { - return ResponseEntity.status(status).body( - Map.of( - "timestamp", LocalDateTime.now().toString(), - "status", status.value(), - "error", status.getReasonPhrase(), - "message", message)); + private static final String VALIDATION_FAILED_MESSAGE_KEY = "error.validation.failed"; + + // MessageSource is used to resolve localized messages for exceptions that + // implement MessageKeyProvider + private final MessageSource messageSource; + + // Constructor injection of MessageSource allows for better testability and + // decoupling from the Spring context + public GlobalExceptionHandler(MessageSource messageSource) { + this.messageSource = messageSource; } - // ------------------------------- - // App exceptions - // ------------------------------- + /** + * Handles custom application exceptions (AppException and its subclasses). + * Returns a structured error response with the HTTP status and a localized + * message if available. + * + * @param ex The AppException to handle + * @return ResponseEntity containing the error details and appropriate HTTP + * status + */ @ExceptionHandler(AppException.class) public ResponseEntity handleAppException(AppException ex) { - return buildResponse(ex.getStatus(), ex.getMessage()); + return buildResponse(ex.getStatus(), resolveAppExceptionMessage(ex)); } - // ------------------------------- - // Spring built-in Errors - // ------------------------------- + /** + * Handles validation errors that occur when @Valid annotated request bodies + * fail validation. + * Uses a generic top-level message and returns detailed validation + * information in fieldErrors for client-side field mapping. + * + * @param ex The MethodArgumentNotValidException to handle + * @return ResponseEntity containing the error details and appropriate HTTP + * status + */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidationErrors(MethodArgumentNotValidException ex) { - // Collect ALL field errors, not just the first one - Map fieldErrors = new HashMap<>(); - ex.getBindingResult().getFieldErrors() - .forEach(error -> fieldErrors.put(error.getField(), error.getDefaultMessage())); - - // Create a single message combining all field errors for backward compatibility - String combinedMessage = fieldErrors.entrySet().stream() - .map(entry -> entry.getKey() + ": " + entry.getValue()) - .reduce((e1, e2) -> e1 + "; " + e2) - .orElse("Validation failed"); - - // Build response with both message and detailed fieldErrors - Map response = new HashMap<>(); - response.put("timestamp", LocalDateTime.now()); - response.put("status", HttpStatus.BAD_REQUEST.value()); - response.put("error", "Validation Failed"); - response.put("message", combinedMessage); + + // Collect detailed validation errors keyed by field name. + Map fieldErrors = ex.getBindingResult().getFieldErrors().stream() + .collect(Collectors.toMap( + FieldError::getField, + this::resolveValidationFieldError, + (existing, replacement) -> replacement)); + + // Keep the top-level message generic and localized. + String genericMessage = msg(VALIDATION_FAILED_MESSAGE_KEY); + + // Include fieldErrors as the source of truth for frontend rendering. + Map response = new java.util.LinkedHashMap<>( + errorResponse(HttpStatus.BAD_REQUEST, genericMessage)); response.put("fieldErrors", fieldErrors); return ResponseEntity.badRequest().body(response); } + /** + * Handles cases where the client sends a request with an unsupported media + * type. + * Returns a structured error response indicating the unsupported media type and + * supported types. + * + * @param ex The HttpMediaTypeNotSupportedException to handle + * @return ResponseEntity containing the error details and appropriate HTTP + * status + */ @ExceptionHandler(HttpMediaTypeNotSupportedException.class) public ResponseEntity handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) { return buildResponse(HttpStatus.UNSUPPORTED_MEDIA_TYPE, ex.getMessage()); } + /** + * Handles cases where a required request parameter is missing. + * Returns a structured error response indicating the missing parameter. + * + * @param ex The MissingServletRequestParameterException to handle + * @return ResponseEntity containing the error details and appropriate HTTP + * status + */ @ExceptionHandler(MissingServletRequestParameterException.class) public ResponseEntity handleMissingParams(MissingServletRequestParameterException ex) { - return buildResponse(HttpStatus.BAD_REQUEST, ex.getParameterName() + " parameter is missing"); + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } + /** + * Handles cases where the request body is not readable, typically due to + * malformed JSON. + * Returns a structured error response indicating the issue with the request + * body. + * + * @param ex The HttpMessageNotReadableException to handle + * @return ResponseEntity containing the error details and appropriate HTTP + * status + */ @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleMalformedJson(HttpMessageNotReadableException ex) { - String message = "Malformed or missing JSON request body"; - - // Extract more specific error information if available - Throwable cause = ex.getCause(); - if (cause != null) { - String causeMessage = cause.getMessage(); - // Provide more specific guidance based on the parsing error - if (causeMessage != null) { - if (causeMessage.contains("Unexpected end-of-input")) { - message = "JSON is incomplete - missing closing bracket or quote"; - } else if (causeMessage.contains("Unexpected character")) { - message = "JSON contains invalid character - check for unescaped quotes or missing commas"; - } else if (causeMessage.contains("cannot deserialize")) { - message = "Invalid value type for a field - check your data types match the schema"; - } else if (causeMessage.contains("No content to map")) { - message = "Empty or missing request body"; - } - } + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /** + * Helper method to construct a structured error response with a timestamp, + * status code, error reason, and message. + * This method centralizes the error response format for consistency across all + * exception handlers. + * + * @param status The HTTP status to include in the response + * @param message The error message to include in the response + * @return A map containing the structured error response + */ + private Map errorResponse(HttpStatus status, String message) { + return Map.of( + "timestamp", LocalDateTime.now(), + "status", status.value(), + "error", status.getReasonPhrase(), + "message", message); + } + + /** + * Helper method to build a ResponseEntity with a structured error response + * body. + * This method uses the errorResponse helper to create a consistent response + * format and sets the appropriate + * HTTP status code for the response. + * + * @param status The HTTP status to set for the response + * @param message The error message to include in the response body + * @return ResponseEntity containing the structured error response and + * appropriate HTTP status + */ + private ResponseEntity buildResponse(HttpStatus status, String message) { + return ResponseEntity.status(status).body(errorResponse(status, message)); + } + + /** + * Helper method to resolve a localized message for an AppException that + * implements MessageKeyProvider. + * If the exception does not implement MessageKeyProvider, it returns a generic + * unexpected error message + * + * @param key The message key to resolve from the MessageSource + * @param args Optional arguments to include in the message formatting + * @return The resolved localized message based on the current locale, or a + * generic error message if the key is not found + */ + private String msg(String key, Object... args) { + return messageSource.getMessage(key, args, LocaleContextHolder.getLocale()); + } + + /** + * Helper method to resolve a localized message for an AppException that + * implements MessageKeyProvider. + * If the exception does not implement MessageKeyProvider, it returns a generic + * unexpected error message. + * + * @param ex The AppException for which to resolve the message + * @return The resolved localized message based on the exception's message key + * and arguments, or a generic error message if the exception does not + * provide a message key + */ + private String resolveAppExceptionMessage(AppException ex) { + if (!(ex instanceof MessageKeyProvider)) { + return msg("error.unexpected"); } - - return buildResponse(HttpStatus.BAD_REQUEST, message); + + MessageKeyProvider provider = (MessageKeyProvider) ex; + return msg(provider.getMessageKey(), provider.getMessageArgs()); + } + + /** + * Helper method to resolve a validation field error message. + * If the field error has a default message, it returns that message; otherwise, + * it returns the field name. + * + * @param error The FieldError to resolve + * @return The resolved validation error message + */ + private String resolveValidationFieldError(FieldError error) { + String defaultMessage = error.getDefaultMessage(); + return defaultMessage != null ? defaultMessage : error.getField(); } } diff --git a/src/main/java/ch/sectioninformatique/auth/app/exceptions/MessageKeyProvider.java b/src/main/java/ch/sectioninformatique/auth/app/exceptions/MessageKeyProvider.java new file mode 100644 index 00000000..58910982 --- /dev/null +++ b/src/main/java/ch/sectioninformatique/auth/app/exceptions/MessageKeyProvider.java @@ -0,0 +1,27 @@ +package ch.sectioninformatique.auth.app.exceptions; + +/** + * Contract for providing message keys and arguments for localization. + */ +public interface MessageKeyProvider { + // Reusable empty arguments array to avoid unnecessary allocations + Object[] NO_ARGS = new Object[0]; + + /** + * Returns the message key for this exception, which can be used to look up + * a localized error message. + * + * @return The message key as a String + */ + String getMessageKey(); + + /** + * Returns the arguments for the message key, which can be used to format + * the localized error message. + * + * @return An array of arguments for the message key + */ + default Object[] getMessageArgs() { + return NO_ARGS; + } +} diff --git a/src/main/java/ch/sectioninformatique/auth/auth/AuthController.java b/src/main/java/ch/sectioninformatique/auth/auth/AuthController.java index 49459302..a84d5dd9 100644 --- a/src/main/java/ch/sectioninformatique/auth/auth/AuthController.java +++ b/src/main/java/ch/sectioninformatique/auth/auth/AuthController.java @@ -11,6 +11,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -23,7 +25,6 @@ import org.springframework.web.bind.annotation.RestController; import com.auth0.jwt.interfaces.DecodedJWT; -import ch.sectioninformatique.auth.app.exceptions.AppException; import ch.sectioninformatique.auth.security.UserAuthenticationProvider; import ch.sectioninformatique.auth.user.UserDto; import ch.sectioninformatique.auth.user.UserService; @@ -54,6 +55,7 @@ public class AuthController { private final UserService userService; private final UserAuthenticationProvider userAuthenticationProvider; + private final MessageSource messageSource; /* * Refresh token lifetime (e.g., "30d" for 30 days), configured via environment @@ -121,9 +123,7 @@ public ResponseEntity refreshLogin(@CookieValue("refresh_token DecodedJWT jwt = userAuthenticationProvider.validateRefreshToken(refreshToken); String login = jwt.getSubject(); - if (!userService.validateRefreshToken(login, refreshToken)) { - throw new AppException("Invalid refresh token", HttpStatus.UNAUTHORIZED); - } + userService.assertValidRefreshToken(login, refreshToken); UserDto user = userService.findByLogin(login); @@ -197,12 +197,21 @@ public ResponseEntity updatePassword(@RequestBody @Valid PasswordUpdateDto pa UserDto currentUser = (UserDto) authentication.getPrincipal(); if (currentUser == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid token"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(messageSource.getMessage( + "error.security.token.invalid", + null, + LocaleContextHolder.getLocale())); } userService.updatePassword(currentUser.getLogin(), passwords); // store securely (hashed!) - return ResponseEntity.ok(Map.of("message", "Password updated successfully")); + return ResponseEntity.ok(Map.of( + "message", + messageSource.getMessage( + "message.password.updated", + null, + LocaleContextHolder.getLocale()))); } /** @@ -264,6 +273,11 @@ public ResponseEntity logout(HttpServletRequest request) { return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .body(Map.of("message", "Logged out successfully")); + .body(Map.of( + "message", + messageSource.getMessage( + "message.logout.success", + null, + LocaleContextHolder.getLocale()))); } } diff --git a/src/main/java/ch/sectioninformatique/auth/auth/AuthExceptions.java b/src/main/java/ch/sectioninformatique/auth/auth/AuthExceptions.java index 041007b7..dbc0be80 100644 --- a/src/main/java/ch/sectioninformatique/auth/auth/AuthExceptions.java +++ b/src/main/java/ch/sectioninformatique/auth/auth/AuthExceptions.java @@ -3,6 +3,7 @@ import org.springframework.http.HttpStatus; import ch.sectioninformatique.auth.app.exceptions.AppException; +import ch.sectioninformatique.auth.app.exceptions.MessageKeyProvider; /** * Authentication-related exceptions for the auth package. @@ -12,9 +13,28 @@ public class AuthExceptions { /** * Thrown when provided credentials are invalid. */ - public static class InvalidCredentialsException extends AppException { + public static class InvalidCredentialsException extends AppException implements MessageKeyProvider { public InvalidCredentialsException() { - super("Invalid credentials", HttpStatus.UNAUTHORIZED); + super(HttpStatus.UNAUTHORIZED); + } + + @Override + public String getMessageKey() { + return "error.authorisation.invalid.credentials"; + } + } + + /** + * Thrown when the refresh token is invalid. + */ + public static class InvalidRefreshTokenException extends AppException implements MessageKeyProvider { + public InvalidRefreshTokenException() { + super(HttpStatus.UNAUTHORIZED); + } + + @Override + public String getMessageKey() { + return "error.security.refresh.token.invalid"; } } } diff --git a/src/main/java/ch/sectioninformatique/auth/auth/CredentialsDto.java b/src/main/java/ch/sectioninformatique/auth/auth/CredentialsDto.java index 51ef2c86..d2aa4ae3 100644 --- a/src/main/java/ch/sectioninformatique/auth/auth/CredentialsDto.java +++ b/src/main/java/ch/sectioninformatique/auth/auth/CredentialsDto.java @@ -17,11 +17,11 @@ * @param password The user's password as a character array */ public record CredentialsDto( - @NotBlank(message = "Login is required") - @Email(message = "Login must be a valid email format") + @NotBlank() + @Email String login, - @NotNull(message = "Password is required") - @Size(min = 8, max = 72, message = "Password must be between 8 and 72 characters long") + @NotNull() + @Size(min = 8, max = 72) char[] password ) {} \ No newline at end of file diff --git a/src/main/java/ch/sectioninformatique/auth/auth/OAuth2Controller.java b/src/main/java/ch/sectioninformatique/auth/auth/OAuth2Controller.java index 1f500f67..dda32b14 100644 --- a/src/main/java/ch/sectioninformatique/auth/auth/OAuth2Controller.java +++ b/src/main/java/ch/sectioninformatique/auth/auth/OAuth2Controller.java @@ -7,6 +7,8 @@ import java.nio.charset.StandardCharsets; import java.util.Objects; import org.springframework.http.HttpStatus; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.web.bind.annotation.GetMapping; @@ -31,6 +33,7 @@ public class OAuth2Controller { private final UserAuthenticationProvider userAuthenticationProvider; private final UserService userService; + private final MessageSource messageSource; private static final Logger log = LoggerFactory.getLogger(OAuth2Controller.class); /** @@ -40,9 +43,11 @@ public class OAuth2Controller { * @param userService Service for user management */ public OAuth2Controller(UserAuthenticationProvider userAuthenticationProvider, - UserService userService) { + UserService userService, + MessageSource messageSource) { this.userAuthenticationProvider = userAuthenticationProvider; this.userService = userService; + this.messageSource = messageSource; } /** @@ -58,14 +63,16 @@ public OAuth2Controller(UserAuthenticationProvider userAuthenticationProvider, @GetMapping("/success") public void oauth2Success(OAuth2AuthenticationToken authentication, HttpServletResponse response) throws IOException { if (authentication == null) { - response.sendError(HttpStatus.UNAUTHORIZED.value(), "Authentication token is missing."); + String message = messageSource.getMessage("error.oauth2.missing.authentication", null, LocaleContextHolder.getLocale()); + response.sendError(HttpStatus.UNAUTHORIZED.value(), message); return; } // Retrieve OAuth2User principal from the authentication token. OAuth2User principal = (OAuth2User) authentication.getPrincipal(); if (principal == null) { - response.sendError(HttpStatus.UNAUTHORIZED.value(), "OAuth2 user details not found."); + String message = messageSource.getMessage("error.oauth2.user.not.found", null, LocaleContextHolder.getLocale()); + response.sendError(HttpStatus.UNAUTHORIZED.value(), message); return; } @@ -76,7 +83,12 @@ public void oauth2Success(OAuth2AuthenticationToken authentication, HttpServletR String familyName = principal.getAttribute("family_name"); if (Objects.isNull(email)) { - response.sendError(HttpStatus.UNAUTHORIZED.value(), "Required user attribute not found."); + String message = messageSource.getMessage( + "error.oauth2.missing.user.attribute", + new Object[] {"email"}, + LocaleContextHolder.getLocale() + ); + response.sendError(HttpStatus.UNAUTHORIZED.value(), message); return; } diff --git a/src/main/java/ch/sectioninformatique/auth/auth/PasswordNotReused.java b/src/main/java/ch/sectioninformatique/auth/auth/PasswordNotReused.java index c11e0822..66b287ed 100644 --- a/src/main/java/ch/sectioninformatique/auth/auth/PasswordNotReused.java +++ b/src/main/java/ch/sectioninformatique/auth/auth/PasswordNotReused.java @@ -22,7 +22,7 @@ @Constraint(validatedBy = PasswordNotReusedValidatorImpl.class) @Documented public @interface PasswordNotReused { - String message() default "New password must be different from current password"; + String message() default "{validation.password.not.reused}"; Class[] groups() default {}; diff --git a/src/main/java/ch/sectioninformatique/auth/auth/PasswordUpdateDto.java b/src/main/java/ch/sectioninformatique/auth/auth/PasswordUpdateDto.java index 52c18818..e65adb4d 100644 --- a/src/main/java/ch/sectioninformatique/auth/auth/PasswordUpdateDto.java +++ b/src/main/java/ch/sectioninformatique/auth/auth/PasswordUpdateDto.java @@ -16,10 +16,10 @@ */ @PasswordNotReused public record PasswordUpdateDto( - @NotNull(message = "Current password is required for verification") + @NotNull() char[] oldPassword, - @NotNull(message = "New password is required") - @Size(min = 8, max = 72, message = "Password must be between 8 and 72 characters") + @NotNull() + @Size(min = 8, max = 72) char[] newPassword ) {} \ No newline at end of file diff --git a/src/main/java/ch/sectioninformatique/auth/auth/SignUpDto.java b/src/main/java/ch/sectioninformatique/auth/auth/SignUpDto.java index 869f1200..7d6daee0 100644 --- a/src/main/java/ch/sectioninformatique/auth/auth/SignUpDto.java +++ b/src/main/java/ch/sectioninformatique/auth/auth/SignUpDto.java @@ -22,25 +22,23 @@ * @param password The user's password as a character array */ public record SignUpDto( - @NotBlank(message = "First name is required") + @NotBlank() @Pattern( - regexp = "^[\\p{L}][\\p{L} '\\-]*[\\p{L}]$", - message = "First name contains invalid characters (only letters, spaces, hyphens and apostrophes allowed)" + regexp = "^[\\p{L}][\\p{L} '\\-]*[\\p{L}]$" ) String firstName, - @NotBlank(message = "Last name is required") + @NotBlank(message = "{validation.signup.lastName.required}") @Pattern( - regexp = "^[\\p{L}][\\p{L} '\\-]*[\\p{L}]$", - message = "Last name contains invalid characters (only letters, spaces, hyphens and apostrophes allowed)" + regexp = "^[\\p{L}][\\p{L} '\\-]*[\\p{L}]$" ) String lastName, - @NotBlank(message = "Login is required") - @Email(message = "Login must be a valid email") + @NotBlank() + @Email() String login, - @NotNull(message = "Password is required") - @Size(min = 8, max = 72, message = "Password must be between 8 and 72 characters") + @NotNull() + @Size(min = 8, max = 72) char[] password ) {} diff --git a/src/main/java/ch/sectioninformatique/auth/config/LocaleConfig.java b/src/main/java/ch/sectioninformatique/auth/config/LocaleConfig.java new file mode 100644 index 00000000..33c3742c --- /dev/null +++ b/src/main/java/ch/sectioninformatique/auth/config/LocaleConfig.java @@ -0,0 +1,170 @@ +package ch.sectioninformatique.auth.config; + +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; +import org.springframework.web.servlet.i18n.SessionLocaleResolver; + +import java.io.IOException; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; + +/** + * Internationalization and localization configuration. + * + * This configuration provides a message source with automatic bundle discovery + * under messages, a default French locale with locale switching through the + * lang request parameter, and validation message resolution through the same + * message source. + */ +@Configuration +public class LocaleConfig implements WebMvcConfigurer { + + // Constants for message resource discovery and basename resolution + private static final String MESSAGE_RESOURCES_PATTERN = "classpath*:messages/**/*.properties"; + private static final String MESSAGE_SEGMENT = "/messages/"; + private static final String PROPERTIES_EXTENSION = ".properties"; + private static final String CLASSPATH_MESSAGES_PREFIX = "classpath:messages/"; + private static final String DEFAULT_MESSAGE_BASENAME = "classpath:messages/messages"; + + // Pattern to identify and remove locale suffixes from message basenames (e.g., _en, _en_US) + private static final Pattern LOCALE_SUFFIX_PATTERN = Pattern.compile("_[a-z]{2}(?:_[A-Z]{2})?$"); + + /** + * Configures the application message source used for internationalization. + * + * Message files matching MESSAGE_RESOURCES_PATTERN are discovered + * automatically and converted to Spring basenames. + * + * @return configured message source + */ + @Bean + public MessageSource messageSource() { + ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); + messageSource.setBasenames(resolveMessageBasenames()); + messageSource.setDefaultEncoding("UTF-8"); + messageSource.setCacheSeconds(3600); + return messageSource; + } + + /** + * Resolves all message basenames by scanning message property files from the + * classpath. + * + * @return discovered message basenames + */ + private String[] resolveMessageBasenames() { + try { + // Scan for all message property files matching the defined pattern + Resource[] resources = new PathMatchingResourcePatternResolver().getResources(MESSAGE_RESOURCES_PATTERN); + Set basenames = new TreeSet<>(); + + // Convert each resource to a Spring message basename by extracting the path relative to the messages segment and removing locale suffixes and file extension + for (Resource resource : resources) { + String basename = resolveResourceBasename(resource); + if (basename != null) { + basenames.add(basename); + } + } + + // Ensure at least the default basename is included if no resources were found + if (basenames.isEmpty()) { + basenames.add(DEFAULT_MESSAGE_BASENAME); + } + + return basenames.toArray(new String[0]); + } catch (IOException exception) { + throw new IllegalStateException("Unable to resolve i18n message bundles from classpath", exception); + } + } + + /** + * Resolves a message basename from a given resource by extracting the path + * relative to the messages segment and removing locale suffixes and file extension. + * + * @param resource message property file resource + * @return message basename corresponding to the resource, or null if the resource does not match expected patterns + * @throws IOException if the resource URL cannot be accessed + */ + private String resolveResourceBasename(Resource resource) throws IOException { + + // Get the resource URL and convert it to a consistent format for processing + String resourceUrl = resource.getURL().toString().replace('\\', '/'); + int messagesIndex = resourceUrl.lastIndexOf(MESSAGE_SEGMENT); + if (messagesIndex < 0) { + return null; + } + + // Extract the path relative to the messages segment and remove locale suffixes and file extension to get the Spring message basename + String relativePath = resourceUrl.substring(messagesIndex + MESSAGE_SEGMENT.length()); + if (!relativePath.endsWith(PROPERTIES_EXTENSION)) { + return null; + } + + // Remove the .properties extension + String withoutExtension = relativePath.substring(0, relativePath.length() - PROPERTIES_EXTENSION.length()); + String basenameWithoutLocale = LOCALE_SUFFIX_PATTERN.matcher(withoutExtension).replaceFirst(""); + return CLASSPATH_MESSAGES_PREFIX + basenameWithoutLocale; + } + + /** + * Defines the default locale used for message resolution. + * + * Clients can override it per request with the lang query parameter, + * for example /api/some-endpoint?lang=en. + * + * @return locale resolver configured with French as default locale + */ + @Bean + public LocaleResolver localeResolver() { + SessionLocaleResolver localeResolver = new SessionLocaleResolver(); + localeResolver.setDefaultLocale(Locale.FRANCE); + return localeResolver; + } + + /** + * Configures Bean Validation to use the same internationalized message source. + * + * @param messageSource application message source + * @return validator factory bean configured with the application message source + */ + @Bean + public LocalValidatorFactoryBean validator(MessageSource messageSource) { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.setValidationMessageSource(messageSource); + return validator; + } + + /** + * Creates an interceptor that switches locale based on the lang request + * parameter. + * + * @return locale change interceptor using the lang parameter + */ + @Bean + public LocaleChangeInterceptor localeChangeInterceptor() { + LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor(); + interceptor.setParamName("lang"); + return interceptor; + } + + /** + * Registers MVC interceptors related to localization. + * + * @param registry interceptor registry + */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(localeChangeInterceptor()); + } +} diff --git a/src/main/java/ch/sectioninformatique/auth/security/CorsConfigurationValidator.java b/src/main/java/ch/sectioninformatique/auth/security/CorsConfigurationValidator.java index 3c76be2d..80128cdd 100644 --- a/src/main/java/ch/sectioninformatique/auth/security/CorsConfigurationValidator.java +++ b/src/main/java/ch/sectioninformatique/auth/security/CorsConfigurationValidator.java @@ -22,7 +22,7 @@ * - Only necessary headers are allowed * - Environment-specific restrictions are enforced * - * Throws IllegalArgumentException if configuration is invalid. + * Throws an application exception if configuration is invalid. */ @Configuration @Slf4j @@ -72,7 +72,7 @@ public CorsConfigurationValidator(Environment environment) { * Validates CORS configuration at application startup. * Called automatically by Spring after bean construction. * - * @throws IllegalArgumentException if any CORS configuration is invalid + * @throws SecurityExceptions.CorsConfigurationException if any CORS configuration is invalid */ @PostConstruct public void validateCorsConfiguration() { @@ -85,8 +85,8 @@ public void validateCorsConfiguration() { validateMethods(); validateHeaders(); log.info("✓ CORS configuration is valid and secure"); - } catch (IllegalArgumentException e) { - log.error("✗ CORS configuration validation failed: {}", e.getMessage()); + } catch (SecurityExceptions.CorsConfigurationException e) { + log.error("✗ CORS configuration validation failed: {}", e.getMessageKey()); throw e; } } @@ -105,9 +105,8 @@ public void validateCorsConfiguration() { */ private void validateOrigins(boolean isDevOrTest) { if (allowedOrigins == null || allowedOrigins.length == 0) { - throw new IllegalArgumentException( - "CORS allowed-origins cannot be empty. Configure at least one origin in cors.allowed-origins" - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.allowed.origins.empty"); } Set uniqueOrigins = new HashSet<>(); @@ -118,10 +117,8 @@ private void validateOrigins(boolean isDevOrTest) { // Check for wildcard if ("*".equals(origin)) { if (!isDevOrTest) { - throw new IllegalArgumentException( - "Wildcard origin '*' is not allowed in production. " + - "Configure specific allowed origins in cors.allowed-origins" - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.origin.wildcard.production"); } log.warn("⚠ Wildcard CORS origin '*' configured (allowed only in dev/test)"); uniqueOrigins.add(origin); @@ -135,33 +132,32 @@ private void validateOrigins(boolean isDevOrTest) { // Check protocol is http or https if (!("http".equals(protocol) || "https".equals(protocol))) { - throw new IllegalArgumentException( - "Origin '" + origin + "' uses invalid protocol '" + protocol + "'. " + - "Only http and https protocols are allowed" - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.origin.invalid.protocol", + origin, + protocol); } // Check for localhost in production String host = uri.getHost(); if (!isDevOrTest && ("localhost".equals(host) || "127.0.0.1".equals(host))) { - throw new IllegalArgumentException( - "Origin '" + origin + "' points to localhost. " + - "Production should not allow localhost origins" - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.origin.localhost.production", + origin); } } catch (URISyntaxException e) { - throw new IllegalArgumentException( - "Origin '" + origin + "' is not a valid URL: " + e.getMessage() - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.origin.invalid.url", + origin, + e.getMessage()); } // Check for duplicates if (!uniqueOrigins.add(origin)) { - throw new IllegalArgumentException( - "Duplicate origin found: '" + origin + "'. " + - "Remove duplicates from cors.allowed-origins" - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.origin.duplicate", + origin); } log.debug("✓ Origin validated: {}", origin); @@ -181,9 +177,8 @@ private void validateOrigins(boolean isDevOrTest) { */ private void validateMethods() { if (allowedMethods == null || allowedMethods.length == 0) { - throw new IllegalArgumentException( - "CORS allowed-methods cannot be empty. Configure at least one method in cors.allowed-methods" - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.allowed.methods.empty"); } Set uniqueMethods = new HashSet<>(); @@ -193,18 +188,17 @@ private void validateMethods() { // Check if method is in whitelist if (!ALLOWED_HTTP_METHODS.contains(method)) { - throw new IllegalArgumentException( - "HTTP method '" + method + "' is not allowed for CORS. " + - "Allowed methods: " + ALLOWED_HTTP_METHODS - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.method.not.allowed", + method, + ALLOWED_HTTP_METHODS); } // Check for duplicates if (!uniqueMethods.add(method)) { - throw new IllegalArgumentException( - "Duplicate HTTP method found: '" + method + "'. " + - "Remove duplicates from cors.allowed-methods" - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.method.duplicate", + method); } log.debug("✓ Method validated: {}", method); @@ -226,9 +220,8 @@ private void validateMethods() { */ private void validateHeaders() { if (allowedHeaders == null || allowedHeaders.length == 0) { - throw new IllegalArgumentException( - "CORS allowed-headers cannot be empty. Configure at least one header in cors.allowed-headers" - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.allowed.headers.empty"); } Set uniqueHeaders = new HashSet<>(); @@ -238,28 +231,24 @@ private void validateHeaders() { // Check for wildcard if ("*".equals(header)) { - throw new IllegalArgumentException( - "Wildcard header '*' is not allowed for CORS. " + - "Specify individual header names in cors.allowed-headers. " + - "Allowed headers: " + ALLOWED_HEADER_NAMES - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.header.wildcard.not.allowed", + ALLOWED_HEADER_NAMES); } // Check if header is in whitelist if (!ALLOWED_HEADER_NAMES.contains(header)) { - throw new IllegalArgumentException( - "Header '" + header + "' is not in the whitelist of allowed CORS headers. " + - "Allowed headers: " + ALLOWED_HEADER_NAMES + ". " + - "If you need a custom header, add it to CorsConfigurationValidator.ALLOWED_HEADER_NAMES" - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.header.not.allowed", + header, + ALLOWED_HEADER_NAMES); } // Check for duplicates if (!uniqueHeaders.add(header)) { - throw new IllegalArgumentException( - "Duplicate header found: '" + header + "'. " + - "Remove duplicates from cors.allowed-headers" - ); + throw new SecurityExceptions.CorsConfigurationException( + "error.cors.header.duplicate", + header); } log.debug("Header validated: {}", header); diff --git a/src/main/java/ch/sectioninformatique/auth/security/CustomAccessDeniedHandler.java b/src/main/java/ch/sectioninformatique/auth/security/CustomAccessDeniedHandler.java index b2e03f4f..e4de094f 100644 --- a/src/main/java/ch/sectioninformatique/auth/security/CustomAccessDeniedHandler.java +++ b/src/main/java/ch/sectioninformatique/auth/security/CustomAccessDeniedHandler.java @@ -7,18 +7,25 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.NoSuchMessageException; import java.io.IOException; /** * Handles requests that are authenticated but not authorized (403 Forbidden). - * Returns a JSON response with a proper message so integration tests expecting - * a $.message field will pass. + * Returns a JSON response with the error.security.access.denied message. */ @Component public class CustomAccessDeniedHandler implements AccessDeniedHandler { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final MessageSource messageSource; + + public CustomAccessDeniedHandler(MessageSource messageSource) { + this.messageSource = messageSource; + } @Override public void handle(HttpServletRequest request, @@ -28,9 +35,28 @@ public void handle(HttpServletRequest request, response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setHeader("Content-Type", "application/json"); - String errorMessage = "You don't have the necessary rights to perform this action"; + String errorMessage = messageSource.getMessage( + "error.security.access.denied", + null, + LocaleContextHolder.getLocale() + ); if (accessDeniedException != null && accessDeniedException.getMessage() != null) { - errorMessage = accessDeniedException.getMessage(); + String exceptionMessage = accessDeniedException.getMessage(); + if (!exceptionMessage.isEmpty()) { + try { + errorMessage = messageSource.getMessage( + exceptionMessage, + null, + LocaleContextHolder.getLocale() + ); + } catch (NoSuchMessageException ignored) { + errorMessage = messageSource.getMessage( + "error.security.access.denied", + null, + LocaleContextHolder.getLocale() + ); + } + } } ErrorDto errorDto = new ErrorDto(errorMessage); diff --git a/src/main/java/ch/sectioninformatique/auth/security/JwtAuthFilter.java b/src/main/java/ch/sectioninformatique/auth/security/JwtAuthFilter.java index eb608e02..4773d905 100644 --- a/src/main/java/ch/sectioninformatique/auth/security/JwtAuthFilter.java +++ b/src/main/java/ch/sectioninformatique/auth/security/JwtAuthFilter.java @@ -10,6 +10,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.http.HttpHeaders; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.stereotype.Component; @@ -45,6 +47,11 @@ public class JwtAuthFilter extends OncePerRequestFilter { */ private final ObjectMapper mapper; + /** + * Message source for localized error messages. + */ + private final MessageSource messageSource; + /** * Processes each incoming request to validate JWT tokens. * This method: @@ -93,10 +100,26 @@ protected void doFilterInternal( response.setContentType("application/json;charset=UTF-8"); String message = switch (e.getClass().getSimpleName()) { - case "TokenExpiredException" -> "Token has expired"; - case "InvalidClaimException" -> "Token contains invalid claims"; - case "SignatureVerificationException" -> "Token signature is invalid"; - default -> "Invalid JWT token"; + case "TokenExpiredException" -> messageSource.getMessage( + "error.security.token.expired", + null, + LocaleContextHolder.getLocale() + ); + case "InvalidClaimException" -> messageSource.getMessage( + "error.security.token.invalid.claims", + null, + LocaleContextHolder.getLocale() + ); + case "SignatureVerificationException" -> messageSource.getMessage( + "error.security.token.invalid.signature", + null, + LocaleContextHolder.getLocale() + ); + default -> messageSource.getMessage( + "error.security.token.invalid", + null, + LocaleContextHolder.getLocale() + ); }; log.debug("JWT validation failed: {}", message, e); diff --git a/src/main/java/ch/sectioninformatique/auth/security/SecurityExceptions.java b/src/main/java/ch/sectioninformatique/auth/security/SecurityExceptions.java index a30dee14..cf47b706 100644 --- a/src/main/java/ch/sectioninformatique/auth/security/SecurityExceptions.java +++ b/src/main/java/ch/sectioninformatique/auth/security/SecurityExceptions.java @@ -3,6 +3,7 @@ import org.springframework.http.HttpStatus; import ch.sectioninformatique.auth.app.exceptions.AppException; +import ch.sectioninformatique.auth.app.exceptions.MessageKeyProvider; /** * Security and authorization-related exceptions for the security package. @@ -12,36 +13,136 @@ public class SecurityExceptions { /** * Thrown when a role with the given name is not found. */ - public static class RoleNotFoundException extends AppException { + public static class RoleNotFoundException extends AppException implements MessageKeyProvider { + private final RoleEnum role; + public RoleNotFoundException(RoleEnum role) { - super("Role not found: " + role.name(), HttpStatus.NOT_FOUND); + super(HttpStatus.NOT_FOUND); + this.role = role; + } + + @Override + public String getMessageKey() { + return "error.security.role.not.found"; + } + + @Override + public Object[] getMessageArgs() { + return new Object[] { role.name() }; + } + } + + /** + * Thrown when an Azure token is not from a trusted tenant. + */ + public static class TokenNotFromTrustedTenantException extends AppException implements MessageKeyProvider { + public TokenNotFromTrustedTenantException() { + super(HttpStatus.FORBIDDEN); + } + + @Override + public String getMessageKey() { + return "error.security.token.untrusted.tenant"; } } /** - * Thrown when a security validation fails or unauthorized access is attempted. + * Thrown when a required claim is missing from the JWT token. */ - public static class SecurityException extends AppException { - public SecurityException(String message) { - super(message, HttpStatus.FORBIDDEN); + public static class MissingJwtClaimException extends AppException implements MessageKeyProvider { + private final String claimName; + + public MissingJwtClaimException(String claimName) { + super(HttpStatus.FORBIDDEN); + this.claimName = claimName; + } + + @Override + public String getMessageKey() { + return "error.security.jwt.missing.claim"; + } + + @Override + public Object[] getMessageArgs() { + return new Object[] { claimName }; } } /** * Thrown when a user attempts an action they are not authorized to perform. */ - public static class UnauthorizedActionException extends AppException { - public UnauthorizedActionException(String message) { - super(message, HttpStatus.FORBIDDEN); + public static class UnauthorizedActionException extends AppException implements MessageKeyProvider { + public UnauthorizedActionException() { + super(HttpStatus.FORBIDDEN); + } + + @Override + public String getMessageKey() { + return "error.security.access.denied"; } } /** * Thrown when a user attempts an action they don't have permissions for due to insufficient rights. */ - public static class UserHasLowerRightsException extends AppException { + public static class UserHasLowerRightsException extends AppException implements MessageKeyProvider { + private final String login; + public UserHasLowerRightsException(String login) { - super("User has insufficient rights: " + login, HttpStatus.FORBIDDEN); + super(HttpStatus.FORBIDDEN); + this.login = login; + } + + public String getLogin() { + return login; + } + + @Override + public String getMessageKey() { + return "error.security.insufficient.rights"; + } + + @Override + public Object[] getMessageArgs() { + return new Object[] { login }; + } + } + + /** + * Thrown when the server's hash algorithm is unavailable. + */ + public static class HashAlgorithmUnavailableException extends AppException implements MessageKeyProvider { + public HashAlgorithmUnavailableException() { + super(HttpStatus.INTERNAL_SERVER_ERROR); + } + + @Override + public String getMessageKey() { + return "error.security.hash.algorithm.unavailable"; + } + } + + /** + * Thrown when CORS configuration is invalid. + */ + public static class CorsConfigurationException extends AppException implements MessageKeyProvider { + private final String messageKey; + private final Object[] args; + + public CorsConfigurationException(String messageKey, Object... args) { + super(HttpStatus.INTERNAL_SERVER_ERROR); + this.messageKey = messageKey; + this.args = args == null ? MessageKeyProvider.NO_ARGS : args; + } + + @Override + public String getMessageKey() { + return messageKey; + } + + @Override + public Object[] getMessageArgs() { + return args; } } } diff --git a/src/main/java/ch/sectioninformatique/auth/security/UserAuthenticationEntryPoint.java b/src/main/java/ch/sectioninformatique/auth/security/UserAuthenticationEntryPoint.java index d9643576..e9764fbc 100644 --- a/src/main/java/ch/sectioninformatique/auth/security/UserAuthenticationEntryPoint.java +++ b/src/main/java/ch/sectioninformatique/auth/security/UserAuthenticationEntryPoint.java @@ -8,9 +8,12 @@ import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; +import org.springframework.context.NoSuchMessageException; import java.io.IOException; @@ -29,6 +32,11 @@ public class UserAuthenticationEntryPoint implements AuthenticationEntryPoint { /** Object mapper for JSON serialization of error responses */ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final MessageSource messageSource; + + public UserAuthenticationEntryPoint(MessageSource messageSource) { + this.messageSource = messageSource; + } /** * Handles unauthenticated requests by sending a JSON response with an error message. @@ -38,7 +46,7 @@ public class UserAuthenticationEntryPoint implements AuthenticationEntryPoint { * - Content-Type: application/json header * - JSON body containing either: * - The specific authentication exception message if available - * - A default "Invalid or missing authentication token" message if no specific message is available + * - A default error.security.authentication.token.invalid.or.missing message if no specific message is available * * @param request The HTTP request that triggered the authentication failure * @param response The HTTP response to be sent back to the client @@ -54,11 +62,33 @@ public void commence( response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); - String errorMessage = "Authentication failed"; + String errorMessage = messageSource.getMessage( + "error.security.authentication.failed", + null, + LocaleContextHolder.getLocale() + ); if (authException != null) { - errorMessage = authException.getMessage(); - if (errorMessage == null || errorMessage.isEmpty()) { - errorMessage = "Invalid or missing authentication token"; + String exceptionMessage = authException.getMessage(); + if (exceptionMessage != null && !exceptionMessage.isEmpty()) { + try { + errorMessage = messageSource.getMessage( + exceptionMessage, + null, + LocaleContextHolder.getLocale() + ); + } catch (NoSuchMessageException ignored) { + errorMessage = messageSource.getMessage( + "error.security.authentication.token.invalid.or.missing", + null, + LocaleContextHolder.getLocale() + ); + } + } else { + errorMessage = messageSource.getMessage( + "error.security.authentication.token.invalid.or.missing", + null, + LocaleContextHolder.getLocale() + ); } } diff --git a/src/main/java/ch/sectioninformatique/auth/security/UserAuthenticationProvider.java b/src/main/java/ch/sectioninformatique/auth/security/UserAuthenticationProvider.java index 488bad7a..e10ed682 100644 --- a/src/main/java/ch/sectioninformatique/auth/security/UserAuthenticationProvider.java +++ b/src/main/java/ch/sectioninformatique/auth/security/UserAuthenticationProvider.java @@ -19,7 +19,7 @@ import ch.sectioninformatique.auth.user.UserDto; import ch.sectioninformatique.auth.user.UserService; -import ch.sectioninformatique.auth.security.SecurityExceptions.SecurityException; +import ch.sectioninformatique.auth.user.UserExceptions.UserNotFoundException; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -273,24 +273,24 @@ public Authentication validateTokenStrongly(String token) { log.debug("Built authorities for user {}: {}", user.getLogin(), authorities); return new UsernamePasswordAuthenticationToken(user, null, authorities); - } catch (Exception e) { + } catch (UserNotFoundException e) { // If user doesn't exist, create a new Azure user log.debug("User not found, creating new Azure user: {}", decoded.getSubject()); DecodedJWT decodedAzure = JWT.decode(token); String issuer = decodedAzure.getIssuer(); // Only verify issuer if both issuer and azureUri are present if (issuer != null && azureUri != null && !issuer.equals(azureUri)) { - throw new SecurityException("Token not from trusted Azure tenant"); + throw new SecurityExceptions.TokenNotFromTrustedTenantException(); } String firstName = decodedAzure.getClaim("firstName").asString(); String lastName = decodedAzure.getClaim("lastName").asString(); if (firstName == null || firstName.isBlank()) { - throw new SecurityException("JWT missing required claim: firstName"); + throw new SecurityExceptions.MissingJwtClaimException("firstName"); } if (lastName == null || lastName.isBlank()) { - throw new SecurityException("JWT missing required claim: lastName"); + throw new SecurityExceptions.MissingJwtClaimException("lastName"); } UserDto newUser = UserDto.builder() diff --git a/src/main/java/ch/sectioninformatique/auth/user/UserController.java b/src/main/java/ch/sectioninformatique/auth/user/UserController.java index 06dc58ee..3655c532 100644 --- a/src/main/java/ch/sectioninformatique/auth/user/UserController.java +++ b/src/main/java/ch/sectioninformatique/auth/user/UserController.java @@ -3,6 +3,8 @@ import java.util.List; import java.util.Map; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -33,14 +35,16 @@ public class UserController { /** Service for handling user-related operations */ private final UserService userService; + private final MessageSource messageSource; /** * Constructs a new UserController with the required service. * * @param userService Service for handling user-related operations */ - public UserController(UserService userService) { + public UserController(UserService userService, MessageSource messageSource) { this.userService = userService; + this.messageSource = messageSource; } /** @@ -118,7 +122,11 @@ public ResponseEntity> deletedUsers() { @PreAuthorize("hasAuthority('user:update')") public ResponseEntity restoreDeletedUser(@PathVariable Long userId) { userService.restoreDeletedUser(userId); - return ResponseEntity.ok().body("User restored successfully"); + return ResponseEntity.ok().body(messageSource.getMessage( + "message.user.restored", + null, + LocaleContextHolder.getLocale() + )); } /** @@ -136,7 +144,11 @@ public ResponseEntity restoreDeletedUser(@PathVariable Long userId) { public ResponseEntity promoteToManager(@PathVariable Long userId) { userService.promoteToManager(userId); - return ResponseEntity.ok().body("User promoted to manager successfully"); + return ResponseEntity.ok().body(messageSource.getMessage( + "message.user.promoted.manager", + null, + LocaleContextHolder.getLocale() + )); } @@ -155,7 +167,11 @@ public ResponseEntity promoteToManager(@PathVariable Long userId) { public ResponseEntity revokeManagerRole(@PathVariable Long userId) { userService.revokeManagerRole(userId); - return ResponseEntity.ok().body("Manager role revoked successfully"); + return ResponseEntity.ok().body(messageSource.getMessage( + "message.user.revoked.manager", + null, + LocaleContextHolder.getLocale() + )); } /** @@ -172,7 +188,11 @@ public ResponseEntity revokeManagerRole(@PathVariable Long userId) { @PutMapping("/{userId}/promote-admin") public ResponseEntity promoteToAdmin(@PathVariable Long userId) { userService.promoteToAdmin(userId); - return ResponseEntity.ok().body("Admin role assigned successfully"); + return ResponseEntity.ok().body(messageSource.getMessage( + "message.user.promoted.admin", + null, + LocaleContextHolder.getLocale() + )); } /** @@ -189,7 +209,11 @@ public ResponseEntity promoteToAdmin(@PathVariable Long userId) { @PutMapping("/{userId}/revoke-admin") public ResponseEntity revokeAdminRole(@PathVariable Long userId) { userService.revokeAdminRole(userId); - return ResponseEntity.ok().body("Admin role revoked successfully"); + return ResponseEntity.ok().body(messageSource.getMessage( + "message.user.revoked.admin", + null, + LocaleContextHolder.getLocale() + )); } /** @@ -206,7 +230,11 @@ public ResponseEntity revokeAdminRole(@PathVariable Long userId) { @PutMapping("/{userId}/downgrade-admin") public ResponseEntity downgradeAdminRole(@PathVariable Long userId) { userService.downgradeAdminRole(userId); - return ResponseEntity.ok().body("Admin role downgraded successfully"); + return ResponseEntity.ok().body(messageSource.getMessage( + "message.user.downgraded.admin", + null, + LocaleContextHolder.getLocale() + )); } /** @@ -224,7 +252,14 @@ public ResponseEntity downgradeAdminRole(@PathVariable Long userId) { public ResponseEntity delete(@PathVariable Long userId) { UserDto deletedUser = userService.deleteUser(userId); return ResponseEntity - .ok(Map.of("message", "User deleted successfully", "deletedUserLogin", deletedUser.getLogin())); + .ok(Map.of( + "message", + messageSource.getMessage( + "message.user.deleted", + null, + LocaleContextHolder.getLocale()), + "deletedUserLogin", + deletedUser.getLogin())); } /** @@ -242,6 +277,13 @@ public ResponseEntity delete(@PathVariable Long userId) { public ResponseEntity deletePermanent(@PathVariable Long userId) { UserDto deletedUser = userService.deletePermanentUser(userId); return ResponseEntity - .ok(Map.of("message", "User deleted permanently", "deletedUserLogin", deletedUser.getLogin())); + .ok(Map.of( + "message", + messageSource.getMessage( + "message.user.deleted.permanent", + null, + LocaleContextHolder.getLocale()), + "deletedUserLogin", + deletedUser.getLogin())); } } diff --git a/src/main/java/ch/sectioninformatique/auth/user/UserExceptions.java b/src/main/java/ch/sectioninformatique/auth/user/UserExceptions.java index 781a457d..5fcc1160 100644 --- a/src/main/java/ch/sectioninformatique/auth/user/UserExceptions.java +++ b/src/main/java/ch/sectioninformatique/auth/user/UserExceptions.java @@ -3,54 +3,130 @@ import org.springframework.http.HttpStatus; import ch.sectioninformatique.auth.app.exceptions.AppException; +import ch.sectioninformatique.auth.app.exceptions.MessageKeyProvider; /** * User-related exceptions for the user package. */ public class UserExceptions { + private abstract static class LoginBasedException extends AppException implements MessageKeyProvider { + private final String login; + + protected LoginBasedException(HttpStatus status, String login) { + super(status); + this.login = login; + } + + public String getLogin() { + return login; + } + + @Override + public Object[] getMessageArgs() { + return new Object[] { login }; + } + } + /** * Thrown when a user with the given login already exists. */ - public static class UserAlreadyExistsException extends AppException { + public static class UserAlreadyExistsException extends LoginBasedException { public UserAlreadyExistsException(String login) { - super("User already exists: " + login, HttpStatus.CONFLICT); + super(HttpStatus.CONFLICT, login); + } + + @Override + public String getMessageKey() { + return "error.user.already.exists"; + } + + @Override + public Object[] getMessageArgs() { + return super.getMessageArgs(); } } /** * Thrown when a user with the given login or ID is not found. */ - public static class UserNotFoundException extends AppException { + public static class UserNotFoundException extends AppException implements MessageKeyProvider { + private final String loginOrId; + public UserNotFoundException(String loginOrId) { - super("User not found: " + loginOrId, HttpStatus.NOT_FOUND); + super(HttpStatus.NOT_FOUND); + this.loginOrId = loginOrId; + } + + public String getLoginOrId() { + return loginOrId; + } + + @Override + public String getMessageKey() { + return "error.user.not.found"; + } + + @Override + public Object[] getMessageArgs() { + return new Object[] { loginOrId }; } } /** * Thrown when attempting to promote a user to admin when they are already admin. */ - public static class UserAlreadyAdminException extends AppException { + public static class UserAlreadyAdminException extends LoginBasedException { public UserAlreadyAdminException(String login) { - super("User already admin: " + login, HttpStatus.CONFLICT); + super(HttpStatus.CONFLICT, login); + } + + @Override + public String getMessageKey() { + return "error.user.already.admin"; + } + + @Override + public Object[] getMessageArgs() { + return super.getMessageArgs(); } } /** * Thrown when attempting to promote a user to manager when they are already manager. */ - public static class UserAlreadyManagerException extends AppException { + public static class UserAlreadyManagerException extends LoginBasedException { public UserAlreadyManagerException(String login) { - super("User already manager: " + login, HttpStatus.CONFLICT); + super(HttpStatus.CONFLICT, login); + } + + @Override + public String getMessageKey() { + return "error.user.already.manager"; + } + + @Override + public Object[] getMessageArgs() { + return super.getMessageArgs(); } } /** * Thrown when attempting to demote a user to regular when they are already regular. */ - public static class UserAlreadyRegularException extends AppException { + public static class UserAlreadyRegularException extends LoginBasedException { public UserAlreadyRegularException(String login) { - super("User already regular: " + login, HttpStatus.CONFLICT); + super(HttpStatus.CONFLICT, login); + } + + @Override + public String getMessageKey() { + return "error.user.already.regular"; + } + + @Override + public Object[] getMessageArgs() { + return super.getMessageArgs(); } } } diff --git a/src/main/java/ch/sectioninformatique/auth/user/UserService.java b/src/main/java/ch/sectioninformatique/auth/user/UserService.java index 2b74d820..b0992c1e 100644 --- a/src/main/java/ch/sectioninformatique/auth/user/UserService.java +++ b/src/main/java/ch/sectioninformatique/auth/user/UserService.java @@ -19,7 +19,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import java.nio.CharBuffer; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -36,10 +35,12 @@ import org.hibernate.Session; import ch.sectioninformatique.auth.auth.AuthExceptions.InvalidCredentialsException; +import ch.sectioninformatique.auth.auth.AuthExceptions.InvalidRefreshTokenException; import ch.sectioninformatique.auth.user.UserExceptions.UserAlreadyExistsException; import ch.sectioninformatique.auth.user.UserExceptions.UserAlreadyManagerException; import ch.sectioninformatique.auth.user.UserExceptions.UserAlreadyRegularException; import ch.sectioninformatique.auth.security.SecurityExceptions.UserHasLowerRightsException; +import ch.sectioninformatique.auth.security.SecurityExceptions.HashAlgorithmUnavailableException; import ch.sectioninformatique.auth.user.UserExceptions.UserNotFoundException; import ch.sectioninformatique.auth.security.SecurityExceptions.RoleNotFoundException; import ch.sectioninformatique.auth.user.UserExceptions.UserAlreadyAdminException; @@ -76,6 +77,7 @@ public class UserService { private final RefreshTokenRepository refreshTokenRepository; + /** * Authenticates a user with their credentials. * @@ -120,22 +122,22 @@ public void storeRefreshToken(String userLogin, String refreshToken, Instant exp } /** - * Validates a refresh token for a given user. - * - * Checks that the token exists, matches the stored hashed token, is not - * revoked, - * and has not expired. + * Validates a refresh token and throws if it is invalid or expired. * * @param userLogin The login/username of the user. * @param refreshToken The raw refresh token to validate. - * @return {@code true} if the token is valid, {@code false} otherwise. + * @throws InvalidRefreshTokenException if the token is invalid or expired */ - public boolean validateRefreshToken(String userLogin, String refreshToken) { + public void assertValidRefreshToken(String userLogin, String refreshToken) { String hashedRefreshToken = hashRefreshToken(refreshToken); - return refreshTokenRepository.findByUserLoginAndRevokedFalse(userLogin) + boolean valid = refreshTokenRepository.findByUserLoginAndRevokedFalse(userLogin) .filter(stored -> hashedRefreshToken.equals(stored.getTokenHash())) .filter(stored -> stored.getExpiresAt().isAfter(Instant.now())) .isPresent(); + + if (!valid) { + throw new InvalidRefreshTokenException(); + } } /** @@ -171,7 +173,7 @@ private String hashRefreshToken(String token) { byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(hash); } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("SHA-256 algorithm not available", e); + throw new HashAlgorithmUnavailableException(); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5b32cdad..017d33a1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -55,3 +55,6 @@ spring.security.oauth2.client.registration.azure.authorization-grant-type=author spring.security.oauth2.client.registration.azure.redirect-uri=${AZURE_REDIRECT_BASE_URL}/login/oauth2/code/{registrationId} spring.security.oauth2.client.registration.azure.scope=openid,profile,email,User.Read spring.security.oauth2.client.registration.azure.client-name=Azure + +# Folder Redirection +spring.messages.basename=messages/messages \ No newline at end of file diff --git a/src/main/resources/messages/app/messages_en.properties b/src/main/resources/messages/app/messages_en.properties new file mode 100644 index 00000000..24a4cf2c --- /dev/null +++ b/src/main/resources/messages/app/messages_en.properties @@ -0,0 +1,2 @@ +error.unexpected=Unexpected error +error.validation.failed=Validation failed diff --git a/src/main/resources/messages/app/messages_fr.properties b/src/main/resources/messages/app/messages_fr.properties new file mode 100644 index 00000000..6215a78f --- /dev/null +++ b/src/main/resources/messages/app/messages_fr.properties @@ -0,0 +1,2 @@ +error.unexpected=Erreur inattendue +error.validation.failed=Échec de la validation diff --git a/src/main/resources/messages/auth/messages_en.properties b/src/main/resources/messages/auth/messages_en.properties new file mode 100644 index 00000000..508eada3 --- /dev/null +++ b/src/main/resources/messages/auth/messages_en.properties @@ -0,0 +1,12 @@ +# OAuth2 Error Messages +error.oauth2.missing.authentication=Authentication token is missing +error.oauth2.user.not.found=OAuth2 user details not found +error.oauth2.missing.user.attribute=Required user attribute not found: {0} + +# Custom Validation Messages +validation.password.not.reused=New password must be different from current password + +# Authorization and Session Messages +error.authorisation.invalid.credentials=Invalid credentials +message.password.updated=Password updated successfully +message.logout.success=Logged out successfully diff --git a/src/main/resources/messages/auth/messages_fr.properties b/src/main/resources/messages/auth/messages_fr.properties new file mode 100644 index 00000000..35687608 --- /dev/null +++ b/src/main/resources/messages/auth/messages_fr.properties @@ -0,0 +1,12 @@ +# Messages d''erreur OAuth2 +error.oauth2.missing.authentication=Le jeton d''authentification est manquant +error.oauth2.user.not.found=Détails de l''utilisateur OAuth2 introuvables +error.oauth2.missing.user.attribute=Attribut utilisateur requis introuvable: {0} + +# Messages de validation personnalisés +validation.password.not.reused=Le nouveau mot de passe doit être différent du mot de passe actuel + +# Messages d''autorisation et de session +error.authorisation.invalid.credentials=Identifiants invalides +message.password.updated=Mot de passe mis à jour avec succès +message.logout.success=Déconnexion réussie diff --git a/src/main/resources/messages/config/messages_en.properties b/src/main/resources/messages/config/messages_en.properties new file mode 100644 index 00000000..b87a9a5d --- /dev/null +++ b/src/main/resources/messages/config/messages_en.properties @@ -0,0 +1,14 @@ +# CORS Validation Messages +error.cors.allowed.origins.empty=CORS allowed-origins cannot be empty. Configure at least one origin in cors.allowed-origins +error.cors.origin.wildcard.production=Wildcard origin '*' is not allowed in production. Configure specific allowed origins in cors.allowed-origins +error.cors.origin.invalid.protocol=Origin ''{0}'' uses invalid protocol ''{1}''. Only http and https protocols are allowed +error.cors.origin.localhost.production=Origin ''{0}'' points to localhost. Production should not allow localhost origins +error.cors.origin.invalid.url=Origin ''{0}'' is not a valid URL: {1} +error.cors.origin.duplicate=Duplicate origin found: ''{0}''. Remove duplicates from cors.allowed-origins +error.cors.allowed.methods.empty=CORS allowed-methods cannot be empty. Configure at least one method in cors.allowed-methods +error.cors.method.not.allowed=HTTP method ''{0}'' is not allowed for CORS. Allowed methods: {1} +error.cors.method.duplicate=Duplicate HTTP method found: ''{0}''. Remove duplicates from cors.allowed-methods +error.cors.allowed.headers.empty=CORS allowed-headers cannot be empty. Configure at least one header in cors.allowed-headers +error.cors.header.wildcard.not.allowed=Wildcard header '*' is not allowed for CORS. Specify individual header names in cors.allowed-headers. Allowed headers: {0} +error.cors.header.not.allowed=Header ''{0}'' is not in the whitelist of allowed CORS headers. Allowed headers: {1}. If you need a custom header, add it to CorsConfigurationValidator.ALLOWED_HEADER_NAMES +error.cors.header.duplicate=Duplicate header found: ''{0}''. Remove duplicates from cors.allowed-headers diff --git a/src/main/resources/messages/config/messages_fr.properties b/src/main/resources/messages/config/messages_fr.properties new file mode 100644 index 00000000..ab17868d --- /dev/null +++ b/src/main/resources/messages/config/messages_fr.properties @@ -0,0 +1,14 @@ +# Messages de validation CORS +error.cors.allowed.origins.empty=Les origines CORS autorisées ne peuvent pas être vides. Configurez au moins une origine dans cors.allowed-origins +error.cors.origin.wildcard.production=L''origine générique '*' n''est pas autorisée en production. Configurez des origines spécifiques dans cors.allowed-origins +error.cors.origin.invalid.protocol=L''origine ''{0}'' utilise un protocole invalide ''{1}''. Seuls les protocoles http et https sont autorisés +error.cors.origin.localhost.production=L''origine ''{0}'' pointe vers localhost. La production ne doit pas autoriser localhost +error.cors.origin.invalid.url=L''origine ''{0}'' n''est pas une URL valide : {1} +error.cors.origin.duplicate=Origine dupliquée trouvée : ''{0}''. Supprimez les doublons de cors.allowed-origins +error.cors.allowed.methods.empty=Les méthodes CORS autorisées ne peuvent pas être vides. Configurez au moins une méthode dans cors.allowed-methods +error.cors.method.not.allowed=La méthode HTTP ''{0}'' n''est pas autorisée pour CORS. Méthodes autorisées : {1} +error.cors.method.duplicate=Méthode HTTP dupliquée trouvée : ''{0}''. Supprimez les doublons de cors.allowed-methods +error.cors.allowed.headers.empty=Les en-têtes CORS autorisés ne peuvent pas être vides. Configurez au moins un en-tête dans cors.allowed-headers +error.cors.header.wildcard.not.allowed=L''en-tête générique '*' n''est pas autorisé pour CORS. Spécifiez les noms d''en-têtes dans cors.allowed-headers. En-têtes autorisés : {0} +error.cors.header.not.allowed=L''en-tête ''{0}'' n''est pas dans la liste des en-têtes CORS autorisés. En-têtes autorisés : {1}. Si vous avez besoin d''un en-tête personnalisé, ajoutez-le à CorsConfigurationValidator.ALLOWED_HEADER_NAMES +error.cors.header.duplicate=En-tête dupliqué trouvé : ''{0}''. Supprimez les doublons de cors.allowed-headers diff --git a/src/main/resources/messages/security/messages_en.properties b/src/main/resources/messages/security/messages_en.properties new file mode 100644 index 00000000..eb5e8f6e --- /dev/null +++ b/src/main/resources/messages/security/messages_en.properties @@ -0,0 +1,14 @@ +# Security Error Messages +error.security.role.not.found=Role not found: {0} +error.security.insufficient.rights=User has insufficient rights: {0} +error.security.token.untrusted.tenant=Token is not from a trusted Azure tenant +error.security.jwt.missing.claim=JWT is missing required claim: {0} +error.security.access.denied=You don't have the necessary rights to perform this action +error.security.token.expired=Token has expired +error.security.token.invalid.claims=Token contains invalid claims +error.security.token.invalid.signature=Token signature is invalid +error.security.token.invalid=Invalid JWT token +error.security.refresh.token.invalid=Invalid refresh token +error.security.authentication.failed=Authentication failed +error.security.authentication.token.invalid.or.missing=Invalid or missing authentication token +error.security.hash.algorithm.unavailable=SHA-256 algorithm not available diff --git a/src/main/resources/messages/security/messages_fr.properties b/src/main/resources/messages/security/messages_fr.properties new file mode 100644 index 00000000..3e47cddf --- /dev/null +++ b/src/main/resources/messages/security/messages_fr.properties @@ -0,0 +1,14 @@ +# Messages d''erreur de sécurité +error.security.role.not.found=Rôle non trouvé: {0} +error.security.insufficient.rights=L''utilisateur dispose de droits insuffisants: {0} +error.security.token.untrusted.tenant=Le jeton n''est pas d''un locataire Azure de confiance +error.security.jwt.missing.claim=JWT manque la réclamation requise: {0} +error.security.access.denied=Vous n''avez pas les droits nécessaires pour effectuer cette action +error.security.token.expired=Le jeton a expiré +error.security.token.invalid.claims=Le jeton contient des revendications invalides +error.security.token.invalid.signature=La signature du jeton est invalide +error.security.token.invalid=Jeton JWT invalide +error.security.refresh.token.invalid=Jeton de rafraîchissement invalide +error.security.authentication.failed=Échec de l''authentification +error.security.authentication.token.invalid.or.missing=Jeton d''authentification invalide ou manquant +error.security.hash.algorithm.unavailable=Algorithme SHA-256 non disponible diff --git a/src/main/resources/messages/user/messages_en.properties b/src/main/resources/messages/user/messages_en.properties new file mode 100644 index 00000000..1c61c03d --- /dev/null +++ b/src/main/resources/messages/user/messages_en.properties @@ -0,0 +1,16 @@ +# User Error Messages +error.user.already.exists=User already exists: {0} +error.user.not.found=User not found: {0} +error.user.already.admin=User already admin: {0} +error.user.already.manager=User already manager: {0} +error.user.already.regular=User already regular: {0} + +# User Success Messages +message.user.restored=User restored successfully +message.user.promoted.manager=User promoted to manager successfully +message.user.revoked.manager=Manager role revoked successfully +message.user.promoted.admin=Admin role assigned successfully +message.user.revoked.admin=Admin role revoked successfully +message.user.downgraded.admin=Admin role downgraded successfully +message.user.deleted=User deleted successfully +message.user.deleted.permanent=User deleted permanently diff --git a/src/main/resources/messages/user/messages_fr.properties b/src/main/resources/messages/user/messages_fr.properties new file mode 100644 index 00000000..5ba8bbb9 --- /dev/null +++ b/src/main/resources/messages/user/messages_fr.properties @@ -0,0 +1,16 @@ +# Messages d''erreur utilisateur +error.user.already.exists=L''utilisateur existe déjà: {0} +error.user.not.found=Utilisateur non trouvé: {0} +error.user.already.admin=L''utilisateur est déjà admin: {0} +error.user.already.manager=L''utilisateur est déjà manager: {0} +error.user.already.regular=L''utilisateur est déjà utilisateur régulier: {0} + +# Messages de succès utilisateur +message.user.restored=Utilisateur restauré avec succès +message.user.promoted.manager=Utilisateur promu manager avec succès +message.user.revoked.manager=Rôle de manager révoqué avec succès +message.user.promoted.admin=Rôle d''admin attribué avec succès +message.user.revoked.admin=Rôle d''admin révoqué avec succès +message.user.downgraded.admin=Rôle d''admin rétrogradé avec succès +message.user.deleted=Utilisateur supprimé avec succès +message.user.deleted.permanent=Utilisateur supprimé définitivement diff --git a/src/test/java/ch/sectioninformatique/auth/auth/AuthControllerIntegrationTest.java b/src/test/java/ch/sectioninformatique/auth/auth/AuthControllerIntegrationTest.java index 4e403119..cf89fb11 100644 --- a/src/test/java/ch/sectioninformatique/auth/auth/AuthControllerIntegrationTest.java +++ b/src/test/java/ch/sectioninformatique/auth/auth/AuthControllerIntegrationTest.java @@ -1,15 +1,19 @@ package ch.sectioninformatique.auth.auth; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.ResourceBundleMessageSource; import org.springframework.http.MediaType; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; - import ch.sectioninformatique.auth.AuthApplication; import ch.sectioninformatique.auth.security.UserAuthenticationProvider; import ch.sectioninformatique.auth.user.UserDto; @@ -38,6 +42,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; +import java.util.Locale; import org.springframework.transaction.annotation.Transactional; import jakarta.servlet.http.Cookie; @@ -103,6 +108,7 @@ private void performRequest( // Set content type requestType.contentType(contentType); + requestType.locale(LocaleContextHolder.getLocale()); // Perform request var request = mockMvc.perform(requestType) @@ -164,6 +170,7 @@ private void performRequest( // Set content type requestType.contentType(contentType); + requestType.locale(LocaleContextHolder.getLocale()); // Perform request var request = mockMvc.perform(requestType) @@ -197,6 +204,30 @@ private void performRequest( @Autowired private PasswordEncoder passwordEncoder; + @Autowired + private MessageSource messageSource; + + private static final ResourceBundleMessageSource HV_MESSAGES = new ResourceBundleMessageSource(); + + static { + HV_MESSAGES.setBasename("org.hibernate.validator.ValidationMessages"); + HV_MESSAGES.setDefaultEncoding("UTF-8"); + } + + @BeforeEach + public void setUp() { + LocaleContextHolder.setLocale(Locale.FRANCE); + } + + @AfterEach + public void tearDown() { + LocaleContextHolder.resetLocaleContext(); + } + + private String message(String key, Object... args) { + return messageSource.getMessage(key, args, Locale.FRANCE); + } + /** * Test the /auth/login endpoint with missing login. * This test performs a login request with missing login and expects a bad @@ -217,7 +248,7 @@ public void login_missingLogin_shouldReturnBadRequest() throws Exception { "login-missing-login", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.fieldErrors.login").isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -266,7 +297,7 @@ public void login_withRealData_shouldReturnSuccess() throws Exception { * * Expected behavior: * - Returns HTTP 400 (Bad Request) - * - Response contains an error message explaining the validation failure + * - Response includes fieldErrors.password for the validation failure * - Request is rejected before attempting authentication * * Test data: @@ -287,7 +318,7 @@ public void login_missingPassword_shouldReturnBadRequest() throws Exception { "login-missing-password", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.fieldErrors.password").isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -302,7 +333,7 @@ public void login_missingPassword_shouldReturnBadRequest() throws Exception { * * Expected behavior: * - Returns HTTP 400 (Bad Request) - * - Response contains an error message about invalid email format + * - Response includes fieldErrors.login for invalid email format * - Request is rejected during input validation * * Test data: @@ -323,7 +354,7 @@ public void login_invalidEmailFormat_shouldReturnBadRequest() throws Exception { "login-invalid-email-format", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.fieldErrors.login").isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -338,7 +369,7 @@ public void login_invalidEmailFormat_shouldReturnBadRequest() throws Exception { * * Expected behavior: * - Returns HTTP 400 (Bad Request) - * - Response contains an error message indicating missing request body + * - Response contains a localized error message indicating missing request body * - Request fails during JSON parsing/validation * * Test data: @@ -358,7 +389,8 @@ public void login_emptyBody_shouldReturnBadRequest() throws Exception { "login-empty-body", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -374,7 +406,7 @@ public void login_emptyBody_shouldReturnBadRequest() throws Exception { * * Expected behavior: * - Returns HTTP 400 (Bad Request) - * - Response contains an error message about JSON parsing failure + * - Response contains a localized error message about JSON parsing failure * - Request fails during JSON deserialization * * Test data: @@ -394,7 +426,8 @@ public void login_malformedJson_shouldReturnBadRequest() throws Exception { "login-malformed-json", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -412,7 +445,7 @@ public void login_malformedJson_shouldReturnBadRequest() throws Exception { * - Returns HTTP 400 (Bad Request) * - SQL injection attempt is rejected by email validation * - No database query is executed with malicious input - * - Response contains validation error message + * - Response includes fieldErrors.login * * Test data: * - Login: ' OR '1'='1 (SQL injection attempt) @@ -432,7 +465,7 @@ public void login_sqlInjectionAttemptLogin_shouldReturnBadRequest() throws Excep "login-sql-injection-attempt-login", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.fieldErrors.login").isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -470,7 +503,8 @@ public void login_sqlInjectionAttemptPassword_shouldReturnUnauthorized() throws "login-sql-injection-attempt-password", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.authorisation.invalid.credentials"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -486,7 +520,7 @@ public void login_sqlInjectionAttemptPassword_shouldReturnUnauthorized() throws * Expected behavior: * - Returns HTTP 415 (Unsupported Media Type) * - Request is rejected due to incorrect Content-Type header - * - Response contains error message about media type + * - Response contains a localized error message about media type * * Test data: * - Content-Type: text/plain (should be application/json) @@ -506,7 +540,8 @@ public void login_wrongMediaType_shouldReturnUnsupportedMediaType() throws Excep "login-wrong-media-type", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -543,7 +578,8 @@ public void login_wrongPassword_shouldReturnUnauthorized() throws Exception { "login-wrong-password", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.authorisation.invalid.credentials"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -581,7 +617,8 @@ public void login_nonExistentUser_shouldReturnUnauthorized() throws Exception { "login-non-existent-user", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.authorisation.invalid.credentials"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -672,7 +709,7 @@ public void register_withRealData_shouldReturnSuccess() throws Exception { * * Expected behavior: * - Returns HTTP 400 (Bad Request) - * - Response contains validation error message + * - Response includes fieldErrors.firstName * - No user is created in the database * - Request is rejected during input validation * @@ -696,7 +733,7 @@ public void register_missingFirstName_shouldReturnBadRequest() throws Exception "register-missing-first-name", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.fieldErrors.firstName").isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -711,7 +748,7 @@ public void register_missingFirstName_shouldReturnBadRequest() throws Exception * * Expected behavior: * - Returns HTTP 400 (Bad Request) - * - Response contains validation error message + * - Response includes fieldErrors.lastName * - No user is created in the database * - Request is rejected during input validation * @@ -735,7 +772,7 @@ public void register_missingLastName_shouldReturnBadRequest() throws Exception { "register-missing-last-name", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.fieldErrors.lastName").isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -750,7 +787,7 @@ public void register_missingLastName_shouldReturnBadRequest() throws Exception { * * Expected behavior: * - Returns HTTP 400 (Bad Request) - * - Response contains validation error message + * - Response includes fieldErrors.login * - No user is created in the database * - Request is rejected during input validation * @@ -774,7 +811,7 @@ public void register_missingLogin_shouldReturnBadRequest() throws Exception { "register-missing-login", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.fieldErrors.login").isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -789,7 +826,7 @@ public void register_missingLogin_shouldReturnBadRequest() throws Exception { * * Expected behavior: * - Returns HTTP 400 (Bad Request) - * - Response contains validation error message + * - Response includes fieldErrors.password * - No user is created in the database * - Request is rejected during input validation * @@ -813,7 +850,7 @@ public void register_missingPassword_shouldReturnBadRequest() throws Exception { "register-missing-password", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.fieldErrors.password").isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -828,7 +865,7 @@ public void register_missingPassword_shouldReturnBadRequest() throws Exception { * * Expected behavior: * - Returns HTTP 400 (Bad Request) - * - Response contains validation error message about email format + * - Response includes fieldErrors.login for email format * - No user is created in the database * - Request is rejected during email validation * @@ -850,7 +887,7 @@ public void register_invalidEmailFormat_shouldReturnBadRequest() throws Exceptio "register-invalid-email-format", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.fieldErrors.login").isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -865,7 +902,7 @@ public void register_invalidEmailFormat_shouldReturnBadRequest() throws Exceptio * * Expected behavior: * - Returns HTTP 400 (Bad Request) - * - Response contains error message about missing request body + * - Response contains a localized error message about missing request body * - No user is created in the database * - Request fails during JSON parsing/validation * @@ -886,7 +923,8 @@ public void register_emptyBody_shouldReturnBadRequest() throws Exception { "register-empty-body", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -901,7 +939,7 @@ public void register_emptyBody_shouldReturnBadRequest() throws Exception { * * Expected behavior: * - Returns HTTP 400 (Bad Request) - * - Response contains error message about JSON parsing failure + * - Response contains a localized error message about JSON parsing failure * - No user is created in the database * - Request fails during JSON deserialization * @@ -922,7 +960,8 @@ public void register_malformedJson_shouldReturnBadRequest() throws Exception { "register-malformed-json", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -938,7 +977,7 @@ public void register_malformedJson_shouldReturnBadRequest() throws Exception { * Expected behavior: * - Returns HTTP 400 (Bad Request) * - SQL injection attempt is rejected by name validation - * - Response contains validation error message + * - Response includes fieldErrors.firstName * - No database query is executed with malicious input * - No user is created * @@ -960,7 +999,7 @@ public void register_sqlInjectionAttemptFirstName_shouldReturnBadRequest() throw "register-sql-injection-attempt-first-name", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.fieldErrors.firstName").isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -976,7 +1015,7 @@ public void register_sqlInjectionAttemptFirstName_shouldReturnBadRequest() throw * Expected behavior: * - Returns HTTP 400 (Bad Request) * - SQL injection attempt is rejected by name validation - * - Response contains validation error message + * - Response includes fieldErrors.lastName * - No database query is executed with malicious input * - No user is created * @@ -998,7 +1037,7 @@ public void register_sqlInjectionAttemptLastName_shouldReturnBadRequest() throws "register-sql-injection-attempt-last-name", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.fieldErrors.lastName").isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -1014,7 +1053,7 @@ public void register_sqlInjectionAttemptLastName_shouldReturnBadRequest() throws * Expected behavior: * - Returns HTTP 400 (Bad Request) * - SQL injection attempt is rejected by email validation - * - Response contains validation error message + * - Response includes fieldErrors.login * - No database query is executed with malicious input * - No user is created * @@ -1036,7 +1075,7 @@ public void register_sqlInjectionAttemptLogin_shouldReturnBadRequest() throws Ex "register-sql-injection-attempt-login", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.fieldErrors.login").isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -1052,7 +1091,7 @@ public void register_sqlInjectionAttemptLogin_shouldReturnBadRequest() throws Ex * Expected behavior: * - Returns HTTP 415 (Unsupported Media Type) * - Request is rejected due to incorrect Content-Type header - * - Response contains error message about media type + * - Response contains a localized error message about media type * - No user is created in the database * * Test data: @@ -1073,7 +1112,8 @@ public void register_wrongMediaType_shouldReturnUnsupportedMediaType() throws Ex "register-wrong-media-type", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -1089,7 +1129,7 @@ public void register_wrongMediaType_shouldReturnUnsupportedMediaType() throws Ex * Expected behavior: * - Returns HTTP 409 (Conflict) * - Registration is rejected because email already exists - * - Response contains error message about duplicate login + * - Response contains a localized error message about duplicate login * - No new user is created (existing user remains unchanged) * * Test data: @@ -1110,7 +1150,10 @@ public void register_duplicateLogin_shouldReturnConflict() throws Exception { "register-duplicate-login", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message( + "error.user.already.exists", + "test.user@test.com"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1190,7 +1233,8 @@ public void refresh_missingToken_shouldReturnUnauthorized() throws Exception { "refresh-missing-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1227,7 +1271,8 @@ public void refresh_invalidToken_shouldReturnUnauthorized() throws Exception { "refresh-invalid-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.invalid"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1263,7 +1308,8 @@ public void refresh_missingAuthorizationHeader_shouldReturnUnauthorized() throws "refresh-missing-authorization", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1301,7 +1347,8 @@ public void refresh_emptyBody_shouldReturnUnauthorized() throws Exception { "refresh-empty-body", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1343,7 +1390,8 @@ public void updatePassword_withRealData_shouldReturnSuccess() throws Exception { "update-password", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("message.password.updated"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1359,7 +1407,7 @@ public void updatePassword_withRealData_shouldReturnSuccess() throws Exception { * Expected behavior: * - Returns HTTP 400 (Bad Request) * - Request is rejected due to missing required fields - * - Response contains validation error message + * - Response contains a localized error message for malformed or missing JSON * - User's password remains unchanged * * Test data: @@ -1383,7 +1431,8 @@ public void setPassword_missingBody_shouldReturnBadRequest() throws Exception { "update-password-missing-body", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .isNotEmpty()); } catch (Exception e) { throw new RuntimeException(e); } @@ -1420,7 +1469,8 @@ public void setPassword_missingToken_shouldReturnUnauthorized() throws Exception "update-password-missing-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1459,7 +1509,8 @@ public void logout_withValidToken_shouldReturnSuccess() throws Exception { "logout", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("message.logout.success"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1495,7 +1546,8 @@ public void logout_missingToken_shouldReturnUnauthorized() throws Exception { "logout-missing-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1532,7 +1584,8 @@ public void logout_withMalformedToken_shouldReturnUnauthorized() throws Exceptio "logout-malformed-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.invalid"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1573,7 +1626,8 @@ public void logout_withExpiredToken_shouldReturnUnauthorized() throws Exception "logout-expired-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.expired"))); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/src/test/java/ch/sectioninformatique/auth/auth/CredentialsDtoTest.java b/src/test/java/ch/sectioninformatique/auth/auth/CredentialsDtoTest.java index fe51a1db..858a120e 100644 --- a/src/test/java/ch/sectioninformatique/auth/auth/CredentialsDtoTest.java +++ b/src/test/java/ch/sectioninformatique/auth/auth/CredentialsDtoTest.java @@ -1,12 +1,20 @@ package ch.sectioninformatique.auth.auth; import jakarta.validation.ConstraintViolation; -import jakarta.validation.Validation; import jakarta.validation.Validator; -import jakarta.validation.ValidatorFactory; -import org.junit.jupiter.api.BeforeAll; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; - +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import java.lang.annotation.Annotation; +import java.util.Locale; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -22,14 +30,35 @@ * * The tests use Jakarta Bean Validation API to verify constraint violations. */ +@SpringBootTest(classes = ch.sectioninformatique.auth.AuthApplication.class) public class CredentialsDtoTest { - private static Validator validator; + private static final ResourceBundleMessageSource HV_MESSAGES = new ResourceBundleMessageSource(); + + static { + HV_MESSAGES.setBasename("org.hibernate.validator.ValidationMessages"); + HV_MESSAGES.setDefaultEncoding("UTF-8"); + } + + @Autowired + private Validator validator; + + @BeforeEach + public void setUp() { + LocaleContextHolder.setLocale(Locale.FRANCE); + } + + @AfterEach + public void tearDown() { + LocaleContextHolder.resetLocaleContext(); + } - @BeforeAll - public static void setUp() { - ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); - validator = factory.getValidator(); + private boolean hasViolation(Set> violations, + String field, + Class annotationType) { + return violations.stream().anyMatch(v -> + v.getPropertyPath().toString().equals(field) + && v.getConstraintDescriptor().getAnnotation().annotationType().equals(annotationType)); } /** @@ -66,7 +95,7 @@ public void credentialsDto_withValidData_shouldPassValidation() { * Test data: * - Login: not-an-email (no @ symbol or domain) * - * Expected: 1 violation with message containing "valid email" + * Expected: 1 @Email constraint violation on login */ @Test public void credentialsDto_withInvalidEmail_shouldFailValidation() { @@ -81,8 +110,7 @@ public void credentialsDto_withInvalidEmail_shouldFailValidation() { // Assert assertEquals(1, violations.size()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("valid email"))); + assertTrue(hasViolation(violations, "login", Email.class)); } /** @@ -95,7 +123,7 @@ public void credentialsDto_withInvalidEmail_shouldFailValidation() { * - Login: "" (empty string) * - Password: password123 (valid) * - * Expected: At least 1 violation about required/blank field + * Expected: At least 1 @NotBlank constraint violation on login */ @Test public void credentialsDto_withBlankLogin_shouldFailValidation() { @@ -110,8 +138,7 @@ public void credentialsDto_withBlankLogin_shouldFailValidation() { // Assert assertFalse(violations.isEmpty()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("required") || v.getMessage().contains("must not be blank"))); + assertTrue(hasViolation(violations, "login", NotBlank.class)); } /** @@ -124,7 +151,7 @@ public void credentialsDto_withBlankLogin_shouldFailValidation() { * - Login: test@example.com (valid) * - Password: null * - * Expected: 1 violation with message containing "required" + * Expected: 1 @NotNull constraint violation on password */ @Test public void credentialsDto_withNullPassword_shouldFailValidation() { @@ -139,8 +166,7 @@ public void credentialsDto_withNullPassword_shouldFailValidation() { // Assert assertEquals(1, violations.size()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("required"))); + assertTrue(hasViolation(violations, "password", NotNull.class)); } /** @@ -153,7 +179,7 @@ public void credentialsDto_withNullPassword_shouldFailValidation() { * - Login: test@example.com (valid) * - Password: "short" (5 characters - below minimum) * - * Expected: 1 violation with message about "between 8 and 72" characters + * Expected: 1 @Size constraint violation on password */ @Test public void credentialsDto_withPasswordTooShort_shouldFailValidation() { @@ -168,8 +194,7 @@ public void credentialsDto_withPasswordTooShort_shouldFailValidation() { // Assert assertEquals(1, violations.size()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("between 8 and 72"))); + assertTrue(hasViolation(violations, "password", Size.class)); } /** @@ -182,7 +207,7 @@ public void credentialsDto_withPasswordTooShort_shouldFailValidation() { * - Login: test@example.com (valid) * - Password: 73-character string (above maximum) * - * Expected: 1 violation with message about "between 8 and 72" characters + * Expected: 1 @Size constraint violation on password */ @Test public void credentialsDto_withPasswordTooLong_shouldFailValidation() { @@ -198,8 +223,7 @@ public void credentialsDto_withPasswordTooLong_shouldFailValidation() { // Assert assertEquals(1, violations.size()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("between 8 and 72"))); + assertTrue(hasViolation(violations, "password", Size.class)); } /** diff --git a/src/test/java/ch/sectioninformatique/auth/auth/SignUpDtoTest.java b/src/test/java/ch/sectioninformatique/auth/auth/SignUpDtoTest.java index dd3193d7..6cab33e4 100644 --- a/src/test/java/ch/sectioninformatique/auth/auth/SignUpDtoTest.java +++ b/src/test/java/ch/sectioninformatique/auth/auth/SignUpDtoTest.java @@ -1,14 +1,22 @@ package ch.sectioninformatique.auth.auth; import jakarta.validation.ConstraintViolation; -import jakarta.validation.Validation; import jakarta.validation.Validator; -import jakarta.validation.ValidatorFactory; -import org.junit.jupiter.api.BeforeAll; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; - import java.util.Set; - +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.context.support.ResourceBundleMessageSource; +import java.lang.annotation.Annotation; +import java.util.Locale; import static org.junit.jupiter.api.Assertions.*; /** @@ -24,14 +32,35 @@ * * SignUpDto is used for user registration requests. */ +@SpringBootTest(classes = ch.sectioninformatique.auth.AuthApplication.class) public class SignUpDtoTest { - private static Validator validator; + private static final ResourceBundleMessageSource HV_MESSAGES = new ResourceBundleMessageSource(); + + static { + HV_MESSAGES.setBasename("org.hibernate.validator.ValidationMessages"); + HV_MESSAGES.setDefaultEncoding("UTF-8"); + } + + @Autowired + private Validator validator; + + @BeforeEach + public void setUp() { + LocaleContextHolder.setLocale(Locale.FRANCE); + } + + @AfterEach + public void tearDown() { + LocaleContextHolder.resetLocaleContext(); + } - @BeforeAll - public static void setUp() { - ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); - validator = factory.getValidator(); + private boolean hasViolation(Set> violations, + String field, + Class annotationType) { + return violations.stream().anyMatch(v -> + v.getPropertyPath().toString().equals(field) + && v.getConstraintDescriptor().getAnnotation().annotationType().equals(annotationType)); } /** @@ -139,7 +168,7 @@ public void signUpDto_withUnicodeNames_shouldPassValidation() { * - login: "john.doe@example.com" * - password: "Password123!" * - * Expected: Validation error containing "First name is required" + * Expected: @NotBlank constraint violation on firstName */ @Test public void signUpDto_withBlankFirstName_shouldFailValidation() { @@ -156,8 +185,7 @@ public void signUpDto_withBlankFirstName_shouldFailValidation() { // Assert assertFalse(violations.isEmpty()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("First name is required"))); + assertTrue(hasViolation(violations, "firstName", NotBlank.class)); } /** @@ -172,7 +200,7 @@ public void signUpDto_withBlankFirstName_shouldFailValidation() { * - login: "john.doe@example.com" * - password: "Password123!" * - * Expected: Validation error containing "Last name is required" + * Expected: @NotBlank constraint violation on lastName */ @Test public void signUpDto_withBlankLastName_shouldFailValidation() { @@ -189,8 +217,7 @@ public void signUpDto_withBlankLastName_shouldFailValidation() { // Assert assertFalse(violations.isEmpty()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("Last name is required"))); + assertTrue(hasViolation(violations, "lastName", NotBlank.class)); } /** @@ -205,7 +232,7 @@ public void signUpDto_withBlankLastName_shouldFailValidation() { * - login: "john.doe@example.com" * - password: "Password123!" * - * Expected: Validation error containing "invalid characters" + * Expected: @Pattern constraint violation on firstName */ @Test public void signUpDto_withInvalidFirstNameCharacters_shouldFailValidation() { @@ -222,8 +249,7 @@ public void signUpDto_withInvalidFirstNameCharacters_shouldFailValidation() { // Assert assertEquals(1, violations.size()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("invalid characters"))); + assertTrue(hasViolation(violations, "firstName", Pattern.class)); } /** @@ -238,7 +264,7 @@ public void signUpDto_withInvalidFirstNameCharacters_shouldFailValidation() { * - login: "john.doe@example.com" * - password: "Password123!" * - * Expected: Validation error containing "invalid characters" + * Expected: @Pattern constraint violation on lastName */ @Test public void signUpDto_withInvalidLastNameCharacters_shouldFailValidation() { @@ -255,8 +281,7 @@ public void signUpDto_withInvalidLastNameCharacters_shouldFailValidation() { // Assert assertEquals(1, violations.size()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("invalid characters"))); + assertTrue(hasViolation(violations, "lastName", Pattern.class)); } /** @@ -271,7 +296,7 @@ public void signUpDto_withInvalidLastNameCharacters_shouldFailValidation() { * - login: "not-an-email" (invalid email format) * - password: "Password123!" * - * Expected: Validation error containing "valid email" + * Expected: @Email constraint violation on login */ @Test public void signUpDto_withInvalidEmail_shouldFailValidation() { @@ -288,8 +313,7 @@ public void signUpDto_withInvalidEmail_shouldFailValidation() { // Assert assertEquals(1, violations.size()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("valid email"))); + assertTrue(hasViolation(violations, "login", Email.class)); } /** @@ -304,7 +328,7 @@ public void signUpDto_withInvalidEmail_shouldFailValidation() { * - login: "" (empty string) * - password: "Password123!" * - * Expected: Validation error containing "Login is required" + * Expected: @NotBlank constraint violation on login */ @Test public void signUpDto_withBlankLogin_shouldFailValidation() { @@ -321,8 +345,7 @@ public void signUpDto_withBlankLogin_shouldFailValidation() { // Assert assertFalse(violations.isEmpty()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("Login is required"))); + assertTrue(hasViolation(violations, "login", NotBlank.class)); } /** @@ -337,7 +360,7 @@ public void signUpDto_withBlankLogin_shouldFailValidation() { * - login: "john.doe@example.com" * - password: null * - * Expected: Validation error containing "Password is required" + * Expected: @NotNull constraint violation on password */ @Test public void signUpDto_withNullPassword_shouldFailValidation() { @@ -354,8 +377,7 @@ public void signUpDto_withNullPassword_shouldFailValidation() { // Assert assertEquals(1, violations.size()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("Password is required"))); + assertTrue(hasViolation(violations, "password", NotNull.class)); } /** @@ -370,7 +392,7 @@ public void signUpDto_withNullPassword_shouldFailValidation() { * - login: "john.doe@example.com" * - password: "Pass1!" (only 6 characters) * - * Expected: Validation error containing "between 8 and 72" + * Expected: @Size constraint violation on password */ @Test public void signUpDto_withPasswordTooShort_shouldFailValidation() { @@ -387,8 +409,7 @@ public void signUpDto_withPasswordTooShort_shouldFailValidation() { // Assert assertEquals(1, violations.size()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("between 8 and 72"))); + assertTrue(hasViolation(violations, "password", Size.class)); } /** @@ -403,7 +424,7 @@ public void signUpDto_withPasswordTooShort_shouldFailValidation() { * - login: "john.doe@example.com" * - password: 73-character string * - * Expected: Validation error containing "between 8 and 72" + * Expected: @Size constraint violation on password */ @Test public void signUpDto_withPasswordTooLong_shouldFailValidation() { @@ -421,8 +442,7 @@ public void signUpDto_withPasswordTooLong_shouldFailValidation() { // Assert assertEquals(1, violations.size()); - assertTrue(violations.stream() - .anyMatch(v -> v.getMessage().contains("between 8 and 72"))); + assertTrue(hasViolation(violations, "password", Size.class)); } /** diff --git a/src/test/java/ch/sectioninformatique/auth/security/CustomAccessDeniedHandlerTest.java b/src/test/java/ch/sectioninformatique/auth/security/CustomAccessDeniedHandlerTest.java index 7f2538a5..862ec1b4 100644 --- a/src/test/java/ch/sectioninformatique/auth/security/CustomAccessDeniedHandlerTest.java +++ b/src/test/java/ch/sectioninformatique/auth/security/CustomAccessDeniedHandlerTest.java @@ -3,7 +3,12 @@ import ch.sectioninformatique.auth.app.errors.ErrorDto; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.access.AccessDeniedException; @@ -23,6 +28,7 @@ * CustomAccessDeniedHandler is invoked when an authenticated user attempts * to access a resource they don't have permission for. */ +@SpringBootTest public class CustomAccessDeniedHandlerTest { private CustomAccessDeniedHandler handler; @@ -30,26 +36,39 @@ public class CustomAccessDeniedHandlerTest { private MockHttpServletResponse response; private ObjectMapper objectMapper; + @Autowired + private MessageSource messageSource; + @BeforeEach public void setUp() { - handler = new CustomAccessDeniedHandler(); + handler = new CustomAccessDeniedHandler(messageSource); request = new MockHttpServletRequest(); response = new MockHttpServletResponse(); objectMapper = new ObjectMapper(); + LocaleContextHolder.setLocale(java.util.Locale.getDefault()); + } + + @AfterEach + public void tearDown() { + LocaleContextHolder.resetLocaleContext(); + } + + private String message(String key, Object... args) { + return messageSource.getMessage(key, args, LocaleContextHolder.getLocale()); } /** - * Test: AccessDeniedException returns 403 with custom error message - * - * Verifies that when an AccessDeniedException with a custom message is handled, - * the response contains HTTP 403 status, JSON content type, and the exception's message. - * - * Test data: AccessDeniedException with "Custom access denied message" - * - * Expected: - * - HTTP status: 403 Forbidden - * - Content-Type: application/json - * - Response body: ErrorDto with "Custom access denied message" + * Test: AccessDeniedException returns 403 with error.security.access.denied message + * + * Verifies that when an AccessDeniedException is handled, the response contains + * HTTP 403 status, JSON content type, and the error.security.access.denied message. + * + * Test data: AccessDeniedException with a non-localized message + * + * Expected: + * - HTTP status: 403 Forbidden + * - Content-Type: application/json + * - Response body: ErrorDto with the error.security.access.denied message */ @Test public void handle_withAccessDeniedException_shouldReturn403WithMessage() throws Exception { @@ -64,7 +83,7 @@ public void handle_withAccessDeniedException_shouldReturn403WithMessage() throws assertEquals("application/json", response.getHeader("Content-Type")); ErrorDto errorDto = objectMapper.readValue(response.getContentAsString(), ErrorDto.class); - assertEquals("Custom access denied message", errorDto.message()); + assertEquals(message("error.security.access.denied"), errorDto.message()); } /** @@ -78,7 +97,7 @@ public void handle_withAccessDeniedException_shouldReturn403WithMessage() throws * Expected: * - HTTP status: 403 Forbidden * - Content-Type: application/json - * - Response body: ErrorDto with default message "You don't have the necessary rights to perform this action" + * - Response body: ErrorDto with the error.security.access.denied message */ @Test public void handle_withNullException_shouldReturn403WithDefaultMessage() throws Exception { @@ -90,7 +109,7 @@ public void handle_withNullException_shouldReturn403WithDefaultMessage() throws assertEquals("application/json", response.getHeader("Content-Type")); ErrorDto errorDto = objectMapper.readValue(response.getContentAsString(), ErrorDto.class); - assertEquals("You don't have the necessary rights to perform this action", errorDto.message()); + assertEquals(message("error.security.access.denied"), errorDto.message()); } /** @@ -104,7 +123,7 @@ public void handle_withNullException_shouldReturn403WithDefaultMessage() throws * Expected: * - HTTP status: 403 Forbidden * - Content-Type: application/json - * - Response body: ErrorDto with default message "You don't have the necessary rights to perform this action" + * - Response body: ErrorDto with the error.security.access.denied message */ @Test public void handle_withExceptionWithNullMessage_shouldReturn403WithDefaultMessage() throws Exception { @@ -119,21 +138,21 @@ public void handle_withExceptionWithNullMessage_shouldReturn403WithDefaultMessag assertEquals("application/json", response.getHeader("Content-Type")); ErrorDto errorDto = objectMapper.readValue(response.getContentAsString(), ErrorDto.class); - assertEquals("You don't have the necessary rights to perform this action", errorDto.message()); + assertEquals(message("error.security.access.denied"), errorDto.message()); } /** - * Test: Response body contains valid JSON structure - * - * Verifies that the response body is valid JSON with the expected structure, - * containing a "message" field with the exception message. - * - * Test data: AccessDeniedException with "Insufficient permissions" - * - * Expected: - * - Response body is valid JSON - * - JSON contains "message" field - * - Message value is "Insufficient permissions" + * Test: Response body contains valid JSON structure + * + * Verifies that the response body is valid JSON with the expected structure, + * containing a "message" field with the error.security.access.denied message. + * + * Test data: AccessDeniedException with a non-localized message + * + * Expected: + * - Response body is valid JSON + * - JSON contains "message" field + * - Message value is the error.security.access.denied message */ @Test public void handle_shouldReturnValidJsonStructure() throws Exception { @@ -147,7 +166,7 @@ public void handle_shouldReturnValidJsonStructure() throws Exception { String responseBody = response.getContentAsString(); assertNotNull(responseBody); assertTrue(responseBody.contains("message")); - assertTrue(responseBody.contains("Insufficient permissions")); + assertTrue(responseBody.contains(message("error.security.access.denied"))); } /** diff --git a/src/test/java/ch/sectioninformatique/auth/security/UserAuthenticationEntryPointTest.java b/src/test/java/ch/sectioninformatique/auth/security/UserAuthenticationEntryPointTest.java index a73eaf1b..79e19b28 100644 --- a/src/test/java/ch/sectioninformatique/auth/security/UserAuthenticationEntryPointTest.java +++ b/src/test/java/ch/sectioninformatique/auth/security/UserAuthenticationEntryPointTest.java @@ -2,8 +2,13 @@ import ch.sectioninformatique.auth.app.errors.ErrorDto; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.BadCredentialsException; @@ -24,8 +29,9 @@ * - Support for various AuthenticationException types (BadCredentialsException, InsufficientAuthenticationException) * * UserAuthenticationEntryPoint is invoked when authentication fails or is missing - * (e.g., invalid credentials, missing token, expired token). + * (e.g., invalid or missing authentication token). */ +@SpringBootTest public class UserAuthenticationEntryPointTest { private UserAuthenticationEntryPoint entryPoint; @@ -33,26 +39,36 @@ public class UserAuthenticationEntryPointTest { private MockHttpServletResponse response; private ObjectMapper objectMapper; + @Autowired + private MessageSource messageSource; + @BeforeEach public void setUp() { - entryPoint = new UserAuthenticationEntryPoint(); + entryPoint = new UserAuthenticationEntryPoint(messageSource); request = new MockHttpServletRequest(); response = new MockHttpServletResponse(); objectMapper = new ObjectMapper(); + LocaleContextHolder.setLocale(java.util.Locale.getDefault()); + } + + @AfterEach + public void tearDown() { + LocaleContextHolder.resetLocaleContext(); } /** - * Test: AuthenticationException returns 401 with custom error message - * - * Verifies that when an AuthenticationException with a custom message is handled, - * the response contains HTTP 401 status, JSON content type, and the exception's message. - * - * Test data: BadCredentialsException with "Invalid credentials" - * - * Expected: - * - HTTP status: 401 Unauthorized - * - Content-Type: application/json - * - Response body: ErrorDto with "Invalid credentials" + * Test: AuthenticationException returns 401 with error.security.authentication.token.invalid.or.missing message + * + * Verifies that when an AuthenticationException with a custom message is handled, + * the response contains HTTP 401 status, JSON content type, and the + * error.security.authentication.token.invalid.or.missing message. + * + * Test data: BadCredentialsException with a non-localized message + * + * Expected: + * - HTTP status: 401 Unauthorized + * - Content-Type: application/json + * - Response body: ErrorDto with the error.security.authentication.token.invalid.or.missing message */ @Test public void commence_withAuthenticationException_shouldReturn401WithMessage() throws Exception { @@ -67,7 +83,12 @@ public void commence_withAuthenticationException_shouldReturn401WithMessage() th assertEquals("application/json", response.getHeader("Content-Type")); ErrorDto errorDto = objectMapper.readValue(response.getContentAsString(), ErrorDto.class); - assertEquals("Invalid credentials", errorDto.message()); + assertEquals( + messageSource.getMessage( + "error.security.authentication.token.invalid.or.missing", + null, + LocaleContextHolder.getLocale()), + errorDto.message()); } /** @@ -81,7 +102,7 @@ public void commence_withAuthenticationException_shouldReturn401WithMessage() th * Expected: * - HTTP status: 401 Unauthorized * - Content-Type: application/json - * - Response body: ErrorDto with "Authentication failed" + * - Response body: ErrorDto with the error.security.authentication.failed message */ @Test public void commence_withNullException_shouldReturn401WithDefaultMessage() throws Exception { @@ -93,7 +114,12 @@ public void commence_withNullException_shouldReturn401WithDefaultMessage() throw assertEquals("application/json", response.getHeader("Content-Type")); ErrorDto errorDto = objectMapper.readValue(response.getContentAsString(), ErrorDto.class); - assertEquals("Authentication failed", errorDto.message()); // Default message when authException is null + assertEquals( + messageSource.getMessage( + "error.security.authentication.failed", + null, + LocaleContextHolder.getLocale()), + errorDto.message()); // Default message when authException is null } /** @@ -107,7 +133,7 @@ public void commence_withNullException_shouldReturn401WithDefaultMessage() throw * Expected: * - HTTP status: 401 Unauthorized * - Content-Type: application/json - * - Response body: ErrorDto with "Invalid or missing authentication token" + * - Response body: ErrorDto with the error.security.authentication.token.invalid.or.missing message */ @Test public void commence_withExceptionWithNullMessage_shouldReturn401WithDefaultMessage() throws Exception { @@ -122,21 +148,26 @@ public void commence_withExceptionWithNullMessage_shouldReturn401WithDefaultMess assertEquals("application/json", response.getHeader("Content-Type")); ErrorDto errorDto = objectMapper.readValue(response.getContentAsString(), ErrorDto.class); - assertEquals("Invalid or missing authentication token", errorDto.message()); + assertEquals( + messageSource.getMessage( + "error.security.authentication.token.invalid.or.missing", + null, + LocaleContextHolder.getLocale()), + errorDto.message()); } /** * Test: Response body contains valid JSON structure * * Verifies that the response body is valid JSON with the expected structure, - * containing a "message" field with the exception message. - * - * Test data: BadCredentialsException with "Token expired" + * containing a "message" field with the error.security.authentication.token.invalid.or.missing message. + * + * Test data: BadCredentialsException with a non-localized message * * Expected: * - Response body is valid JSON * - JSON contains "message" field - * - Message value is "Token expired" + * - Message value is the error.security.authentication.token.invalid.or.missing message */ @Test public void commence_shouldReturnValidJsonStructure() throws Exception { @@ -150,7 +181,11 @@ public void commence_shouldReturnValidJsonStructure() throws Exception { String responseBody = response.getContentAsString(); assertNotNull(responseBody); assertTrue(responseBody.contains("message")); - assertTrue(responseBody.contains("Token expired")); + assertTrue(responseBody.contains( + messageSource.getMessage( + "error.security.authentication.token.invalid.or.missing", + null, + LocaleContextHolder.getLocale()))); } /** @@ -203,11 +238,11 @@ public void commence_shouldSetCorrectStatusCode() throws Exception { * Verifies that InsufficientAuthenticationException (thrown when authentication * is required but not provided) is handled correctly with its custom message. * - * Test data: InsufficientAuthenticationException with "Full authentication is required" + * Test data: InsufficientAuthenticationException with a non-localized message * * Expected: * - HTTP status: 401 Unauthorized - * - Response body: ErrorDto with "Full authentication is required" + * - Response body: ErrorDto with the error.security.authentication.token.invalid.or.missing message */ @Test public void commence_withInsufficientAuthenticationException_shouldReturn401() throws Exception { @@ -221,7 +256,12 @@ public void commence_withInsufficientAuthenticationException_shouldReturn401() t assertEquals(401, response.getStatus()); ErrorDto errorDto = objectMapper.readValue(response.getContentAsString(), ErrorDto.class); - assertEquals("Full authentication is required", errorDto.message()); + assertEquals( + messageSource.getMessage( + "error.security.authentication.token.invalid.or.missing", + null, + LocaleContextHolder.getLocale()), + errorDto.message()); } /** @@ -234,7 +274,7 @@ public void commence_withInsufficientAuthenticationException_shouldReturn401() t * * Expected: * - HTTP status: 401 Unauthorized - * - Response body: ErrorDto with "Invalid or missing authentication token" + * - Response body: ErrorDto with the error.security.authentication.token.invalid.or.missing message */ @Test public void commence_shouldHandleEmptyExceptionMessage() throws Exception { @@ -248,6 +288,11 @@ public void commence_shouldHandleEmptyExceptionMessage() throws Exception { assertEquals(401, response.getStatus()); ErrorDto errorDto = objectMapper.readValue(response.getContentAsString(), ErrorDto.class); - assertEquals("Invalid or missing authentication token", errorDto.message()); + assertEquals( + messageSource.getMessage( + "error.security.authentication.token.invalid.or.missing", + null, + LocaleContextHolder.getLocale()), + errorDto.message()); } } diff --git a/src/test/java/ch/sectioninformatique/auth/security/UserAuthenticationProviderTest.java b/src/test/java/ch/sectioninformatique/auth/security/UserAuthenticationProviderTest.java index 2b572ff9..48cd0251 100644 --- a/src/test/java/ch/sectioninformatique/auth/security/UserAuthenticationProviderTest.java +++ b/src/test/java/ch/sectioninformatique/auth/security/UserAuthenticationProviderTest.java @@ -10,6 +10,7 @@ import ch.sectioninformatique.auth.user.UserDto; import ch.sectioninformatique.auth.user.UserService; +import ch.sectioninformatique.auth.user.UserExceptions.UserNotFoundException; import java.lang.reflect.Method; import java.util.Arrays; @@ -139,7 +140,7 @@ void testValidateTokenStrongly_NewUser() { .build(); String token = authenticationProvider.createToken(user); - when(userService.findByLogin(TEST_LOGIN)).thenThrow(new RuntimeException("User not found")); + when(userService.findByLogin(TEST_LOGIN)).thenThrow(new UserNotFoundException("User not found")); when(userService.createAzureUser(any())).thenReturn(user); // When diff --git a/src/test/java/ch/sectioninformatique/auth/user/UserControllerIntegrationTest.java b/src/test/java/ch/sectioninformatique/auth/user/UserControllerIntegrationTest.java index 510d4a5b..a8365d10 100644 --- a/src/test/java/ch/sectioninformatique/auth/user/UserControllerIntegrationTest.java +++ b/src/test/java/ch/sectioninformatique/auth/user/UserControllerIntegrationTest.java @@ -1,10 +1,16 @@ package ch.sectioninformatique.auth.user; +import java.util.Locale; + import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -105,6 +111,7 @@ private void performRequest( // Set content type requestType.contentType(contentType); + requestType.locale(LocaleContextHolder.getLocale()); // Perform request var request = mockMvc.perform(requestType) @@ -137,6 +144,22 @@ private void performRequest( @Autowired private UserRepository userRepository; + @Autowired + private MessageSource messageSource; + @BeforeEach + public void setUp() { + LocaleContextHolder.setLocale(Locale.FRANCE); + } + + @AfterEach + public void tearDown() { + LocaleContextHolder.resetLocaleContext(); + } + + private String message(String key, Object... args) { + return messageSource.getMessage(key, args, Locale.FRANCE); + } + /** * Test: GET /users/me - Retrieve authenticated user's information * @@ -190,7 +213,7 @@ public void me_withRealData_shouldReturnSuccess() throws Exception { * Expected behavior: * - Returns HTTP 401 (Unauthorized) * - Request is rejected due to missing authentication - * - Response contains error message + * - Response contains localized error message * - No user data is returned * * Test data: @@ -210,7 +233,8 @@ public void me_missingAuthorizationHeader_shouldReturnUnauthorized() throws Exce "me-missing-authorization", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -247,7 +271,8 @@ public void me_withMalformedToken_shouldReturnUnauthorized() throws Exception { "me-malformed-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.invalid"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -263,7 +288,7 @@ public void me_withMalformedToken_shouldReturnUnauthorized() throws Exception { * Expected behavior: * - Returns HTTP 401 (Unauthorized) * - Token expiration validation fails - * - Response contains error message about expired token + * - Response contains localized error message about expired token * - No user data is returned * - Client should request a new token using refresh token * @@ -289,7 +314,8 @@ public void me_withExpiredToken_shouldReturnUnauthorized() throws Exception { "me-expired-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.expired"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -372,7 +398,7 @@ public void all_withRealData_shouldReturnSuccess() throws Exception { * Expected behavior: * - Returns HTTP 401 (Unauthorized) * - Request is rejected due to missing authentication - * - Response contains error message + * - Response contains localized error message * - No user list is returned * * Test data: @@ -392,7 +418,8 @@ public void all_missingAuthorizationHeader_shouldReturnUnauthorized() throws Exc "all-missing-authorization", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -429,7 +456,8 @@ public void all_withMalformedToken_shouldReturnUnauthorized() throws Exception { "all-malformed-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.invalid"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -445,7 +473,7 @@ public void all_withMalformedToken_shouldReturnUnauthorized() throws Exception { * Expected behavior: * - Returns HTTP 401 (Unauthorized) * - Token expiration validation fails - * - Response contains error message about expired token + * - Response contains localized error message about expired token * - No user list is returned * - Client should refresh token and retry * @@ -471,7 +499,8 @@ public void all_withExpiredToken_shouldReturnUnauthorized() throws Exception { "all-expired-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.expired"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -581,7 +610,7 @@ public void deleted_withRealData_shouldReturnSuccess() throws Exception { * Expected behavior: * - Step 1: Soft-delete succeeds (HTTP 200) * - Step 2: Restore succeeds (HTTP 200) - * - Response contains success message: "User restored successfully" + * - Response contains success message: "message.user.restored" * - User is marked as active again in the database * - User can log in after restoration * @@ -620,7 +649,8 @@ public void restoreDeletedUser_withRealData_shouldReturnSuccess() throws Excepti "restore", request -> { try { - request.andExpect(jsonPath("$").value("User restored successfully")); + request.andExpect(jsonPath("$") + .value(message("message.user.restored"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -636,7 +666,7 @@ public void restoreDeletedUser_withRealData_shouldReturnSuccess() throws Excepti * Expected behavior: * - Returns HTTP 200 (OK) * - User is permanently removed from the database - * - Response contains confirmation message: "User deleted permanently" + * - Response contains confirmation message: "message.user.deleted.permanent" * - Response includes the deleted user's login for confirmation * - User cannot be restored after permanent deletion * @@ -663,7 +693,7 @@ public void deletePermanent_withRealData_shouldReturnSuccess() throws Exception request -> { try { request.andExpect(jsonPath("$.message") - .value("User deleted permanently")) + .value(message("message.user.deleted.permanent"))) .andExpect(jsonPath("$.deletedUserLogin") .value("test.user@test.com")); } catch (Exception e) { @@ -681,7 +711,7 @@ public void deletePermanent_withRealData_shouldReturnSuccess() throws Exception * Expected behavior: * - Returns HTTP 200 (OK) * - User's role is changed from USER to MANAGER - * - Response contains success message: "User promoted to manager successfully" + * - Response contains success message: "message.user.promoted.manager" * - Database is updated with new role * - User gains MANAGER permissions immediately * @@ -709,7 +739,7 @@ public void promoteToManager_withRealData_shouldReturnSuccess() throws Exception request -> { try { request.andExpect(content() - .string("User promoted to manager successfully")); + .string(message("message.user.promoted.manager"))); // Assert: fetch user again and verify role changed to MANAGER UserDto updatedUser = userService.findByLogin("test.user@test.com"); @@ -732,7 +762,7 @@ public void promoteToManager_withRealData_shouldReturnSuccess() throws Exception * Expected behavior: * - Returns HTTP 401 (Unauthorized) * - Request is rejected due to missing Authorization header - * - Response contains error message + * - Response contains localized error message * - User's role remains unchanged * * Test data: @@ -754,7 +784,8 @@ public void promoteToManager_missingAuthorizationHeader_shouldReturnUnauthorized "promote-manager-missing-authorization", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -794,7 +825,8 @@ public void promoteToManager_withMalformedToken_shouldReturnUnauthorized() throw "promote-manager-malformed-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.invalid"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -834,7 +866,8 @@ public void promoteToManager_asNonAdmin_shouldReturnForbidden() throws Exception "promote-manager-non-admin", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.access.denied"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -850,7 +883,7 @@ public void promoteToManager_asNonAdmin_shouldReturnForbidden() throws Exception * Expected behavior: * - Returns HTTP 404 (Not Found) * - Request is rejected because user ID doesn't exist - * - Response contains error message about user not found + * - Response contains localized error message about user not found * * Test data: * - Authenticated as: test.admin@test.com (admin user) @@ -875,7 +908,8 @@ public void promoteToManager_userNotFound_shouldReturnNotFound() throws Exceptio "promote-manager-user-not-found", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.user.not.found", fakeUserId))); } catch (Exception e) { throw new RuntimeException(e); } @@ -891,7 +925,7 @@ public void promoteToManager_userNotFound_shouldReturnNotFound() throws Exceptio * Expected behavior: * - Returns HTTP 409 (Conflict) * - Request is rejected because user already has MANAGER role - * - Response contains error message about user already being manager + * - Response contains localized error message about user already being manager * - User's role remains MANAGER (unchanged) * * Test data: @@ -915,7 +949,9 @@ public void promoteToManager_userAlreadyManager_shouldReturnConflict() throws Ex "promote-manager-user-already-manager", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.user.already.manager", + managerDto.getLogin()))); } catch (Exception e) { throw new RuntimeException(e); } @@ -931,7 +967,7 @@ public void promoteToManager_userAlreadyManager_shouldReturnConflict() throws Ex * Expected behavior: * - Returns HTTP 409 (Conflict) * - Request is rejected because user has ADMIN role (higher than MANAGER) - * - Response contains error message about conflicting role + * - Response contains localized error message about conflicting role * - User's role remains ADMIN (unchanged) * * Test data: @@ -954,7 +990,9 @@ public void promoteToManager_userAlreadyAdmin_shouldReturnConflict() throws Exce "promote-manager-user-already-admin", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.user.already.admin", + adminDto.getLogin()))); } catch (Exception e) { throw new RuntimeException(e); } @@ -970,7 +1008,7 @@ public void promoteToManager_userAlreadyAdmin_shouldReturnConflict() throws Exce * Expected behavior: * - Returns HTTP 200 (OK) * - User's role is changed from MANAGER to USER - * - Response contains success message: "Manager role revoked successfully" + * - Response contains success message: "message.user.revoked.manager" * - Database is updated with new role * - User loses manager permissions immediately * @@ -998,7 +1036,7 @@ public void revokeManagerRole_withRealData_shouldReturnSuccess() throws Exceptio request -> { try { request.andExpect( - content().string("Manager role revoked successfully")); + content().string(message("message.user.revoked.manager"))); // Assert: fetch manager again and verify role changed to USER UserDto updatedManager = userService @@ -1022,7 +1060,7 @@ public void revokeManagerRole_withRealData_shouldReturnSuccess() throws Exceptio * Expected behavior: * - Returns HTTP 401 (Unauthorized) * - Request is rejected due to missing Authorization header - * - Response contains error message + * - Response contains localized error message * - User's role remains unchanged * * Test data: @@ -1044,7 +1082,8 @@ public void revokeManagerRole_missingAuthorizationHeader_shouldReturnUnauthorize "revoke-manager-missing-authorization", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1083,7 +1122,8 @@ public void revokeManagerRole_withMalformedToken_shouldReturnUnauthorized() thro "revoke-manager-malformed-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.invalid"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1124,7 +1164,8 @@ public void revokeManagerRole_asNonAdmin_shouldReturnForbidden() throws Exceptio "revoke-manager-non-admin", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.access.denied"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1140,7 +1181,7 @@ public void revokeManagerRole_asNonAdmin_shouldReturnForbidden() throws Exceptio * Expected behavior: * - Returns HTTP 404 (Not Found) * - Request is rejected because user ID doesn't exist - * - Response contains error message about user not found + * - Response contains localized error message about user not found * * Test data: * - Authenticated as: test.admin@test.com (admin user) @@ -1163,7 +1204,8 @@ public void revokeManagerRole_userNotFound_shouldReturnNotFound() throws Excepti "revoke-manager-user-not-found", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.user.not.found", "9999"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1179,7 +1221,7 @@ public void revokeManagerRole_userNotFound_shouldReturnNotFound() throws Excepti * Expected behavior: * - Returns HTTP 200 (OK) * - User's role is changed to ADMIN - * - Response contains success message: "Admin role assigned successfully" + * - Response contains success message: "message.user.promoted.admin" * - Database is updated with new role * - User gains all administrative permissions immediately * @@ -1206,7 +1248,7 @@ public void promoteToAdmin_withRealData_shouldReturnSuccess() throws Exception { "promote-admin", request -> { try { - request.andExpect(content().string("Admin role assigned successfully")); + request.andExpect(content().string(message("message.user.promoted.admin"))); // Assert: fetch manager again and verify role changed to ADMIN UserDto updatedManager = userService @@ -1230,7 +1272,7 @@ public void promoteToAdmin_withRealData_shouldReturnSuccess() throws Exception { * Expected behavior: * - Returns HTTP 401 (Unauthorized) * - Request is rejected due to missing Authorization header - * - Response contains error message + * - Response contains localized error message * - User's role remains unchanged * * Test data: @@ -1252,7 +1294,8 @@ public void promoteToAdmin_missingAuthorizationHeader_shouldReturnUnauthorized() "promote-admin-missing-authorization", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1291,7 +1334,8 @@ public void promoteToAdmin_withMalformedToken_shouldReturnUnauthorized() throws "promote-admin-malformed-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.invalid"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1332,7 +1376,8 @@ public void promoteToAdmin_asNonAdmin_shouldReturnForbidden() throws Exception { "promote-admin-non-admin", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.access.denied"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1348,7 +1393,7 @@ public void promoteToAdmin_asNonAdmin_shouldReturnForbidden() throws Exception { * Expected behavior: * - Returns HTTP 404 (Not Found) * - Request is rejected because user ID doesn't exist - * - Response contains error message about user not found + * - Response contains localized error message about user not found * * Test data: * - Authenticated as: test.admin@test.com (admin user) @@ -1373,7 +1418,8 @@ public void promoteToAdmin_userNotFound_shouldReturnNotFound() throws Exception "promote-admin-user-not-found", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.user.not.found", fakeUserId))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1389,7 +1435,7 @@ public void promoteToAdmin_userNotFound_shouldReturnNotFound() throws Exception * Expected behavior: * - Returns HTTP 409 (Conflict) * - Request is rejected because user already has ADMIN role - * - Response contains error message about user already being admin + * - Response contains localized error message about user already being admin * - User's role remains ADMIN (unchanged) * * Test data: @@ -1413,7 +1459,9 @@ public void promoteToAdmin_userAlreadyAdmin_shouldReturnConflict() throws Except "promote-admin-user-already-admin", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.user.already.admin", + adminDto.getLogin()))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1429,7 +1477,7 @@ public void promoteToAdmin_userAlreadyAdmin_shouldReturnConflict() throws Except * Expected behavior: * - Returns HTTP 200 (OK) * - User's role is changed from ADMIN to USER - * - Response contains success message: "Admin role revoked successfully" + * - Response contains success message: "message.user.revoked.admin" * - Database is updated with new role * - User loses admin permissions immediately * @@ -1456,7 +1504,7 @@ public void revokeAdminRole_withRealData_shouldReturnSuccess() throws Exception "revoke-admin", request -> { try { - request.andExpect(content().string("Admin role revoked successfully")); + request.andExpect(content().string(message("message.user.revoked.admin"))); // Assert: fetch admin again and verify role changed to USER UserDto updatedAdmin = userService.findByLogin("test.admin2@test.com"); @@ -1479,7 +1527,7 @@ public void revokeAdminRole_withRealData_shouldReturnSuccess() throws Exception * Expected behavior: * - Returns HTTP 401 (Unauthorized) * - Request is rejected due to missing Authorization header - * - Response contains error message + * - Response contains localized error message * - User's role remains unchanged * * Test data: @@ -1501,7 +1549,8 @@ public void revokeAdminRole_missingAuthorizationHeader_shouldReturnUnauthorized( "revoke-admin-missing-authorization", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); @@ -1541,7 +1590,8 @@ public void revokeAdminRole_withMalformedToken_shouldReturnUnauthorized() throws "revoke-admin-malformed-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.invalid"))); } catch (Exception e) { throw new RuntimeException(e); @@ -1583,7 +1633,8 @@ public void revokeAdminRole_asNonAdmin_shouldReturnForbidden() throws Exception "revoke-admin-non-admin", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.access.denied"))); } catch (Exception e) { throw new RuntimeException(e); @@ -1600,7 +1651,7 @@ public void revokeAdminRole_asNonAdmin_shouldReturnForbidden() throws Exception * Expected behavior: * - Returns HTTP 404 (Not Found) * - Request is rejected because user ID doesn't exist - * - Response contains error message about user not found + * - Response contains localized error message about user not found * * Test data: * - Authenticated as: test.admin@test.com (admin user) @@ -1623,7 +1674,8 @@ public void revokeAdminRole_userNotFound_shouldReturnNotFound() throws Exception "revoke-admin-user-not-found", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.user.not.found", "9999"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1639,7 +1691,7 @@ public void revokeAdminRole_userNotFound_shouldReturnNotFound() throws Exception * Expected behavior: * - Returns HTTP 200 (OK) * - User's role is changed from ADMIN to MANAGER - * - Response contains success message: "Admin role downgraded successfully" + * - Response contains success message: "message.user.downgraded.admin" * - Database is updated with new role * - User loses admin permissions but retains manager permissions * @@ -1667,7 +1719,7 @@ public void downgradeAdminRole_withRealData_shouldReturnSuccess() throws Excepti request -> { try { request.andExpect( - content().string("Admin role downgraded successfully")); + content().string(message("message.user.downgraded.admin"))); // Assert: fetch admin again and verify role changed to MANAGER UserDto updatedAdmin = userService.findByLogin("test.admin2@test.com"); @@ -1690,7 +1742,7 @@ public void downgradeAdminRole_withRealData_shouldReturnSuccess() throws Excepti * Expected behavior: * - Returns HTTP 401 (Unauthorized) * - Request is rejected due to missing Authorization header - * - Response contains error message + * - Response contains localized error message * - User's role remains unchanged * * Test data: @@ -1712,7 +1764,8 @@ public void downgradeAdminRole_missingAuthorizationHeader_shouldReturnUnauthoriz "downgrade-admin-missing-authorization", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); @@ -1752,7 +1805,8 @@ public void downgradeAdminRole_withMalformedToken_shouldReturnUnauthorized() thr "downgrade-admin-malformed-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.invalid"))); } catch (Exception e) { throw new RuntimeException(e); @@ -1770,7 +1824,7 @@ public void downgradeAdminRole_withMalformedToken_shouldReturnUnauthorized() thr * - Returns HTTP 200 (OK) * - User is marked as deleted in the database (isDeleted = true) * - User record remains in database for audit/recovery purposes - * - Response contains success message: "User deleted successfully" + * - Response contains success message: "message.user.deleted" * - Response includes the deleted user's login for confirmation * - User cannot log in after soft deletion * @@ -1798,7 +1852,7 @@ public void deleteUser_withRealData_shouldReturnSuccess() throws Exception { request -> { try { request.andExpect(jsonPath("$.message") - .value("User deleted successfully")) + .value(message("message.user.deleted"))) .andExpect(jsonPath("$.deletedUserLogin") .value("test.user@test.com")); @@ -1821,7 +1875,7 @@ public void deleteUser_withRealData_shouldReturnSuccess() throws Exception { * Expected behavior: * - Returns HTTP 401 (Unauthorized) * - Request is rejected due to missing Authorization header - * - Response contains error message + * - Response contains localized error message * - User remains active (not deleted) * * Test data: @@ -1843,7 +1897,8 @@ public void deleteUser_missingAuthorizationHeader_shouldReturnUnauthorized() thr "delete-missing-authorization", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.authentication.token.invalid.or.missing"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1883,7 +1938,8 @@ public void deleteUser_withMalformedToken_shouldReturnUnauthorized() throws Exce "delete-malformed-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(message("error.security.token.invalid"))); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/src/test/java/ch/sectioninformatique/auth/user/UserServiceTest.java b/src/test/java/ch/sectioninformatique/auth/user/UserServiceTest.java index 8de9c972..ca37505d 100644 --- a/src/test/java/ch/sectioninformatique/auth/user/UserServiceTest.java +++ b/src/test/java/ch/sectioninformatique/auth/user/UserServiceTest.java @@ -8,17 +8,17 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.password.PasswordEncoder; -import ch.sectioninformatique.auth.app.exceptions.AppException; +import ch.sectioninformatique.auth.auth.AuthExceptions; import ch.sectioninformatique.auth.auth.CredentialsDto; import ch.sectioninformatique.auth.auth.SignUpDto; import ch.sectioninformatique.auth.security.Role; import ch.sectioninformatique.auth.security.RoleEnum; import ch.sectioninformatique.auth.security.RoleRepository; +import ch.sectioninformatique.auth.security.SecurityExceptions; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContext; -import java.nio.CharBuffer; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -111,7 +111,7 @@ void login_Successful_ReturnsUserDto() { /** * Test: Login fails when user doesn't exist * - * Verifies that the login method throws AppException when attempting + * Verifies that the login method throws InvalidCredentialsException when attempting * to authenticate with an email that doesn't exist in the database. * * Arrange: @@ -119,11 +119,11 @@ void login_Successful_ReturnsUserDto() { * * Act & Assert: * - Call userService.login() with non-existent email - * - Verify AppException is thrown with message "Invalid credentials" + * - Verify InvalidCredentialsException is thrown * - Error message should not reveal whether user exists (security best practice) */ @Test - void login_UserNotFound_ThrowsAppException() { + void login_UserNotFound_ThrowsInvalidCredentialsException() { // Arrange String login = "nonexistent@test.com"; String password = "password123"; @@ -131,15 +131,16 @@ void login_UserNotFound_ThrowsAppException() { when(userRepository.findByLogin(login)).thenReturn(Optional.empty()); // Act & Assert - AppException exception = assertThrows(AppException.class, - () -> userService.login(new CredentialsDto(login, password.toCharArray()))); - assertEquals("Invalid credentials", exception.getMessage()); + assertThrows( + AuthExceptions.InvalidCredentialsException.class, + () -> userService.login(new CredentialsDto(login, password.toCharArray())) + ); } /** * Test: Login fails with incorrect password * - * Verifies that the login method throws AppException when the password + * Verifies that the login method throws InvalidCredentialsException when the password * doesn't match the user's hashed password. * * Arrange: @@ -148,11 +149,11 @@ void login_UserNotFound_ThrowsAppException() { * * Act & Assert: * - Call userService.login() with wrong password - * - Verify AppException is thrown with message "Invalid credentials" + * - Verify InvalidCredentialsException is thrown * - Error message should be same as user not found (security best practice) */ @Test - void login_InvalidPassword_ThrowsAppException() { + void login_InvalidPassword_ThrowsInvalidCredentialsException() { // Arrange String login = "john@test.com"; String password = "wrongpassword"; @@ -162,9 +163,10 @@ void login_InvalidPassword_ThrowsAppException() { when(passwordEncoder.matches(password, user.getPassword())).thenReturn(false); // Act & Assert - AppException exception = assertThrows(AppException.class, - () -> userService.login(new CredentialsDto(login, password.toCharArray()))); - assertEquals("Invalid credentials", exception.getMessage()); + assertThrows( + AuthExceptions.InvalidCredentialsException.class, + () -> userService.login(new CredentialsDto(login, password.toCharArray())) + ); } /** @@ -230,18 +232,18 @@ void register_Successful_ReturnsUserDto() { * Test: Registration fails when email already exists * * Verifies that attempting to register with an email that already exists - * throws an AppException to prevent duplicate accounts. + * throws UserAlreadyExistsException to prevent duplicate accounts. * * Arrange: * - Mock repository to return existing user with the same login * * Act & Assert: * - Call userService.register() with duplicate email - * - Verify AppException is thrown with message indicating user already exists + * - Verify UserAlreadyExistsException is thrown with login context * - No new user is created */ @Test - void register_LoginExists_ThrowsAppException() { + void register_LoginExists_ThrowsUserAlreadyExistsException() { // Arrange String login = "existing@test.com"; String password = "password123"; @@ -252,9 +254,11 @@ void register_LoginExists_ThrowsAppException() { when(userRepository.findByLogin(login)).thenReturn(Optional.of(existingUser)); // Act & Assert - AppException exception = assertThrows(AppException.class, - () -> userService.register(signUpDto)); - assertEquals("User already exists: existing@test.com", exception.getMessage()); + UserExceptions.UserAlreadyExistsException exception = assertThrows( + UserExceptions.UserAlreadyExistsException.class, + () -> userService.register(signUpDto) + ); + assertEquals("existing@test.com", exception.getLogin()); } /** @@ -335,9 +339,11 @@ void promoteToManager_UserNotFound_ThrowsRuntimeException() { when(userRepository.findById(userId)).thenReturn(Optional.empty()); // Act & Assert - RuntimeException exception = assertThrows(RuntimeException.class, - () -> userService.promoteToManager(userId)); - assertEquals("User not found: 1", exception.getMessage()); + UserExceptions.UserNotFoundException exception = assertThrows( + UserExceptions.UserNotFoundException.class, + () -> userService.promoteToManager(userId) + ); + assertEquals("1", exception.getLoginOrId()); } /** @@ -373,9 +379,11 @@ void promoteToManager_AlreadyManager_ThrowsRuntimeException() { when(userRepository.findById(userId)).thenReturn(Optional.of(user)); // Act & Assert - RuntimeException exception = assertThrows(RuntimeException.class, - () -> userService.promoteToManager(userId)); - assertEquals("User already manager: john@test.com", exception.getMessage()); + UserExceptions.UserAlreadyManagerException exception = assertThrows( + UserExceptions.UserAlreadyManagerException.class, + () -> userService.promoteToManager(userId) + ); + assertEquals("john@test.com", exception.getLogin()); } /** @@ -494,8 +502,10 @@ void deleteUser_Unauthorized_ThrowsRuntimeException() { when(userRepository.findByLogin("user@test.com")).thenReturn(Optional.of(authenticatedUser)); // Act & Assert - RuntimeException exception = assertThrows(RuntimeException.class, - () -> userService.deleteUser(userId)); - assertEquals("User has insufficient rights: user@test.com", exception.getMessage()); + SecurityExceptions.UserHasLowerRightsException exception = assertThrows( + SecurityExceptions.UserHasLowerRightsException.class, + () -> userService.deleteUser(userId) + ); + assertEquals("user@test.com", exception.getLogin()); } } \ No newline at end of file