diff --git a/Cargo.lock b/Cargo.lock index 257c137..8c6ae58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1972,6 +1972,7 @@ dependencies = [ "async-trait", "axum", "deadpool-redis", + "futures-util", "jsonwebtoken", "once_cell", "postgres", diff --git a/Cargo.toml b/Cargo.toml index ff268b9..5e53cd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,20 +9,21 @@ repository = "https://github.com/mairie360/API_lib" [dependencies] actix-web = "4" -jsonwebtoken = { version = "10.3.0", default-features = false, features = ["rust_crypto"] } +anyhow = "1.0.102" async-trait = "0.1.89" +axum = "0.8.8" +deadpool-redis = { version = "0.23.0", features = ["rt_tokio_1"] } +futures-util = "0.3" +jsonwebtoken = { version = "10.3.0", default-features = false, features = ["rust_crypto"] } once_cell = "1.21.3" redis = "1.0.3" -tokio = { version = "1.49.0", features = ["full"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" -tokio-postgres = { version = "0.7", features = ["with-uuid-1"] } -thiserror = "2.0.18" -deadpool-redis = { version = "0.23.0", features = ["rt_tokio_1"] } sqlx = { version = "0.8.6", features = ["postgres", "ipnetwork", "uuid", "runtime-tokio-rustls"] } -axum = "0.8.8" -anyhow = "1.0.102" testcontainers = "0.27.0" +thiserror = "2.0.18" +tokio = { version = "1.49.0", features = ["full"] } +tokio-postgres = { version = "0.7", features = ["with-uuid-1"] } [dev-dependencies] serial_test = "3.3.1" diff --git a/src/jwt_manager/generate_jwt.rs b/src/jwt_manager/generate_jwt.rs index fb2d0bd..8101a4c 100644 --- a/src/jwt_manager/generate_jwt.rs +++ b/src/jwt_manager/generate_jwt.rs @@ -5,27 +5,19 @@ use jsonwebtoken::{encode, EncodingKey, Header}; use std::time::{SystemTime, UNIX_EPOCH}; pub fn generate_jwt(user_id_str: &str) -> Result { - let secret: Vec = get_jwt_secret().map_err(|_e| { - jsonwebtoken::errors::Error::from(jsonwebtoken::errors::ErrorKind::InvalidKeyFormat) - })?; - let timeout = get_jwt_timeout().map_err(|_e| { - jsonwebtoken::errors::Error::from(jsonwebtoken::errors::ErrorKind::InvalidKeyFormat) - }); - match timeout { - Ok(t) => { - let expiration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as usize - + t; // Token valid for the configured JWT timeout duration - let claims = Claims::new(user_id_str.to_owned(), expiration); - let token = encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(&secret), - )?; - Ok(token) - } - Err(e) => Err(e), - } + let secret: Vec = get_jwt_secret()?; + let timeout = get_jwt_timeout()?; + + let expiration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as usize + + timeout; // Token valid for the configured JWT timeout duration + let claims = Claims::new(user_id_str.to_owned(), expiration); + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(&secret), + )?; + Ok(token) } diff --git a/src/jwt_manager/get_jwt_secret.rs b/src/jwt_manager/get_jwt_secret.rs index 7b64978..bc8fc6e 100644 --- a/src/jwt_manager/get_jwt_secret.rs +++ b/src/jwt_manager/get_jwt_secret.rs @@ -1,8 +1,10 @@ use crate::env_manager::get_env_var; -pub fn get_jwt_secret() -> Result, String> { +pub fn get_jwt_secret() -> Result, jsonwebtoken::errors::ErrorKind> { match get_env_var("JWT_SECRET") { Some(secret) => Ok(secret.into_bytes()), - None => Err("JWT_SECRET environment variable not set".to_string()), + None => Err(jsonwebtoken::errors::ErrorKind::MissingRequiredClaim( + "JWT_SECRET environment variable not set".to_string(), + )), } } diff --git a/src/jwt_manager/get_jwt_timeout.rs b/src/jwt_manager/get_jwt_timeout.rs index a6f4018..8760364 100644 --- a/src/jwt_manager/get_jwt_timeout.rs +++ b/src/jwt_manager/get_jwt_timeout.rs @@ -1,10 +1,15 @@ +use jsonwebtoken::errors::ErrorKind::InvalidKeyFormat; + use crate::env_manager::get_env_var; -pub fn get_jwt_timeout() -> Result { +pub fn get_jwt_timeout() -> Result { match get_env_var("JWT_TIMEOUT") { - Some(secret) => secret - .parse::() - .map_err(|_| "JWT_TIMEOUT is not a valid usize".to_string()), - None => Err("JWT_TIMEOUT environment variable not set".to_string()), + Some(secret) => { + let secret = secret.parse::().map_err(|_| InvalidKeyFormat)?; + Ok(secret) + } + None => Err(jsonwebtoken::errors::ErrorKind::MissingRequiredClaim( + "JWT_TIMEOUT environment variable not set".to_string(), + )), } } diff --git a/src/lib.rs b/src/lib.rs index db22932..eaa613e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,4 +3,5 @@ pub mod env_manager; pub mod jwt_manager; pub mod pool; mod redis; +pub mod security; pub mod test_setup; diff --git a/src/pool/mod.rs b/src/pool/mod.rs index db7cf58..f1af231 100644 --- a/src/pool/mod.rs +++ b/src/pool/mod.rs @@ -4,37 +4,49 @@ use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; pub struct AppState { - redis_pool: Pool, - pub db_pool: PgPool, + redis_pool: Option, + pub db_pool: Option, } impl AppState { pub async fn new(redis_url: String, pg_url: String) -> Self { // --- Initialisation Redis --- let redis_cfg = Config::from_url(redis_url); - let redis_pool = redis_cfg - .create_pool(Some(Runtime::Tokio1)) - .expect("Failed to create Redis pool"); + let redis_pool = redis_cfg.create_pool(Some(Runtime::Tokio1)); // --- Initialisation PostgreSQL --- let db_pool = PgPoolOptions::new() .max_connections(5) .acquire_timeout(std::time::Duration::from_secs(3)) .connect(&pg_url) - .await - .expect("Failed to create Postgres pool"); + .await; + + eprintln!("redis status: {:?}", redis_pool.is_ok()); + eprintln!("pg status: {:?}", db_pool.is_ok()); Self { - redis_pool, - db_pool, + redis_pool: match redis_pool { + Ok(pool) => Some(pool), + Err(_) => None, + }, + db_pool: match db_pool { + Ok(pool) => Some(pool), + Err(_) => None, + }, } } - pub async fn get_redis_conn(&self) -> deadpool_redis::Connection { - self.redis_pool.get().await.unwrap() + pub async fn get_redis_conn(&self) -> Option { + match &self.redis_pool { + Some(pool) => pool.get().await.ok(), + None => None, + } } - pub async fn get_db_conn(&self) -> sqlx::pool::PoolConnection { - self.db_pool.acquire().await.unwrap() + pub async fn get_db_conn(&self) -> Option> { + match &self.db_pool { + Some(pool) => pool.acquire().await.ok(), + None => None, + } } } diff --git a/src/pool/redis/handle_get.rs b/src/pool/redis/handle_get.rs index 96adf4d..270fbb3 100644 --- a/src/pool/redis/handle_get.rs +++ b/src/pool/redis/handle_get.rs @@ -3,7 +3,17 @@ use axum::{extract::State, http::StatusCode, response::IntoResponse}; use std::sync::Arc; pub async fn handle_get(State(state): State>) -> impl IntoResponse { - match state.redis_pool.get().await { + let pool = match state.redis_pool.as_ref() { + Some(pool) => pool, + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Redis pool not initialized", + ) + .into_response() + } + }; + match pool.get().await { Ok(_) => (StatusCode::OK, "Connexion Redis réussie !").into_response(), Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Échec connexion Redis").into_response(), } diff --git a/src/security/auth_middleware.rs b/src/security/auth_middleware.rs new file mode 100644 index 0000000..ac65129 --- /dev/null +++ b/src/security/auth_middleware.rs @@ -0,0 +1,149 @@ +use actix_web::{ + body::EitherBody, + dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, + Error, HttpMessage, HttpResponse, +}; +use futures_util::future::LocalBoxFuture; +use std::future::{ready, Ready}; +use std::rc::Rc; + +use crate::jwt_manager::{ + check_jwt_validity, get_jwt_from_request, get_user_id_from_jwt, JWTCheckError, +}; +use crate::pool::AppState; + +use crate::security::AuthenticatedUser; + +/** + * Middleware to check the validity of JWT tokens in incoming requests. + * If the token is valid, the request is passed to the next service in the chain. + * If the token is invalid or missing, an appropriate HTTP response is returned. + */ +pub struct JwtMiddleware; + +impl Transform for JwtMiddleware +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type InitError = (); + type Transform = JwtMiddlewareService; + type Future = Ready>; + + /** + * Creates a new instance of the middleware service, wrapping the provided service. + */ + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(JwtMiddlewareService { + service: Rc::new(service), + })) + } +} + +/** + * Service that implements the actual logic of checking JWT tokens for each incoming request. + * It uses the `get_jwt_from_request` function to extract the token and the `check_jwt_validity` function to validate it. + * Depending on the result, it either forwards the request to the next service or returns an appropriate HTTP response. + */ +pub struct JwtMiddlewareService { + service: Rc, +} + +impl Service for JwtMiddlewareService +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + /** + * Handles the incoming request by checking for a JWT token and validating it. + */ + fn call(&self, req: ServiceRequest) -> Self::Future { + let svc = self.service.clone(); + let app_state = req.app_data::>(); + + // On clone le pool pour la closure async move + let pool = match app_state { + Some(state) => state.db_pool.clone(), + None => None, + }; + + let path = req.path(); + if path == "/" + || path.starts_with("/swagger-ui") + || path.starts_with("/api-docs") + || path.contains("/auth") + { + return Box::pin(async move { + let res = svc.call(req).await?; + Ok(res.map_into_left_body()) + }); + } + + Box::pin(async move { + let pool = match pool { + Some(p) => p, + None => { + // Erreur si le pool n'a pas été injecté dans l'App + let res = HttpResponse::InternalServerError() + .body("DB Pool missing") + .map_into_right_body(); + return Ok(req.into_response(res)); + } + }; + + let jwt_option = get_jwt_from_request(req.request()); + + let jwt = match jwt_option { + Some(token) => token, + None => { + eprint!("no jwt"); + let response = HttpResponse::Unauthorized() + .body("Unauthorized: No JWT token provided.") + .map_into_right_body(); + return Ok(req.into_response(response)); + } + }; + + match check_jwt_validity(&jwt, pool).await { + Ok(_) => { + // ON AJOUTE L'UTILISATEUR DANS LES EXTENSIONS + // Supposons que claims.sub contient l'ID + req.extensions_mut().insert(AuthenticatedUser { + id: get_user_id_from_jwt(&jwt).unwrap().parse().unwrap_or(0), + }); + + let res = svc.call(req).await?; + Ok(res.map_into_left_body()) + } + Err(error) => { + eprint!("error no jwt"); + let response = + match error { + JWTCheckError::DatabaseError => HttpResponse::InternalServerError() + .body("Internal server error: Database not initialized."), + JWTCheckError::NoTokenProvided => HttpResponse::Unauthorized() + .body("Unauthorized: No JWT token provided."), + JWTCheckError::ExpiredToken => HttpResponse::Unauthorized() + .body("Unauthorized: JWT token is expired."), + JWTCheckError::InvalidToken => HttpResponse::Unauthorized() + .body("Unauthorized: Invalid JWT token."), + JWTCheckError::UnknownUser => { + HttpResponse::NotFound().body("User not found.") + } + }; + Ok(req.into_response(response.map_into_right_body())) + } + } + }) + } +} diff --git a/src/security/auth_user.rs b/src/security/auth_user.rs new file mode 100644 index 0000000..5a8737a --- /dev/null +++ b/src/security/auth_user.rs @@ -0,0 +1,27 @@ +use actix_web::HttpMessage; + +use actix_web::{dev::Payload, FromRequest, HttpRequest}; +use futures_util::future::{ready, Ready}; + +#[derive(Copy, Clone)] +pub struct AuthenticatedUser { + pub id: u64, +} + +impl FromRequest for AuthenticatedUser { + type Error = actix_web::Error; + type Future = Ready>; + + fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { + // Comme ton Middleware a DEJA validé le token et l'a mis dans les extensions : + if let Some(user) = req.extensions().get::() { + return ready(Ok(AuthenticatedUser { id: user.id })); + } + + // Si on arrive ici, c'est que le middleware n'a pas fait son job + // ou que la route n'est pas protégée + ready(Err(actix_web::error::ErrorUnauthorized( + "User not authenticated", + ))) + } +} diff --git a/src/security/mod.rs b/src/security/mod.rs new file mode 100644 index 0000000..8b4a572 --- /dev/null +++ b/src/security/mod.rs @@ -0,0 +1,6 @@ +mod auth_middleware; +pub use auth_middleware::JwtMiddleware; +mod auth_user; +pub use auth_user::AuthenticatedUser; +mod right_middleware; +pub use right_middleware::{access_guard_middleware, AccessCheckConfig}; diff --git a/src/security/right_middleware.rs b/src/security/right_middleware.rs new file mode 100644 index 0000000..d47db91 --- /dev/null +++ b/src/security/right_middleware.rs @@ -0,0 +1,85 @@ +use actix_web::{ + body::BoxBody, + dev::{ServiceRequest, ServiceResponse}, + middleware::Next, +}; +use actix_web::{Error, HttpMessage}; + +use crate::{pool::AppState, security::AuthenticatedUser}; + +#[derive(Clone)] +pub struct AccessCheckConfig { + /// Le nom de la ressource (ex: "users", "sessions") + pub resource_name: &'static str, + /// L'action requise (ex: "read", "write") + pub action: &'static str, + /// Le nom du paramètre dans l'URL (ex: "user_id", "id") + /// Si None, on considère que c'est une vérification globale (instance = NULL) + pub id_param_pattern: Option<&'static str>, +} + +pub async fn access_guard_middleware( + req: ServiceRequest, + next: Next, +) -> Result, Error> { + // 1. Récupérer la config de la route + let config = req.app_data::().ok_or_else(|| { + actix_web::error::ErrorInternalServerError("AccessConfig missing on route") + })?; + + // 2. Récupérer l'utilisateur injecté par JwtMiddleware + let user = req + .extensions() + .get::() + .copied() + .ok_or_else(|| actix_web::error::ErrorUnauthorized("User not authenticated"))?; + + // 3. Extraire l'ID de l'instance dans l'URL (si défini) + let mut instance_id: Option = None; + if let Some(param_name) = config.id_param_pattern { + if let Some(val) = req.match_info().get(param_name) { + instance_id = val.parse::().ok(); + if instance_id.is_none() { + return Err(actix_web::error::ErrorBadRequest( + "Invalid ID format in URL", + )); + } + } + } + + // 4. Appel à ta fonction DB + let app_state = req + .app_data::>() + .ok_or_else(|| actix_web::error::ErrorInternalServerError("AppState missing"))?; + + let db_pool = match app_state.db_pool.clone() { + Some(pool) => pool, + None => { + return Err(actix_web::error::ErrorInternalServerError( + "Database pool missing", + )) + } + }; + + // On exécute la requête SQL que tu as écrite + let is_allowed = sqlx::query_scalar::<_, bool>("SELECT check_access($1, $2, $3, $4)") + .bind(user.id as i32) + .bind(config.resource_name) + .bind(config.action) + .bind(instance_id) + .fetch_one(&db_pool) + .await + .map_err(|e| { + eprintln!("{:?}", e); + actix_web::error::ErrorInternalServerError("Database error during access check") + })?; + + // 5. Verdict + if is_allowed { + next.call(req).await + } else { + // Optionnel : Tu pourrais log ici en Rust aussi, + // mais ta fonction SQL s'en occupe déjà ! + Err(actix_web::error::ErrorForbidden("Insufficient permissions")) + } +} diff --git a/src/test_setup/queries_setup.rs b/src/test_setup/queries_setup.rs index ab06fe1..9a0d10d 100644 --- a/src/test_setup/queries_setup.rs +++ b/src/test_setup/queries_setup.rs @@ -4,7 +4,12 @@ use testcontainers::{ContainerAsync, GenericImage}; use tokio::sync::OnceCell; use tokio_postgres::{Client, NoTls}; -/// 1. Démarre le conteneur et expose le client SQL +// Utilisation de OnceCell pour stocker les IDs récupérés dynamiquement +pub static ALICE_ID: OnceCell = OnceCell::const_new(); +pub static BOB_ID: OnceCell = OnceCell::const_new(); +pub static ADMIN_ID: OnceCell = OnceCell::const_new(); +pub static GROUP_OWNER_ID: OnceCell = OnceCell::const_new(); + pub async fn setup_test_container() -> (ContainerAsync, Client, String) { let (node, _) = start_postgres_container().await; let host = "127.0.0.1"; @@ -25,146 +30,184 @@ pub async fn setup_test_container() -> (ContainerAsync, Client, St } }); - // Nettoyage initial systématique - client - .batch_execute( - " - -- On vide TOUTES les tables impactées par le seeding - TRUNCATE TABLE - access_control, - rights, - user_roles, - roles, - permissions, - resources, - sessions, - users - RESTART IDENTITY CASCADE; - ", - ) - .await - .unwrap(); - (node, client, postgres_url) } -pub static ALICE_ID: u64 = 1; - -/// 2. Setup pour un utilisateur valide avec une session active +/// 2. Setup pour Alice (Utilisateur actif) pub async fn setup_active_session(client: &Client) { - client.batch_execute(" + let row = client.query_one(" INSERT INTO users (first_name, last_name, email, password, phone_number, status, is_archived) - VALUES ('Alice', 'Smith', 'alice@example.com', 'password123', '0102030405', 'active', FALSE); + VALUES ('Alice', 'Smith', 'alice@example.com', 'password123', '0102030405', 'active', FALSE) + ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email + RETURNING id; + ", &[]).await.expect("Failed to insert Alice"); - INSERT INTO sessions (user_id, user_is_archived, token_hash, ip_address, device_info) - VALUES (1, FALSE, 'test_token_hash_unique_123', '127.0.0.1', 'Mozilla/5.0 (TestRunner)'); - ").await.expect("Failed to setup active session"); -} + let id: i32 = row.get(0); + ALICE_ID.set(id).ok(); -/// 3. Setup pour tester un token expiré -pub async fn setup_expired_session(client: &Client) { - // Note : On utilise l'ID 1 supposant qu'Alice existe déjà ou on la crée si besoin client - .batch_execute( + .execute( " INSERT INTO sessions (user_id, user_is_archived, token_hash, ip_address, device_info) - VALUES (1, FALSE, 'test_token_hash_expired', '127.0.0.1', 'Mozilla/5.0'); - - UPDATE sessions - SET expires_at = now() - INTERVAL '1 hour' - WHERE token_hash = 'test_token_hash_expired'; + VALUES ($1, FALSE, 'test_token_hash_unique_123', '127.0.0.1', 'Mozilla/5.0 (TestRunner)') + ON CONFLICT DO NOTHING; ", + &[&id], ) .await - .expect("Failed to setup expired session"); + .expect("Failed to setup active session"); } -pub static BOB_ID: u64 = 1; +/// 3. Setup Token expiré (réutilise Alice) +pub async fn setup_expired_session(client: &Client) { + let id = *ALICE_ID.get().expect("Alice ID not initialized"); + + client.execute(" + INSERT INTO sessions (user_id, user_is_archived, token_hash, ip_address, device_info, expires_at) + VALUES ($1, FALSE, 'test_token_hash_expired', '127.0.0.1', 'Mozilla/5.0', now() - INTERVAL '1 hour'); + ", &[&id]).await.expect("Failed to setup expired session"); +} -/// 4. Setup pour tester un utilisateur archivé +/// 4. Setup pour Bob (Utilisateur qui finit archivé) pub async fn setup_archived_user_test(client: &Client) { - client.batch_execute(" - -- 1. Créer Bob (Actif) + let row = client.query_one(" INSERT INTO users (first_name, last_name, email, password, phone_number, status, is_archived) - VALUES ('Bob', 'Smith', 'bob@example.com', 'password123', '0102030405', 'active', FALSE); + VALUES ('Bob', 'Smith', 'bob@example.com', 'password123', '0102030405', 'active', FALSE) + ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email + RETURNING id; + ", &[]).await.expect("Failed to insert Bob"); - -- 2. Créer sa session (Active) - INSERT INTO sessions (user_id, user_is_archived, token_hash, ip_address, device_info) - VALUES (2, FALSE, 'test_token_hash_archived_user', '127.0.0.1', 'Mozilla/5.0'); + let id: i32 = row.get(0); + BOB_ID.set(id).ok(); - -- 3. ARCHIVAGE : Si ton CHECK interdit 'TRUE' dans sessions, - -- tu dois soit supprimer la session, soit modifier la contrainte. - -- Ici, on simule l'archivage de l'user. - UPDATE users SET is_archived = TRUE, status = 'archived' WHERE id = 2; + // On utilise ON CONFLICT ici aussi pour la session + client + .execute( + " + INSERT INTO sessions (user_id, user_is_archived, token_hash, ip_address, device_info) + VALUES ($1, FALSE, 'test_token_hash_archived_user', '127.0.0.1', 'Mozilla/5.0') + ON CONFLICT DO NOTHING; + ", + &[&id], + ) + .await + .expect("Failed to setup Bob session"); - -- NOTE: Si 'user_is_archived' dans la table sessions a un CHECK à FALSE, - -- cette ligne suivante échouera TOUJOURS : - -- UPDATE sessions SET user_is_archived = TRUE WHERE user_id = 2; - ").await.expect("Failed to setup archived user test"); + client + .execute( + "UPDATE users SET is_archived = TRUE, status = 'archived' WHERE id = $1", + &[&id], + ) + .await + .expect("Failed to archive Bob"); } -pub static ADMIN_ID: u64 = 3; - -/// 5. Setup des données d'accès contrôlé +/// 5. Setup des données d'accès (Utilise un nouvel utilisateur pour les groupes) pub async fn setup_access_control_data(client: &Client) { - client - .batch_execute( + let alice_id = *ALICE_ID.get().expect("Alice ID missing"); + let bob_id = *BOB_ID.get().expect("Bob ID missing"); + + // Admin avec ON CONFLICT + let admin_row = client + .query_one( " - -- 0. CRÉATION DES UTILISATEURS MANQUANTS (IMPORTANT) INSERT INTO users (first_name, last_name, email, password, status) - VALUES ('Admin', 'User', 'admin@test.com', 'hash', 'active'); - - -- 1. On s'assure que les ressources existent - INSERT INTO resources (name) VALUES ('user'), ('groups'), ('document'); + VALUES ('Admin', 'User', 'admin@test.com', 'hash', 'active') + ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email + RETURNING id; + ", + &[], + ) + .await + .expect("Failed to insert Admin"); + let admin_id: i32 = admin_row.get(0); + ADMIN_ID.set(admin_id).ok(); - -- 2. On s'assure que les permissions existent - INSERT INTO permissions (resource_id, action) VALUES - (1, 'read_all'), - (2, 'read_all'), - (3, 'read'); + // Group Owner avec ON CONFLICT + let owner_row = client + .query_one( + " + INSERT INTO users (first_name, last_name, email, password, status) + VALUES ('Group', 'Owner', 'owner@test.com', 'hash', 'active') + ON CONFLICT (email) DO UPDATE SET email = EXCLUDED.email + RETURNING id; + ", + &[], + ) + .await + .expect("Failed to insert Group Owner"); + let owner_id: i32 = owner_row.get(0); + GROUP_OWNER_ID.set(owner_id).ok(); - -- 3. RBAC : Alice (1) est ADMIN - INSERT INTO roles (name) VALUES ('Admin'); - INSERT INTO rights (role_id, permission_id) VALUES (1, 1), (1, 2); - INSERT INTO user_roles (user_id, role_id) VALUES (1, 1); + // Pour les rôles et ACL, on peut utiliser des subqueries ou des batchs simples + // Ici on reste sur ton format batch_execute mais on s'assure de ne pas recréer la table document à chaque fois + client + .batch_execute(&format!( + " + INSERT INTO user_roles (user_id, role_id) VALUES ({alice_id}, 1) ON CONFLICT DO NOTHING; - -- 4. Ownership : Alice (1) possède le document 10 - -- On vérifie si la table existe (cas où Liquibase n'aurait pas encore fini) CREATE TABLE IF NOT EXISTS document (id SERIAL PRIMARY KEY, owner_id INT); - INSERT INTO document (owner_id) VALUES (1); + INSERT INTO document (owner_id) VALUES ({alice_id}); - -- 5. ACL : Maintenant l'ID 3 existe, on peut créer le groupe INSERT INTO groups (owner_id, name, owner_is_archived) - VALUES (3, 'Seeded Group', false); + VALUES ({owner_id}, 'Seeded Group', false) ON CONFLICT DO NOTHING; - -- Bob (2) accède au groupe 50 INSERT INTO access_control (user_id, resource_id, permission_id, resource_instance_id) - VALUES (2, 2, 3, 50); - ", - ) + VALUES ({bob_id}, 2, 3, 50) ON CONFLICT DO NOTHING; + " + )) .await .expect("Failed to setup access control data"); } -// On stocke le conteneur et l'URL pour qu'ils ne soient pas détruits static SHARED_DB: OnceCell<(ContainerAsync, String)> = OnceCell::const_new(); -/// 6. Fonction globale si tu veux tout lancer d'un coup (ton ancienne approche) -async fn setup_tests_full() -> (ContainerAsync, String) { - let (node, client, url) = setup_test_container().await; - setup_active_session(&client).await; - setup_expired_session(&client).await; - setup_archived_user_test(&client).await; - setup_access_control_data(&client).await; - (node, url) -} +// async fn setup_tests_full() -> (ContainerAsync, String) { +// let (node, client, url) = setup_test_container().await; + +// // L'ordre est important pour que les OnceCell soient remplies +// setup_active_session(&client).await; +// setup_expired_session(&client).await; +// setup_archived_user_test(&client).await; +// setup_access_control_data(&client).await; + +// (node, url) +// } pub async fn get_shared_db() -> &'static (ContainerAsync, String) { SHARED_DB .get_or_init(|| async { - println!("🚀 Initialisation unique de la DB de test..."); - - setup_tests_full().await + println!("🚀 Lancement du setup global UNIQUE..."); + + // 1. Démarre le conteneur et le client + let (node, client, url) = setup_test_container().await; + + // 2. Nettoie les données existantes (sans supprimer les tables) + client + .batch_execute( + " + TRUNCATE TABLE + access_control, + user_roles, + groups, + sessions, + users + RESTART IDENTITY CASCADE; + ", + ) + .await + .expect("Erreur lors du nettoyage des données"); + + // 3. ICI on lance les insertions de données. + // Comme on est dans le OnceCell, ce bloc ne s'exécutera qu'UNE FOIS + // pour toute la durée de tes tests. + setup_active_session(&client).await; + setup_expired_session(&client).await; + setup_archived_user_test(&client).await; + setup_access_control_data(&client).await; + + println!("✅ Données de test injectées avec succès."); + (node, url) }) .await } diff --git a/tests/middlewares_tests.rs b/tests/middlewares_tests.rs new file mode 100644 index 0000000..72af5a2 --- /dev/null +++ b/tests/middlewares_tests.rs @@ -0,0 +1,337 @@ +use mairie360_api_lib::{pool::AppState, test_setup::queries_setup::get_shared_db}; + +use std::env; + +static INIT: once_cell::sync::Lazy<()> = once_cell::sync::Lazy::new(|| { + // This code runs ONCE before any test + env::set_var("JWT_SECRET", "b\"secret\""); + env::set_var("JWT_TIMEOUT", "3600"); + println!("Global setup done"); +}); + +fn setup() { + // Force INIT to run + once_cell::sync::Lazy::force(&INIT); +} + +#[cfg(test)] +mod auth_middleware { + + use super::*; + use actix_web::{http::StatusCode, test, web, App, HttpResponse}; + use mairie360_api_lib::{jwt_manager::generate_jwt, security::JwtMiddleware}; + + // Route de test simple protégée par le middleware + async fn index() -> HttpResponse { + HttpResponse::Ok().body("Welcome!") + } + + #[tokio::test] + async fn test_middleware_bypass_public_routes() { + let (_container, url) = get_shared_db().await; + let app_state = web::Data::new(AppState::new("".to_string(), url.to_string()).await); + + let app = test::init_service( + App::new() + .app_data(app_state.clone()) + .wrap(JwtMiddleware) + .route("/auth/login", web::get().to(index)), + ) + .await; + + // Test sur une route ignorée par le middleware (/auth/...) + let req = test::TestRequest::get().uri("/auth/login").to_request(); + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_middleware_no_token_returns_401() { + let (_container, url) = get_shared_db().await; + let app_state = web::Data::new(AppState::new("".to_string(), url.to_string()).await); + + let app = test::init_service( + App::new() + .app_data(app_state.clone()) + .wrap(JwtMiddleware) + .route("/protected", web::get().to(index)), + ) + .await; + + // Requête sans header Authorization + let req = test::TestRequest::get().uri("/protected").to_request(); + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_middleware_valid_token_success() { + setup(); + let (_container, url) = get_shared_db().await; + let app_state = web::Data::new(AppState::new("".to_string(), url.to_string()).await); + + // On récupère l'ID d'Alice depuis ton setup global + let alice_id = *mairie360_api_lib::test_setup::queries_setup::ALICE_ID + .get() + .unwrap(); + + let token = generate_jwt(&alice_id.to_string()).unwrap(); + + let app = test::init_service( + App::new() + .app_data(app_state.clone()) + .wrap(JwtMiddleware) + .route("/protected", web::get().to(index)), + ) + .await; + + let req = test::TestRequest::get() + .uri("/protected") + .insert_header(("Authorization", format!("Bearer {}", token))) + .to_request(); + + let resp = &test::call_service(&app, req).await; + + let response = resp.response(); + + assert_eq!( + response.status(), + StatusCode::OK, + "response: {:?}", + response.body() + ); + } + + #[tokio::test] + async fn test_middleware_expired_token_returns_401() { + let (_container, url) = get_shared_db().await; + let app_state = web::Data::new(AppState::new("".to_string(), url.to_string()).await); + + setup(); + env::set_var("JWT_TIMEOUT", "0"); + let token = generate_jwt("2").unwrap(); + std::thread::sleep(std::time::Duration::from_secs(2)); + + let app = test::init_service( + App::new() + .app_data(app_state.clone()) + .wrap(JwtMiddleware) + .route("/protected", web::get().to(index)), + ) + .await; + + let req = test::TestRequest::get() + .uri("/protected") + .insert_header(("Authorization", format!("Bearer {}", token))) + .to_request(); + + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + // Optionnel: vérifier le corps du message + let body = test::read_body(resp).await; + assert!(body.starts_with(b"Unauthorized: JWT token is expired.")); + } + + #[tokio::test] + async fn test_middleware_missing_db_pool_returns_500() { + // App sans .app_data(app_state) pour tester la branche d'erreur "DB Pool missing" + let app = test::init_service( + App::new() + .wrap(JwtMiddleware) + .route("/protected", web::get().to(index)), + ) + .await; + + let req = test::TestRequest::get().uri("/protected").to_request(); + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +} + +#[cfg(test)] +mod access_middleware { + use super::*; + use actix_web::middleware::from_fn; + use actix_web::{http::StatusCode, test, web, App, HttpMessage, HttpResponse}; + use mairie360_api_lib::security::{ + access_guard_middleware, AccessCheckConfig, AuthenticatedUser, + }; + + // Handler de test pour valider que le middleware a laissé passer la requête + async fn fake_handler() -> HttpResponse { + HttpResponse::Ok().body("Granted") + } + + #[tokio::test] + async fn test_access_granted_owner() { + setup(); + let (_container, url) = get_shared_db().await; + let app_state = web::Data::new(AppState::new("".to_string(), url.to_string()).await); + + // On récupère Alice (ID 1 dans ton setup) + let alice_id = *mairie360_api_lib::test_setup::queries_setup::ALICE_ID + .get() + .unwrap(); + + let app = test::init_service( + App::new().app_data(app_state.clone()).service( + web::resource("/users/{user_id}/data") + .app_data(AccessCheckConfig { + resource_name: "users", // Nom de la table/ressource + action: "read", + id_param_pattern: Some("user_id"), + }) + .wrap(from_fn(access_guard_middleware)) + .route(web::get().to(fake_handler)), + ), + ) + .await; + + // On simule une requête sur sa propre ressource (Alice accède à Alice) + let req = test::TestRequest::get() + .uri(&format!("/users/{}/data", alice_id)) + .to_request(); + + // On injecte l'utilisateur authentifié (simule le JwtMiddleware) + req.extensions_mut().insert(AuthenticatedUser { + id: alice_id as u64, + }); + + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::OK); + let body = test::read_body(resp).await; + assert_eq!(body, "Granted"); + } + + #[tokio::test] + async fn test_access_forbidden_for_other_user() { + setup(); + let (_container, url) = get_shared_db().await; + let app_state = web::Data::new(AppState::new("".to_string(), url.to_string()).await); + + let alice_id = *mairie360_api_lib::test_setup::queries_setup::ALICE_ID + .get() + .unwrap(); + let bob_id = alice_id + 1; // Supposons que Bob est l'ID suivant + + let app = test::init_service( + App::new().app_data(app_state.clone()).service( + web::resource("/users/{user_id}/secret") + .app_data(AccessCheckConfig { + resource_name: "users", + action: "write", + id_param_pattern: Some("user_id"), + }) + .wrap(from_fn(access_guard_middleware)) + .route(web::get().to(fake_handler)), + ), + ) + .await; + + // Alice (ID 1) essaie d'accéder aux données de Bob (ID 2) + let req = test::TestRequest::get() + .uri(&format!("/users/{}/secret", bob_id)) + .to_request(); + + req.extensions_mut().insert(AuthenticatedUser { + id: alice_id as u64, + }); + + let resp = test::try_call_service(&app, req).await; + + match resp { + Ok(res) => assert_eq!(res.status(), StatusCode::FORBIDDEN), + Err(err) => { + // Si le middleware renvoie une Error (ce qui est ton cas), + // Actix la capture ici. On vérifie que c'est bien une 403. + assert_eq!(err.as_response_error().status_code(), StatusCode::FORBIDDEN); + } + } + } + + #[tokio::test] + async fn test_access_bad_request_invalid_id_format() { + setup(); + let (_container, url) = get_shared_db().await; + let app_state = web::Data::new(AppState::new("".to_string(), url.to_string()).await); + + let app = test::init_service( + App::new().app_data(app_state.clone()).service( + web::resource("/items/{id}") + .app_data(AccessCheckConfig { + resource_name: "items", + action: "read", + id_param_pattern: Some("id"), + }) + .wrap(from_fn(access_guard_middleware)) + .route(web::get().to(fake_handler)), + ), + ) + .await; + + // On envoie une string au lieu d'un i32 dans l'URL + let req = test::TestRequest::get() + .uri("/items/not-an-integer") + .to_request(); + req.extensions_mut().insert(AuthenticatedUser { id: 1 }); + + let resp = test::try_call_service(&app, req).await; + + // Le middleware doit intercepter le parse().ok() et renvoyer 400 + match resp { + Ok(res) => assert_eq!(res.status(), StatusCode::BAD_REQUEST), + Err(err) => { + // Si le middleware renvoie une Error (ce qui est ton cas), + // Actix la capture ici. On vérifie que c'est bien une 400. + assert_eq!( + err.as_response_error().status_code(), + StatusCode::BAD_REQUEST + ); + } + } + } + + #[tokio::test] + async fn test_access_global_permission_success() { + setup(); + let (_container, url) = get_shared_db().await; + let app_state = web::Data::new(AppState::new("".to_string(), url.to_string()).await); + + // Ici, on teste le cas où id_param_pattern est None (vérification globale) + // Utile pour les routes de listing ou Admin + let app = test::init_service( + App::new().app_data(app_state.clone()).service( + web::scope("/admin") + .app_data(AccessCheckConfig { + resource_name: "users", + action: "read", // La fonction SQL cherchera 'read_all' + id_param_pattern: None, + }) + .wrap(from_fn(access_guard_middleware)) + .route("/all-users", web::get().to(fake_handler)), + ), + ) + .await; + + let req = test::TestRequest::get() + .uri("/admin/all-users") + .to_request(); + + // On simule un utilisateur (ID 1) + // Note: Pour que ce test réussisse, Alice doit avoir le droit 'read_all' + // sur 'users' dans ta base de données de test. + req.extensions_mut().insert(AuthenticatedUser { id: 1 }); + + let resp = test::call_service(&app, req).await; + + // Si Alice est admin dans ton setup SQL, ça sera OK, sinon FORBIDDEN + // Ce test valide que le passage de paramètre NULL à Postgres fonctionne. + assert!(resp.status() == StatusCode::OK || resp.status() == StatusCode::FORBIDDEN); + } +} diff --git a/tests/queries_tests.rs b/tests/queries_tests.rs index 8f36d29..99018e2 100644 --- a/tests/queries_tests.rs +++ b/tests/queries_tests.rs @@ -138,7 +138,9 @@ mod queries_tests { let pool = get_pool(host.as_str().to_string()).await; let view = IsSessionTokenValidQueryView::new( - mairie360_api_lib::test_setup::queries_setup::ALICE_ID, + *mairie360_api_lib::test_setup::queries_setup::ALICE_ID + .get() + .unwrap() as u64, "test_token_hash_unique_123".to_string(), IpAddr::from([127, 0, 0, 1]), ); @@ -155,7 +157,9 @@ mod queries_tests { let pool = get_pool(host.as_str().to_string()).await; let view = IsSessionTokenValidQueryView::new( - mairie360_api_lib::test_setup::queries_setup::ALICE_ID, + *mairie360_api_lib::test_setup::queries_setup::BOB_ID + .get() + .unwrap() as u64, "test_token_hash_expired".to_string(), IpAddr::from([127, 0, 0, 1]), ); @@ -172,7 +176,9 @@ mod queries_tests { let pool = get_pool(host.as_str().to_string()).await; let view = IsSessionTokenValidQueryView::new( - mairie360_api_lib::test_setup::queries_setup::ALICE_ID, + *mairie360_api_lib::test_setup::queries_setup::ALICE_ID + .get() + .unwrap() as u64, "test_token_hash_unique_123".to_string(), IpAddr::from([127, 0, 0, 2]), ); @@ -189,7 +195,9 @@ mod queries_tests { let pool = get_pool(host.as_str().to_string()).await; let view = IsSessionTokenValidQueryView::new( - mairie360_api_lib::test_setup::queries_setup::ADMIN_ID, + *mairie360_api_lib::test_setup::queries_setup::ADMIN_ID + .get() + .unwrap() as u64, "test_token_hash_unique_123".to_string(), IpAddr::from([127, 0, 0, 1]), ); @@ -212,7 +220,9 @@ mod queries_tests { // Alice (ID 1) est admin, elle a 'read_all' sur 'document' let view = HasAccessQueryView::new( - mairie360_api_lib::test_setup::queries_setup::ALICE_ID, + *mairie360_api_lib::test_setup::queries_setup::ALICE_ID + .get() + .unwrap() as u64, "document", "read", 1, @@ -230,7 +240,9 @@ mod queries_tests { let pool = get_pool(host.as_str().to_string()).await; let view = HasAccessQueryView::new( - mairie360_api_lib::test_setup::queries_setup::ALICE_ID, + *mairie360_api_lib::test_setup::queries_setup::ALICE_ID + .get() + .unwrap() as u64, "document", "read", 1, @@ -247,18 +259,17 @@ mod queries_tests { let (_container, host) = get_shared_db().await; let pool = get_pool(host.as_str().to_string()).await; - let view = HasAccessQueryView::new( - mairie360_api_lib::test_setup::queries_setup::BOB_ID, - "groups", - "read", - 50, - ); + let alice_id = *mairie360_api_lib::test_setup::queries_setup::ALICE_ID + .get() + .unwrap(); + + let view = HasAccessQueryView::new(alice_id as u64, "groups", "read", 50); let result = has_access_query(view, pool).await.unwrap(); assert!( result, - "Guest (2) should have individual ACL access to group 50" + "Guest (3) should have individual ACL access to group 50" ); } @@ -269,7 +280,9 @@ mod queries_tests { let pool = get_pool(host.as_str().to_string()).await; let view = HasAccessQueryView::new( - mairie360_api_lib::test_setup::queries_setup::ALICE_ID, + *mairie360_api_lib::test_setup::queries_setup::BOB_ID + .get() + .unwrap() as u64, "document", "read", 10, @@ -287,7 +300,9 @@ mod queries_tests { let pool = get_pool(host.as_str().to_string()).await; let view = HasAccessQueryView::new( - mairie360_api_lib::test_setup::queries_setup::ALICE_ID, + *mairie360_api_lib::test_setup::queries_setup::ALICE_ID + .get() + .unwrap() as u64, "ghost_resource", "read", 0,