diff --git a/imphnen-backend/src/bin/seed_roles.rs b/imphnen-backend/src/bin/seed_roles.rs index d605cef..63e5a73 100644 --- a/imphnen-backend/src/bin/seed_roles.rs +++ b/imphnen-backend/src/bin/seed_roles.rs @@ -26,7 +26,7 @@ async fn main() -> Result<(), Box> { ( "5713cb37-dc02-4e87-8048-d7a41d352059", "User", - None, + Some("2025-02-28T14:53:58.576688+00"), Some("2025-02-28T14:53:58.576688+00"), ), ( @@ -44,13 +44,13 @@ async fn main() -> Result<(), Box> { ( "f6b03f25-e416-4893-ac88-caaa690afb07", "Admin", - None, + Some("2025-02-22T15:38:39.868306+00"), Some("2025-02-22T15:38:39.868306+00"), ), ( "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a", "Mentor", - None, + Some("2025-07-06T10:00:00.000000+00"), Some("2025-07-06T10:00:00.000000+00"), ), ]; diff --git a/imphnen-backend/src/bin/seed_roles_permissions.rs b/imphnen-backend/src/bin/seed_roles_permissions.rs index 2e6597f..a8b0a47 100644 --- a/imphnen-backend/src/bin/seed_roles_permissions.rs +++ b/imphnen-backend/src/bin/seed_roles_permissions.rs @@ -46,6 +46,8 @@ async fn main() -> Result<(), Box> { PermissionsEnum::ReadDetailGachaRolls, PermissionsEnum::CreateGachaRolls, PermissionsEnum::ExecuteGachaRolls, + PermissionsEnum::ReadListRoles, + PermissionsEnum::ReadListPermissions, ], ), ( @@ -65,6 +67,8 @@ async fn main() -> Result<(), Box> { PermissionsEnum::ReadDetailMentors, PermissionsEnum::ReadOwnMentorProfile, PermissionsEnum::ReadOwnMentorStatus, + PermissionsEnum::ReadListRoles, + PermissionsEnum::ReadListPermissions, ], ), ( @@ -85,6 +89,7 @@ async fn main() -> Result<(), Box> { PermissionsEnum::ReadDetailGachaRolls, PermissionsEnum::CreateGachaRolls, PermissionsEnum::ExecuteGachaRolls, + PermissionsEnum::ManageAllTeams, ], ), ( diff --git a/imphnen-cms/src/v1/landing/events/events_dto.rs b/imphnen-cms/src/v1/landing/events/events_dto.rs index c2f9e3d..260f11b 100644 --- a/imphnen-cms/src/v1/landing/events/events_dto.rs +++ b/imphnen-cms/src/v1/landing/events/events_dto.rs @@ -1,34 +1,70 @@ -use lazy_static::lazy_static; -lazy_static! { - pub static ref VALID_URL_REGEX: regex::Regex = - regex::Regex::new(r"^https?://").unwrap(); -} use chrono::{DateTime, Utc}; +use lazy_static::lazy_static; +use regex::Regex; use serde::{Deserialize, Serialize}; use surrealdb::sql::Thing; use utoipa::ToSchema; -use validator::Validate; +use validator::{Validate, ValidationError}; + +// Custom URL validator that ensures valid HTTP/HTTPS URLs +pub fn validate_url(url: &str) -> Result<(), ValidationError> { + lazy_static! { + static ref VALID_URL_REGEX: Regex = Regex::new(r"^https?://[^\s$.?#].[^\s]*$").unwrap(); + } + if VALID_URL_REGEX.is_match(url) { + Ok(()) + } else { + Err(ValidationError::new("invalid_url")) + } +} + +// Custom validator for future dates +pub fn validate_future_date(end_date: &DateTime) -> Result<(), ValidationError> { + let now = Utc::now(); + if end_date > &now { + Ok(()) + } else { + Err(ValidationError::new("future_date")) + } +} + +// Custom validator for event date ranges (for combined validation) +pub fn validate_date_range(start_date: &DateTime, end_date: &DateTime) -> Result<(), ValidationError> { + if start_date <= end_date { + Ok(()) + } else { + Err(ValidationError::new("date_range")) + } +} #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct EventsCreateRequestDto { - #[validate(length(min = 1, message = "Name is required"))] + #[validate(length(min = 1, max = 100, message = "Name must be between 1 and 100 characters"))] pub name: String, - - #[validate(length(min = 1, message = "Description is required"))] + + #[validate(length(min = 1, max = 1000, message = "Description must be between 1 and 1000 characters"))] pub description: String, - - #[validate(url(message = "Detail link must be a valid URL"))] + + #[validate(custom( + function = "validate_url", + message = "Detail link must be a valid HTTP/HTTPS URL" + ))] pub detail_link: String, - - #[validate(range(min = 0.0, message = "Price cannot be negative"))] + + #[validate(range(min = 0.0, max = 1_000_000.0, message = "Price must be between 0 and 1,000,000"))] pub price: f64, #[schema(example = "2025-09-20T13:00:00Z", value_type = String)] + #[validate(custom( + function = "validate_future_date", + message = "End date must be in the future" + ))] pub end_date: DateTime, #[schema(example = "2025-09-20T13:00:00Z", value_type = String)] pub start_date: DateTime, + #[validate(length(max = 200, message = "Location name cannot exceed 200 characters"))] pub location: Option, pub is_online: bool, } diff --git a/imphnen-cms/src/v1/landing/testimonials/testimonials_dto.rs b/imphnen-cms/src/v1/landing/testimonials/testimonials_dto.rs index 7acdf88..b4d7f5d 100644 --- a/imphnen-cms/src/v1/landing/testimonials/testimonials_dto.rs +++ b/imphnen-cms/src/v1/landing/testimonials/testimonials_dto.rs @@ -1,31 +1,53 @@ use imphnen_iam::v1::users::UsersSchema; +use lazy_static::lazy_static; +use regex::Regex; use serde::{Deserialize, Serialize}; use surrealdb::sql::Thing; use utoipa::ToSchema; -use validator::Validate; +use validator::{Validate, ValidationError}; + +// Custom validator for content length and format +pub fn validate_testimonial_content(content: &str) -> Result<(), ValidationError> { + lazy_static! { + static ref CONTENT_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9\s.,!?'-]+$").unwrap(); + } + if CONTENT_REGEX.is_match(content) && content.len() <= 1000 { + Ok(()) + } else { + Err(ValidationError::new("invalid_content")) + } +} #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct TestimonialsCreateRequestDto { - #[validate(length(min = 1, message = "Role is required"))] + #[validate(length(min = 1, max = 100, message = "Role must be between 1 and 100 characters"))] pub role: String, - + #[validate(length( min = 1, - max = 500, - message = "Content must be between 1 and 500 characters" + max = 1000, + message = "Content must be between 1 and 1000 characters" + ))] + #[validate(custom( + function = "validate_testimonial_content", + message = "Content contains invalid characters or is too long" ))] pub content: String, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct TestimonialsUpdateRequestDto { - #[validate(length(min = 1, message = "Role is required"))] + #[validate(length(min = 1, max = 100, message = "Role must be between 1 and 100 characters"))] pub role: String, - + #[validate(length( min = 1, - max = 500, - message = "Content must be between 1 and 500 characters" + max = 1000, + message = "Content must be between 1 and 1000 characters" + ))] + #[validate(custom( + function = "validate_testimonial_content", + message = "Content contains invalid characters or is too long" ))] pub content: String, } diff --git a/imphnen-gacha/src/lib.rs b/imphnen-gacha/src/lib.rs index 79b791f..30c2b5b 100644 --- a/imphnen-gacha/src/lib.rs +++ b/imphnen-gacha/src/lib.rs @@ -40,9 +40,4 @@ pub use imphnen_utils::{ }; // Re-export public v1 API -pub use v1::{ - gacha_claim_router, - gacha_item_router, - gacha_roll_router, - gacha_router, -}; +pub use v1::gacha_router; diff --git a/imphnen-gacha/src/v1/gacha_claims/gacha_claims_dto.rs b/imphnen-gacha/src/v1/gacha_claims/gacha_claims_dto.rs index 19f5115..8cdccd7 100644 --- a/imphnen-gacha/src/v1/gacha_claims/gacha_claims_dto.rs +++ b/imphnen-gacha/src/v1/gacha_claims/gacha_claims_dto.rs @@ -1,15 +1,34 @@ use crate::v1::gacha_items::GachaItemDto; use crate::v1::gacha_items::gacha_items_schema::GachaItemSchema; use imphnen_iam::{UsersDetailItemDto, UsersDetailQueryDto}; +use lazy_static::lazy_static; +use regex::Regex; use serde::{Deserialize, Serialize}; use surrealdb::sql::Thing; use utoipa::ToSchema; -use validator::Validate; +use validator::{Validate, ValidationError}; + +// Custom validator for user ID format (UUID-like validation) +pub fn validate_user_id_format(user_id: &str) -> Result<(), ValidationError> { + lazy_static! { + static ref UUID_REGEX: Regex = Regex::new(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$").unwrap(); + } + if UUID_REGEX.is_match(user_id) { + Ok(()) + } else { + Err(ValidationError::new("invalid_format")) + } +} #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct GachaClaimRequestDto { #[validate(length(min = 1, message = "User ID must not be empty"))] + #[validate(custom( + function = "validate_user_id_format", + message = "User ID must be a valid UUID" + ))] pub user_id: String, + #[validate(length(min = 1, message = "Item ID must not be empty"))] pub item_id: String, } diff --git a/imphnen-gacha/src/v1/gacha_credits/gacha_credits_controller.rs b/imphnen-gacha/src/v1/gacha_credits/gacha_credits_controller.rs new file mode 100644 index 0000000..06dfaa1 --- /dev/null +++ b/imphnen-gacha/src/v1/gacha_credits/gacha_credits_controller.rs @@ -0,0 +1,35 @@ +use axum::{ + extract::Json, + http::HeaderMap, + response::Response, + Extension, +}; +use crate::AppState; +use crate::v1::gacha_credits::gacha_credits_dto::GachaCreditRequestDto; +use crate::v1::gacha_credits::gacha_credits_service::GachaCreditService; + +pub struct GachaCreditController; + +impl GachaCreditController { + pub async fn get_user_credits( + headers: HeaderMap, + Extension(state): Extension, + ) -> Response { + GachaCreditService::get_user_credits(&headers, &state).await + } + + pub async fn add_user_credits( + headers: HeaderMap, + Extension(state): Extension, + Json(payload): Json, + ) -> Response { + GachaCreditService::add_user_credits(&headers, &state, payload).await + } + + pub async fn consume_user_credit( + headers: HeaderMap, + Extension(state): Extension, + ) -> Response { + GachaCreditService::consume_user_credit(&headers, &state).await + } +} \ No newline at end of file diff --git a/imphnen-gacha/src/v1/gacha_credits/gacha_credits_dto.rs b/imphnen-gacha/src/v1/gacha_credits/gacha_credits_dto.rs index 424143f..ad247ed 100644 --- a/imphnen-gacha/src/v1/gacha_credits/gacha_credits_dto.rs +++ b/imphnen-gacha/src/v1/gacha_credits/gacha_credits_dto.rs @@ -1,7 +1,38 @@ use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use validator::Validate; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] pub struct GachaCreditRequestDto { + #[validate(length(min = 1, message = "User ID must not be empty"))] pub user_id: String, + + #[validate(range( + min = 1, + message = "Amount must be at least 1 credit" + ))] pub amount: i32, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GachaCreditResponseDto { + pub id: String, + pub user_id: String, + pub available_rolls: i32, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, +} + +impl From<&crate::v1::gacha_credits::gacha_credits_schema::GachaCreditSchema> for GachaCreditResponseDto { + fn from(credit: &crate::v1::gacha_credits::gacha_credits_schema::GachaCreditSchema) -> Self { + Self { + id: credit.id.id.to_raw(), + user_id: credit.user.id.to_raw(), + available_rolls: credit.available_rolls, + is_deleted: credit.is_deleted, + created_at: credit.created_at.clone(), + updated_at: credit.updated_at.clone(), + } + } +} diff --git a/imphnen-gacha/src/v1/gacha_credits/gacha_credits_repository.rs b/imphnen-gacha/src/v1/gacha_credits/gacha_credits_repository.rs index c1b1470..c1b832f 100644 --- a/imphnen-gacha/src/v1/gacha_credits/gacha_credits_repository.rs +++ b/imphnen-gacha/src/v1/gacha_credits/gacha_credits_repository.rs @@ -25,9 +25,9 @@ impl<'a> GachaCreditRepository<'a> { let now = Instant::now(); let db = &self.state.surrealdb_ws; let sql = format!( - "SELECT * FROM {} WHERE user = {}:⟨$user_id⟩ AND is_deleted = false LIMIT 1", + "SELECT * FROM {} WHERE user = type::thing('{}', $user_id) AND is_deleted = false LIMIT 1", ResourceEnum::GachaCredits, - ResourceEnum::Users + ResourceEnum::Users.as_str() ); info!(query = %sql, "Executing SurrealDB query"); let result: Vec = diff --git a/imphnen-gacha/src/v1/gacha_credits/gacha_credits_router.rs b/imphnen-gacha/src/v1/gacha_credits/gacha_credits_router.rs new file mode 100644 index 0000000..f3dcf8f --- /dev/null +++ b/imphnen-gacha/src/v1/gacha_credits/gacha_credits_router.rs @@ -0,0 +1,9 @@ +use axum::{Router, routing::get}; +use axum::routing::post; + +pub fn gacha_credit_router() -> Router { + Router::new() + .route("/", get(crate::v1::gacha_credits::GachaCreditController::get_user_credits)) + .route("/add", post(crate::v1::gacha_credits::GachaCreditController::add_user_credits)) + .route("/consume", post(crate::v1::gacha_credits::GachaCreditController::consume_user_credit)) +} \ No newline at end of file diff --git a/imphnen-gacha/src/v1/gacha_credits/gacha_credits_service.rs b/imphnen-gacha/src/v1/gacha_credits/gacha_credits_service.rs new file mode 100644 index 0000000..77c71da --- /dev/null +++ b/imphnen-gacha/src/v1/gacha_credits/gacha_credits_service.rs @@ -0,0 +1,97 @@ +use crate::AppState; +use imphnen_entities::ResponseSuccessDto; +use imphnen_utils::{errors::AppError, error_response}; +use imphnen_utils::{common_response, success_response, validate_request}; +use crate::v1::gacha_credits::gacha_credits_dto::{GachaCreditRequestDto, GachaCreditResponseDto}; +use crate::v1::gacha_credits::gacha_credits_repository::GachaCreditRepository; +use axum::http::StatusCode; +use axum::response::Response; +use imphnen_iam::UsersRepository; +use imphnen_utils::extract_email; + +pub struct GachaCreditService; + +impl GachaCreditService { + pub async fn get_user_credits(headers: &axum::http::HeaderMap, state: &AppState) -> Response { + let repo = GachaCreditRepository::new(state); + let repo_user = UsersRepository::new(state); + let Some(email) = extract_email(headers) else { + return error_response(AppError::AuthenticationError("Unauthorized".into())); + }; + + let Ok(user) = repo_user.query_user_by_email(email.to_string()).await else { + return error_response(AppError::NotFoundError("User not found".into())); + }; + + match repo.query_by_user_id(user.id.id.to_raw()).await { + Ok(Some(credit)) => { + let response_dto = GachaCreditResponseDto::from(&credit); + success_response(ResponseSuccessDto { data: response_dto }) + } + Ok(None) => { + // Return empty credits if no record exists + let response_dto = GachaCreditResponseDto { + id: "".to_string(), + user_id: user.id.id.to_raw(), + available_rolls: 0, + is_deleted: false, + created_at: None, + updated_at: None, + }; + success_response(ResponseSuccessDto { data: response_dto }) + } + Err(e) => error_response(AppError::InternalServerError(e.to_string())), + } + } + + pub async fn add_user_credits( + headers: &axum::http::HeaderMap, + state: &AppState, + payload: GachaCreditRequestDto, + ) -> Response { + if let Err((status, message)) = validate_request(&payload) { + return common_response(status, &message); + } + + let repo = GachaCreditRepository::new(state); + let repo_user = UsersRepository::new(state); + let Some(email) = extract_email(headers) else { + return error_response(AppError::AuthenticationError("Unauthorized".into())); + }; + + let Ok(user) = repo_user.query_user_by_email(email.to_string()).await else { + return error_response(AppError::NotFoundError("User not found".into())); + }; + + // Ensure the user can only modify their own credits + if payload.user_id != user.id.id.to_raw() { + return error_response(AppError::AuthorizationError("You can only modify your own credits".into())); + } + + let amount = payload.amount; // Extract amount before moving payload + match repo.query_add_credit(payload).await { + Ok(_) => common_response( + StatusCode::OK, + &format!("Added {} credits successfully", amount) + ), + Err(e) => error_response(AppError::InternalServerError(e.to_string())), + } + } + + pub async fn consume_user_credit(headers: &axum::http::HeaderMap, state: &AppState) -> Response { + let repo = GachaCreditRepository::new(state); + let repo_user = UsersRepository::new(state); + let Some(email) = extract_email(headers) else { + return error_response(AppError::AuthenticationError("Unauthorized".into())); + }; + + let Ok(user) = repo_user.query_user_by_email(email.to_string()).await else { + return error_response(AppError::NotFoundError("User not found".into())); + }; + + match repo.query_consume_credit(user.id.id.to_raw()).await { + Ok(_) => common_response(StatusCode::OK, "Consumed 1 credit successfully"), + Err(e) => error_response(AppError::BadRequestError(e.to_string())), + } + } +} \ No newline at end of file diff --git a/imphnen-gacha/src/v1/gacha_credits/mod.rs b/imphnen-gacha/src/v1/gacha_credits/mod.rs index 6b825d1..e425310 100644 --- a/imphnen-gacha/src/v1/gacha_credits/mod.rs +++ b/imphnen-gacha/src/v1/gacha_credits/mod.rs @@ -1,6 +1,13 @@ +pub mod gacha_credits_controller; pub mod gacha_credits_dto; pub mod gacha_credits_repository; pub mod gacha_credits_schema; +pub mod gacha_credits_service; +pub mod gacha_credits_router; // Export only public types and functions -pub use gacha_credits_dto::GachaCreditRequestDto; +pub use gacha_credits_controller::GachaCreditController; +pub use gacha_credits_dto::{GachaCreditRequestDto, GachaCreditResponseDto}; +pub use gacha_credits_repository::GachaCreditRepository; +pub use gacha_credits_service::GachaCreditService; +pub use gacha_credits_router::gacha_credit_router; diff --git a/imphnen-gacha/src/v1/gacha_items/gacha_items_dto.rs b/imphnen-gacha/src/v1/gacha_items/gacha_items_dto.rs index 9429750..59e84e1 100644 --- a/imphnen-gacha/src/v1/gacha_items/gacha_items_dto.rs +++ b/imphnen-gacha/src/v1/gacha_items/gacha_items_dto.rs @@ -1,22 +1,46 @@ use crate::v1::gacha_items::gacha_items_schema::GachaItemSchema; +use lazy_static::lazy_static; +use regex::Regex; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use validator::Validate; +use validator::{Validate, ValidationError}; + +// Custom validator for image URLs +pub fn validate_image_url(url: &str) -> Result<(), ValidationError> { + lazy_static! { + static ref IMAGE_URL_REGEX: Regex = Regex::new(r"^https?://[^\s]+\.(jpg|jpeg|png|gif|webp)$").unwrap(); + } + if IMAGE_URL_REGEX.is_match(url) { + Ok(()) + } else { + Err(ValidationError::new("invalid_image_url")) + } +} #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct GachaItemRequestDto { - #[validate(length(min = 1, message = "Item name must not be empty"))] + #[validate(length(min = 1, max = 100, message = "Item name must be between 1 and 100 characters"))] pub name: String, + #[validate(length(min = 1, message = "Image URL must not be empty"))] + #[validate(custom( + function = "validate_image_url", + message = "Image URL must be a valid URL pointing to JPG, JPEG, PNG, GIF, or WebP image" + ))] pub image_url: String, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct GachaItemUpdateRequestDto { - #[validate(length(min = 1, message = "Item name must not be empty"))] + #[validate(length(min = 1, max = 100, message = "Item name must be between 1 and 100 characters"))] #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, + #[validate(length(min = 1, message = "Image URL must not be empty"))] + #[validate(custom( + function = "validate_image_url", + message = "Image URL must be a valid URL pointing to JPG, JPEG, PNG, GIF, or WebP image" + ))] #[serde(skip_serializing_if = "Option::is_none")] pub image_url: Option, } diff --git a/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_dto.rs b/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_dto.rs index 6249741..2742a79 100644 --- a/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_dto.rs +++ b/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_dto.rs @@ -7,10 +7,13 @@ use validator::Validate; #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct GachaRollRequestDto { - #[validate(length(min = 1, message = "Item ID must not be empty"))] + #[validate(length(min = 1, max = 100, message = "Item ID must be between 1 and 100 characters"))] pub item_id: String, + + #[validate(range(min = 0.0, max = 1.0, message = "Weight must be between 0.0 and 1.0"))] pub weight: f32, - #[validate(range(min = 1, message = "Quantity must be at least 1"))] + + #[validate(range(min = 1, max = 100, message = "Quantity must be between 1 and 100"))] pub quantity: i32, } diff --git a/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_service.rs b/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_service.rs index 1214bc7..b47babc 100644 --- a/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_service.rs +++ b/imphnen-gacha/src/v1/gacha_rolls/gacha_rolls_service.rs @@ -6,6 +6,7 @@ use crate::v1::gacha_claims::gacha_claims_schema::GachaClaimSchema; use crate::v1::gacha_rolls::gacha_rolls_dto::{GachaRollItemDto, GachaRollRequestDto}; use crate::v1::gacha_rolls::gacha_rolls_repository::GachaRollRepository; use crate::v1::gacha_rolls::gacha_rolls_schema::GachaRollSchema; +use crate::v1::gacha_credits::gacha_credits_repository::GachaCreditRepository; use axum::http::{HeaderMap, StatusCode}; use axum::response::Response; use imphnen_iam::UsersRepository; @@ -40,33 +41,63 @@ impl GachaRollService { } pub async fn execute_roll_once(headers: HeaderMap, state: &AppState) -> Response { - let repo = GachaRollRepository::new(state); - let repo_claim = GachaClaimRepository::new(state); - let repo_user = UsersRepository::new(state); - let Some(email) = extract_email(&headers) else { - return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"); - }; - let Ok(user) = repo_user.query_user_by_email(email.to_string()).await else { - return common_response(StatusCode::NOT_FOUND, "User not found"); - }; - match repo.query_all_active_rolls().await { - Ok(rolls) => match GachaRollRepository::roll_once(&rolls) { - Some(roll) => { - let claim = GachaClaimSchema::roll(roll.clone(), user.id); - match repo_claim.query_create_gacha_claim(claim).await { - Ok(_) => success_response(ResponseSuccessDto { - data: GachaRollItemDto::from(&roll), - }), - Err(e) => { - common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) + let repo = GachaRollRepository::new(state); + let repo_claim = GachaClaimRepository::new(state); + let repo_user = UsersRepository::new(state); + let repo_credits = GachaCreditRepository::new(state); + let Some(email) = extract_email(&headers) else { + return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"); + }; + let Ok(user) = repo_user.query_user_by_email(email.to_string()).await else { + return common_response(StatusCode::NOT_FOUND, "User not found"); + }; + + // Check if user has enough credits + let credit_opt = repo_credits.query_by_user_id(user.id.id.to_raw()).await; + let has_enough_credits = match credit_opt { + Ok(Some(credit)) => credit.available_rolls > 0, + Ok(None) => false, // No credit record means no credits + Err(e) => { + return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) + } + }; + + if !has_enough_credits { + return common_response(StatusCode::PAYMENT_REQUIRED, "Not enough credits to perform this action"); + } + + // Consume one credit + match repo_credits.query_consume_credit(user.id.id.to_raw()).await { + Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + _ => {} + } + + // Proceed with the roll + match repo.query_all_active_rolls().await { + Ok(rolls) => match GachaRollRepository::roll_once(&rolls) { + Some(roll) => { + let user_id_clone = user.id.clone(); + let claim = GachaClaimSchema::roll(roll.clone(), user_id_clone); + match repo_claim.query_create_gacha_claim(claim).await { + Ok(_) => success_response(ResponseSuccessDto { + data: GachaRollItemDto::from(&roll), + }), + Err(e) => { + // Refund the credit if claim creation fails + let user_id = user.id.id.to_raw(); // Extract value before potential move + let _ = repo_credits.query_add_credit(crate::v1::gacha_credits::gacha_credits_dto::GachaCreditRequestDto { + user_id, + amount: 1, + }).await; + common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) + } } } - } - None => common_response(StatusCode::NOT_FOUND, "No rollable item available"), - }, - Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + None => common_response(StatusCode::NOT_FOUND, "No rollable item available"), + }, + Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } } - } pub async fn soft_delete_gacha_roll(state: &AppState, id: String) -> Response { let repo = GachaRollRepository::new(state); diff --git a/imphnen-gacha/src/v1/mod.rs b/imphnen-gacha/src/v1/mod.rs index e14277b..27b0997 100644 --- a/imphnen-gacha/src/v1/mod.rs +++ b/imphnen-gacha/src/v1/mod.rs @@ -6,15 +6,17 @@ pub mod gacha_items; pub mod gacha_rolls; // Export only public router functions to avoid namespace pollution -pub use gacha_claims::gacha_claim_router; -pub use gacha_credits::*; // gacha_credits doesn't have router functions +pub use gacha_credits::gacha_credit_router; pub use gacha_items::gacha_item_router; pub use gacha_rolls::gacha_roll_router; +pub use gacha_claims::gacha_claim_router; /// Creates the main gacha router with all version 1 endpoints pub fn gacha_router() -> Router { - Router::new() - .nest("/claims", gacha_claim_router()) - .nest("/items", gacha_item_router()) - .nest("/rolls", gacha_roll_router()) + let mut router = Router::new(); + router = router.nest("/credits", gacha_credit_router()); + router = router.nest("/items", gacha_item_router()); + router = router.nest("/rolls", gacha_roll_router()); + router = router.nest("/claims", gacha_claim_router()); + router } diff --git a/imphnen-gateway/src/lib.rs b/imphnen-gateway/src/lib.rs index be78af3..78db3b6 100644 --- a/imphnen-gateway/src/lib.rs +++ b/imphnen-gateway/src/lib.rs @@ -21,7 +21,7 @@ use imphnen_iam::{ v1::auth::auth_repository::AuthRepoImpl, }; use imphnen_libs::{AppState, SurrealMemClient, SurrealWsClient}; -use imphnen_middleware::{auth_middleware, cors_middleware}; +use imphnen_middleware::{auth_middleware, cors_middleware, auth_rate_limiting_middleware, security_headers_middleware}; use std::sync::Arc; use utoipa_swagger_ui::SwaggerUi; @@ -40,7 +40,7 @@ pub async fn gateway_service( }; let public_routes = Router::new() - .merge(iam_public_routes()) + .merge(iam_public_routes().layer(from_fn(auth_rate_limiting_middleware))) .merge(hackathon_public_routes()) .merge(testimonials_public_routes()) .merge(events_public_routes()); @@ -55,9 +55,10 @@ pub async fn gateway_service( .layer(from_fn(auth_middleware)); Router::new() - .route("/", get(Redirect::to("/docs"))) - .nest("/v1", public_routes.merge(protected_routes)) - .merge(SwaggerUi::new("/docs").url("/openapi.json", docs_router())) - .layer(cors_middleware()) - .layer(Extension(state)) + .route("/", get(Redirect::to("/docs"))) + .nest("/v1", public_routes.merge(protected_routes)) + .merge(SwaggerUi::new("/docs").url("/openapi.json", docs_router())) + .layer(cors_middleware()) + .layer(from_fn(security_headers_middleware)) + .layer(Extension(state)) } diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_dto.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_dto.rs index 6fef587..9c0e655 100644 --- a/imphnen-hackathon/src/v1/hackathon/hackathon_dto.rs +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_dto.rs @@ -1,7 +1,43 @@ use chrono::{DateTime, Utc}; +use lazy_static::lazy_static; +use regex::Regex; use serde::{Deserialize, Serialize}; use utoipa::{ToSchema, schema}; -use validator::Validate; +use validator::{Validate, ValidationError}; + +// Custom validators +pub fn validate_url_format(url: &str) -> Result<(), ValidationError> { + lazy_static! { + static ref URL_REGEX: Regex = Regex::new(r"^https?://[^\s$.?#].[^\s]*$").unwrap(); + } + if URL_REGEX.is_match(url) { + Ok(()) + } else { + Err(ValidationError::new("invalid_url")) + } +} + +pub fn validate_github_url(url: &str) -> Result<(), ValidationError> { + lazy_static! { + static ref GITHUB_REGEX: Regex = Regex::new(r"^https?://github\.com/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)?$").unwrap(); + } + if GITHUB_REGEX.is_match(url) { + Ok(()) + } else { + Err(ValidationError::new("invalid_github_url")) + } +} + +pub fn validate_demo_url(url: &str) -> Result<(), ValidationError> { + lazy_static! { + static ref DEMO_URL_REGEX: Regex = Regex::new(r"^https?://(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+(/[^\s]*)?$").unwrap(); + } + if DEMO_URL_REGEX.is_match(url) { + Ok(()) + } else { + Err(ValidationError::new("invalid_demo_url")) + } +} use crate::v1::hackathon::hackathon_schema::{ HackathonEventType, HackathonEventsSchema, HackathonPhase, HackathonSchema, @@ -15,20 +51,32 @@ use crate::v1::hackathon::hackathon_schema::{ pub struct HackathonCreateRequestDto { #[validate(length(min = 1, max = 100, message = "Hackathon name must be between 1 and 100 characters"))] pub name: String, + #[validate(length(min = 1, max = 1000, message = "Description must be between 1 and 1000 characters"))] pub description: String, + #[schema(value_type = String, format = DateTime)] pub start_date: DateTime, + #[schema(value_type = String, format = DateTime)] pub end_date: DateTime, + #[schema(value_type = String, format = DateTime)] pub registration_deadline: DateTime, + #[validate(range(min = 1, max = 10000, message = "Max participants must be between 1 and 10000"))] pub max_participants: Option, + + #[validate(length(max = 200, message = "Theme cannot exceed 200 characters"))] pub theme: Option, + + #[validate(length(max = 2000, message = "Rules cannot exceed 2000 characters"))] pub rules: Option, + pub prizes: Option>, pub previous_winners: Option>, + + #[validate(length(min = 1, message = "Organizers list cannot be empty"))] pub organizers: Vec, } diff --git a/imphnen-iam/src/v1/auth/auth_service.rs b/imphnen-iam/src/v1/auth/auth_service.rs index 3813656..bb6905b 100644 --- a/imphnen-iam/src/v1/auth/auth_service.rs +++ b/imphnen-iam/src/v1/auth/auth_service.rs @@ -16,6 +16,7 @@ use crate::{ verify_password, }; use axum::{http::StatusCode, response::Response}; +use imphnen_utils::{AppError, error_response}; use surrealdb::Uuid; use tracing::error; use tokio; @@ -80,53 +81,53 @@ impl AuthServiceTrait for AuthService { match user_repo.query_user_by_email(email.to_string()).await { Ok(user) => { - let is_password_correct = tokio::task::spawn_blocking({ - let password = password.to_owned(); - let user_password = user.password.clone(); - move || verify_password(&password, &user_password).unwrap_or(false) - }).await.unwrap_or(false); + let is_password_correct = match tokio::task::spawn_blocking({ + let password = password.to_owned(); + let user_password = user.password.clone(); + move || verify_password(&password, &user_password) + }).await { + Ok(result) => match result { + Ok(valid) => valid, + Err(e) => { + error!("Password verification failed: {}", e); + false + } + }, + Err(e) => { + error!("Task spawn blocking failed: {}", e); + false + } + }; if !is_password_correct { - return common_response( - StatusCode::BAD_REQUEST, - "Email or password not correct", - ); + return error_response(AppError::AuthenticationError("Email or password not correct".into())); } if !user.is_active { - return common_response( - StatusCode::BAD_REQUEST, - "Account not active, please verify your email", - ); + return error_response(AppError::AuthenticationError("Account not active, please verify your email".into())); } let user_id = user.id.id.to_raw(); let access_token = match encode_access_token(email.to_string(), user_id.clone()) { - Ok(token) => token, - Err(_e) => { - error!( - "Failed to generate access token for {}: {}", - email, _e - ); - return common_response( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to generate access token", - ); + Ok(token) => token, + Err(_e) => { + error!( + "Failed to generate access token for {}: {}", + email, _e + ); + return error_response(AppError::InternalServerError("Failed to generate access token".into())); } }; let refresh_token = match encode_refresh_token(email.to_string(), user_id) { - Ok(token) => token, - Err(_e) => { - error!( - "Failed to generate refresh token for {}: {}", - email, _e - ); - return common_response( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to generate refresh token", - ); + Ok(token) => token, + Err(_e) => { + error!( + "Failed to generate refresh token for {}: {}", + email, _e + ); + return error_response(AppError::InternalServerError("Failed to generate refresh token".into())); } }; @@ -142,19 +143,16 @@ impl AuthServiceTrait for AuthService { // Only clone user if caching is required if let Err(err_store) = auth_repo.query_store_user(user.clone()).await { - error!( - "Failed to store user cache for {}: {}", - user.email, err_store - ); - return common_response( - StatusCode::BAD_REQUEST, - "User already login or failed to cache", - ); + error!( + "Failed to store user cache for {}: {}", + user.email, err_store + ); + return error_response(AppError::BadRequestError("User already login or failed to cache".into())); } success_response(response) } Err(err_find) => { - common_response(StatusCode::UNAUTHORIZED, &err_find.to_string()) + error_response(AppError::AuthenticationError(err_find.to_string())) } } }) @@ -175,11 +173,23 @@ impl AuthServiceTrait for AuthService { match user_repo.query_user_by_email(payload.email.clone()).await { Ok(user) => { - let is_password_correct = tokio::task::spawn_blocking({ - let password = payload.password.clone(); - let user_password = user.password.clone(); - move || verify_password(&password, &user_password).unwrap_or(false) - }).await.unwrap_or(false); + let is_password_correct = match tokio::task::spawn_blocking({ + let password = payload.password.clone(); + let user_password = user.password.clone(); + move || verify_password(&password, &user_password) + }).await { + Ok(result) => match result { + Ok(valid) => valid, + Err(e) => { + error!("Password verification failed: {}", e); + false + } + }, + Err(e) => { + error!("Task spawn blocking failed: {}", e); + false + } + }; if !is_password_correct { return common_response( diff --git a/imphnen-iam/src/v1/permissions/permissions_guard.rs b/imphnen-iam/src/v1/permissions/permissions_guard.rs index d95fd1b..381f24d 100644 --- a/imphnen-iam/src/v1/permissions/permissions_guard.rs +++ b/imphnen-iam/src/v1/permissions/permissions_guard.rs @@ -72,6 +72,7 @@ pub async fn permissions_guard( }) .collect(); + // If user has Administrator permission, allow all. // Accept either the permission name or the canonical permission id. let admin_name = PermissionsEnum::Administrator.to_string(); diff --git a/imphnen-iam/src/v1/users/users_service.rs b/imphnen-iam/src/v1/users/users_service.rs index ebdc85b..4294efb 100644 --- a/imphnen-iam/src/v1/users/users_service.rs +++ b/imphnen-iam/src/v1/users/users_service.rs @@ -8,6 +8,7 @@ use crate::{ ResponseSuccessDto, common_response, success_list_response, success_response, validate_request, }; +use imphnen_utils::{errors::AppError, error_response}; use imphnen_utils::success_created_response; use axum::{http::StatusCode, response::Response, extract::Multipart}; use imphnen_libs::{ResourceEnum, hash_password, verify_password, MinioConfig, FileType, decode_base64_file, extract_content_type_from_data_url, create_minio_service_from_config}; @@ -72,7 +73,7 @@ pub trait UsersServiceTrait: Send + Sync + 'static { }; success_list_response(response) } - Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + Err(e) => error_response(AppError::BadRequestError(e.to_string())), } }) } @@ -82,7 +83,7 @@ pub trait UsersServiceTrait: Send + Sync + 'static { let id = id.to_owned(); Box::pin(async move { if Uuid::parse_str(&id).is_err() { - return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); + return error_response(AppError::BadRequestError("Invalid User ID format".into())); } let repo = UsersRepository::new(&state); let thing_id = make_thing_from_enum(ResourceEnum::Users, &id); @@ -91,7 +92,7 @@ pub trait UsersServiceTrait: Send + Sync + 'static { data: UserDto::from(&user), // Corrected to use UserDto::from by reference }), Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), - Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), + Err(e) => error_response(AppError::NotFoundError(e.to_string())), } }) } @@ -127,7 +128,7 @@ pub trait UsersServiceTrait: Send + Sync + 'static { .await .is_ok() { - return common_response(StatusCode::BAD_REQUEST, "User already exists"); + return error_response(AppError::ConflictError("User already exists".into())); } match repo.query_create_user(UsersSchema::create(new_user.clone())).await { Ok(_msg) => { @@ -140,7 +141,7 @@ pub trait UsersServiceTrait: Send + Sync + 'static { } } Err(err) => { - common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()) + error_response(AppError::InternalServerError(err.to_string())) } } }) @@ -155,7 +156,7 @@ pub trait UsersServiceTrait: Send + Sync + 'static { let id = id.to_owned(); Box::pin(async move { if Uuid::parse_str(&id).is_err() { - return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); + return error_response(AppError::BadRequestError("Invalid User ID format".into())); } let repo = UsersRepository::new(&state); if let Err((status, message)) = validate_request(&user) { @@ -166,13 +167,13 @@ pub trait UsersServiceTrait: Send + Sync + 'static { let thing_id = make_thing_from_enum(ResourceEnum::Users, &id); let current_user = match repo.query_user_by_id(&thing_id).await { Ok(user) => user, - Err(_) => return common_response(StatusCode::NOT_FOUND, "User not found"), + Err(_) => return error_response(AppError::NotFoundError("User not found".into())), }; let updated_user = UsersSchema::partial_update(current_user, user); match repo.query_update_user(updated_user).await { Ok(msg) => common_response(StatusCode::OK, &msg), - Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + Err(e) => error_response(AppError::BadRequestError(e.to_string())), } }) } @@ -214,7 +215,7 @@ pub trait UsersServiceTrait: Send + Sync + 'static { let id = id.to_owned(); Box::pin(async move { if Uuid::parse_str(&id).is_err() { - return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); + return error_response(AppError::BadRequestError("Invalid User ID format".into())); } let repo = UsersRepository::new(&state); let thing_id = make_thing_from_enum(ResourceEnum::Users, &id); @@ -247,28 +248,22 @@ pub trait UsersServiceTrait: Send + Sync + 'static { let repo = UsersRepository::new(&state); let user = match repo.query_user_by_email(email.clone()).await { Ok(user) if !user.is_deleted => user, - _ => return common_response(StatusCode::NOT_FOUND, "User not found"), + _ => return error_response(AppError::NotFoundError("User not found".into())), }; let verify_result = match verify_password(&payload.old_password, &user.password) { Ok(result) => result, Err(_) => { - return common_response( - StatusCode::BAD_REQUEST, - "Old password is incorrect", - ); + return error_response(AppError::BadRequestError("Old password is incorrect".into())); } }; if !verify_result { - return common_response(StatusCode::BAD_REQUEST, "Old password is incorrect"); + return error_response(AppError::BadRequestError("Old password is incorrect".into())); } let new_password = match hash_password(&payload.password) { Ok(pw) => pw, Err(_) => { - return common_response( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to hash password", - ); + return error_response(AppError::InternalServerError("Failed to hash password".into())); } }; let patch = UsersSchema { @@ -278,7 +273,7 @@ pub trait UsersServiceTrait: Send + Sync + 'static { }; match repo.query_update_user(patch).await { Ok(msg) => common_response(StatusCode::OK, &msg), - Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + Err(e) => error_response(AppError::BadRequestError(e.to_string())), } }) } @@ -307,16 +302,16 @@ pub trait UsersServiceTrait: Send + Sync + 'static { let id = id.to_owned(); Box::pin(async move { if Uuid::parse_str(&id).is_err() { - return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); + return error_response(AppError::BadRequestError("Invalid User ID format".into())); } let repo = UsersRepository::new(&state); let thing_id = make_thing_from_enum(ResourceEnum::Users, &id); if repo.query_user_by_id(&thing_id).await.is_err() { - return common_response(StatusCode::BAD_REQUEST, "User not found"); + return error_response(AppError::NotFoundError("User not found".into())); } match repo.query_delete_user(id).await { Ok(msg) => common_response(StatusCode::OK, &msg), - Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + Err(e) => error_response(AppError::BadRequestError(e.to_string())), } }) } @@ -340,9 +335,16 @@ pub trait UsersServiceTrait: Send + Sync + 'static { Box::pin(async move { let repo = UsersRepository::new(&state); let email_clone = new_user.email.clone(); + let hashed_password = match hash_password(&new_user.password) { + Ok(pw) => pw, + Err(_) => { + return Err(anyhow::anyhow!("Failed to hash password")); + } + }; + let user_schema = UsersSchema { email: new_user.email, - password: new_user.password, + password: hashed_password, fullname: new_user.fullname, phone_number: new_user.phone_number, is_active: new_user.is_active, diff --git a/imphnen-middleware/src/lib.rs b/imphnen-middleware/src/lib.rs index f5e58c5..df383e3 100644 --- a/imphnen-middleware/src/lib.rs +++ b/imphnen-middleware/src/lib.rs @@ -1,7 +1,11 @@ pub mod auth_middleware; pub mod cors_middleware; pub mod permissions_middleware; +pub mod rate_limiting_middleware; +pub mod security_headers_middleware; pub use auth_middleware::auth_middleware; pub use cors_middleware::cors_middleware; pub use permissions_middleware::PermissionsMiddlewareLayer; +pub use rate_limiting_middleware::auth_rate_limiting_middleware; +pub use security_headers_middleware::security_headers_middleware; diff --git a/imphnen-middleware/src/rate_limiting_middleware/mod.rs b/imphnen-middleware/src/rate_limiting_middleware/mod.rs new file mode 100644 index 0000000..833c35a --- /dev/null +++ b/imphnen-middleware/src/rate_limiting_middleware/mod.rs @@ -0,0 +1,61 @@ +use axum::{ + http::{Request, StatusCode}, + middleware::Next, + response::Response, + Extension, +}; +use imphnen_libs::AppState; +use std::{ + collections::HashMap, + sync::{Arc, RwLock}, + time::{Duration, Instant}, +}; + +// Simple rate limiting middleware for auth endpoints +pub async fn auth_rate_limiting_middleware( + Extension(_state): Extension, + mut req: Request, + next: Next, +) -> Result { + let uri = req.uri().path().to_string(); + + // Only apply rate limiting to auth endpoints + if uri == "/v1/auth/login" || uri == "/v1/auth/register" { + // Get client IP (simplified for this example) + let client_ip = "127.0.0.1"; // In production, use proper IP extraction + + // Create a simple in-memory rate limiter + let limiter = Arc::new(RwLock::new(HashMap::new())); + + let now = Instant::now(); + let window = Duration::from_secs(60); // 1 minute window + let max_requests = 10; // 10 requests per minute + + { + let mut limiter = limiter.write().unwrap(); + + // Clean up old entries + limiter.retain(|_, (timestamp, _)| { + now.duration_since(*timestamp) < window + }); + + // Check rate limit + let entry = limiter.entry(client_ip.to_string()).or_insert((now, 0)); + let (timestamp, count) = entry; + + if now.duration_since(*timestamp) > window { + *count = 1; + } else if *count >= max_requests { + return Ok(Response::builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .header("Retry-After", "60") + .body("Too Many Requests: Rate limit exceeded for authentication endpoint".into()) + .unwrap()); + } else { + *count += 1; + } + } + } + + Ok(next.run(req).await) +} \ No newline at end of file diff --git a/imphnen-middleware/src/security_headers_middleware/mod.rs b/imphnen-middleware/src/security_headers_middleware/mod.rs new file mode 100644 index 0000000..b206125 --- /dev/null +++ b/imphnen-middleware/src/security_headers_middleware/mod.rs @@ -0,0 +1,98 @@ +use axum::{ + http::{HeaderValue, Request, Response}, + middleware::Next, + Extension, +}; +use imphnen_libs::{AppState, ENV}; +use std::convert::Infallible; + +/// Security headers middleware that adds various security-related HTTP headers to all responses. +/// +/// This middleware implements security best practices by adding headers that help protect +/// against common web attacks like clickjacking, XSS, and information leakage. +pub async fn security_headers_middleware( + Extension(_state): Extension, + mut req: Request, + next: Next, +) -> Result, Infallible> { + let res = next.run(req).await; + + let mut res = add_security_headers(res); + + Ok(res) +} + +/// Adds security headers to a response based on the current environment. +/// +/// # Arguments +/// * `res` - The response to add headers to +/// +/// # Returns +/// The response with security headers added +fn add_security_headers(mut res: Response) -> Response { + let headers = res.headers_mut(); + + // Strict-Transport-Security (HSTS) + // Prevents downgrade attacks and cookie hijacking + // Only enable in production to avoid HSTS pinning issues during development + if ENV.rust_env == "production" { + headers.insert( + "Strict-Transport-Security", + HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"), + ); + } else { + headers.insert( + "Strict-Transport-Security", + HeaderValue::from_static("max-age=0"), + ); + } + + // Content-Security-Policy (CSP) + // Mitigates XSS and data injection attacks + let csp = if ENV.rust_env == "production" { + // Production CSP - strict policy for production + "default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://images.example.com; connect-src 'self' https://api.example.com; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; report-uri /csp-violation-report-endpoint" + } else { + // Development CSP - more permissive for development + "default-src 'self' http://localhost:3000; script-src 'self' 'unsafe-eval' 'unsafe-inline' http://localhost:3000; style-src 'self' 'unsafe-inline' http://localhost:3000; img-src 'self' data: http://localhost:3000; connect-src 'self' http://localhost:3000; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'" + }; + + headers.insert("Content-Security-Policy", HeaderValue::from_str(csp).unwrap()); + + // X-Frame-Options + // Prevents clickjacking attacks + headers.insert( + "X-Frame-Options", + HeaderValue::from_static("DENY"), + ); + + // X-Content-Type-Options + // Prevents MIME sniffing attacks + headers.insert( + "X-Content-Type-Options", + HeaderValue::from_static("nosniff"), + ); + + // Referrer-Policy + // Controls how much referrer information should be included with requests + headers.insert( + "Referrer-Policy", + HeaderValue::from_static("strict-origin-when-cross-origin"), + ); + + // Permissions-Policy (Feature Policy) + // Controls which features and APIs can be used + headers.insert( + "Permissions-Policy", + HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), + ); + + // X-XSS-Protection + // Provides basic XSS protection (note: this is a legacy header and CSP is preferred) + headers.insert( + "X-XSS-Protection", + HeaderValue::from_static("1; mode=block"), + ); + + res +} \ No newline at end of file diff --git a/imphnen-utils/src/errors.rs b/imphnen-utils/src/errors.rs new file mode 100644 index 0000000..d71d431 --- /dev/null +++ b/imphnen-utils/src/errors.rs @@ -0,0 +1,72 @@ +use axum::http::StatusCode; +use serde::Serialize; +use std::fmt; + +#[derive(Debug, Serialize)] +pub enum AppError { + ValidationError(String), + AuthenticationError(String), + AuthorizationError(String), + NotFoundError(String), + ConflictError(String), + InternalServerError(String), + BadRequestError(String), + ForbiddenError(String), + PaymentRequiredError(String), + MethodNotAllowedError(String), + NotAcceptableError(String), + RequestTimeoutError(String), + TooManyRequestsError(String), + GatewayTimeoutError(String), + ServiceUnavailableError(String), +} + +impl std::fmt::Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg), + AppError::AuthenticationError(msg) => write!(f, "Authentication failed: {}", msg), + AppError::AuthorizationError(msg) => write!(f, "Authorization failed: {}", msg), + AppError::NotFoundError(msg) => write!(f, "Resource not found: {}", msg), + AppError::ConflictError(msg) => write!(f, "Conflict error: {}", msg), + AppError::InternalServerError(msg) => write!(f, "Internal server error: {}", msg), + AppError::BadRequestError(msg) => write!(f, "Bad request: {}", msg), + AppError::ForbiddenError(msg) => write!(f, "Forbidden: {}", msg), + AppError::PaymentRequiredError(msg) => write!(f, "Payment required: {}", msg), + AppError::MethodNotAllowedError(msg) => write!(f, "Method not allowed: {}", msg), + AppError::NotAcceptableError(msg) => write!(f, "Not acceptable: {}", msg), + AppError::RequestTimeoutError(msg) => write!(f, "Request timeout: {}", msg), + AppError::TooManyRequestsError(msg) => write!(f, "Too many requests: {}", msg), + AppError::GatewayTimeoutError(msg) => write!(f, "Gateway timeout: {}", msg), + AppError::ServiceUnavailableError(msg) => write!(f, "Service unavailable: {}", msg), + } + } +} + +impl AppError { + pub fn status_code(&self) -> StatusCode { + match self { + AppError::ValidationError(_) => StatusCode::BAD_REQUEST, + AppError::AuthenticationError(_) => StatusCode::UNAUTHORIZED, + AppError::AuthorizationError(_) => StatusCode::FORBIDDEN, + AppError::NotFoundError(_) => StatusCode::NOT_FOUND, + AppError::ConflictError(_) => StatusCode::CONFLICT, + AppError::InternalServerError(_) => StatusCode::INTERNAL_SERVER_ERROR, + AppError::BadRequestError(_) => StatusCode::BAD_REQUEST, + AppError::ForbiddenError(_) => StatusCode::FORBIDDEN, + AppError::PaymentRequiredError(_) => StatusCode::PAYMENT_REQUIRED, + AppError::MethodNotAllowedError(_) => StatusCode::METHOD_NOT_ALLOWED, + AppError::NotAcceptableError(_) => StatusCode::NOT_ACCEPTABLE, + AppError::RequestTimeoutError(_) => StatusCode::REQUEST_TIMEOUT, + AppError::TooManyRequestsError(_) => StatusCode::TOO_MANY_REQUESTS, + AppError::GatewayTimeoutError(_) => StatusCode::GATEWAY_TIMEOUT, + AppError::ServiceUnavailableError(_) => StatusCode::SERVICE_UNAVAILABLE, + } + } + + pub fn message(&self) -> String { + self.to_string() + } +} + +pub type Result = std::result::Result; \ No newline at end of file diff --git a/imphnen-utils/src/lib.rs b/imphnen-utils/src/lib.rs index a32db0b..eea0019 100644 --- a/imphnen-utils/src/lib.rs +++ b/imphnen-utils/src/lib.rs @@ -15,6 +15,7 @@ pub mod get_id; pub mod logger; pub mod make_thing; pub mod query_builder; +pub mod errors; pub mod query_list; pub mod response_format; pub mod serde_helpers; @@ -38,7 +39,8 @@ pub use query_builder::{ ListQueryBuilder, }; pub use query_list::QueryListBuilder; -pub use response_format::{common_response, success_created_response, success_list_response, success_response}; +pub use errors::AppError; +pub use response_format::{common_response, success_created_response, success_list_response, success_response, error_response}; pub use serde_helpers::{ deserialize_datetime, option_thing_or_string, diff --git a/imphnen-utils/src/response_format.rs b/imphnen-utils/src/response_format.rs index 4a0067a..e38b5bd 100644 --- a/imphnen-utils/src/response_format.rs +++ b/imphnen-utils/src/response_format.rs @@ -12,7 +12,7 @@ use axum::{ use serde::Serialize; use serde_json::json; -use crate::{ResponseListSuccessDto, ResponseSuccessDto}; +use crate::{ResponseListSuccessDto, ResponseSuccessDto, AppError}; pub fn success_response(params: ResponseSuccessDto) -> Response { ( @@ -40,14 +40,25 @@ pub fn success_list_response( } pub fn common_response(status: StatusCode, message: &str) -> Response { - ( - status, - Json(json!({ - "message": message, - "version": env!("CARGO_PKG_VERSION"), - })), - ) - .into_response() + ( + status, + Json(json!({ + "message": message, + "version": env!("CARGO_PKG_VERSION"), + })), + ) + .into_response() +} + +pub fn error_response(error: AppError) -> Response { + ( + error.status_code(), + Json(json!({ + "error": error.message(), + "version": env!("CARGO_PKG_VERSION"), + })), + ) + .into_response() } pub fn success_created_response(params: ResponseSuccessDto) -> Response { diff --git a/test.sh b/test.sh index 9edd8a9..2e01a57 100644 --- a/test.sh +++ b/test.sh @@ -473,6 +473,66 @@ test_comprehensive_with_user() { local original_auth_token="$AUTH_TOKEN" AUTH_TOKEN="$user_token" + # Get user ID and add credits for gacha testing + local temp_file=$(mktemp) + local status_file=$(mktemp) + + # Get user profile to extract user_id + curl -s -X "GET" -H "Content-Type: application/json" -H "Authorization: Bearer $user_token" "$BASE_URL/v1/users/me" \ + -D "$status_file" -o "$temp_file" + + local user_profile_body=$(cat "$temp_file") + local user_profile_status=$(head -n 1 "$status_file" | cut -d' ' -f2) + + rm -f "$temp_file" "$status_file" + + if [[ "$user_profile_status" =~ ^[0-9]+$ ]] && [ "$user_profile_status" -eq 200 ]; then + local user_id=$(echo "$user_profile_body" | jq -r '.data.id // empty') + if [ -n "$user_id" ]; then + # Add credits for gacha testing + local add_credits_data=$(jq -n --arg user_id "$user_id" '{user_id: $user_id, amount: 10}') + local temp_file=$(mktemp) + local status_file=$(mktemp) + + curl -s -X "POST" -H "Content-Type: application/json" -H "Authorization: Bearer $user_token" -d "$add_credits_data" "$BASE_URL/v1/gacha/credits/add" \ + -D "$status_file" -o "$temp_file" + + local add_credits_body=$(cat "$temp_file") + local add_credits_status=$(head -n 1 "$status_file" | cut -d' ' -f2) + + rm -f "$temp_file" "$status_file" + + if [[ "$add_credits_status" =~ ^[0-9]+$ ]] && [ "$add_credits_status" -eq 200 ]; then + write_test_log "SUCCESS" "✓ Credits added for $fullname (user_id: $user_id)" + + # Verify credits were added correctly + local get_credits_response=$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X GET "$BASE_URL/v1/gacha/credits" \ + -H "Authorization: Bearer $user_token") + local get_credits_body=$(echo "$get_credits_response" | head -n -1) + local get_credits_status=$(echo "$get_credits_response" | tail -n 1 | sed 's/HTTP_STATUS://') + + if [[ "$get_credits_status" =~ ^[0-9]+$ ]] && [ "$get_credits_status" -eq 200 ]; then + local available_rolls=$(echo "$get_credits_body" | jq -r '.data.available_rolls // 0') + if [ "$available_rolls" -ge 10 ]; then + write_test_log "SUCCESS" "✓ Credits verified for $fullname: $available_rolls rolls available" + else + write_test_log "ERROR" "✗ Credits not added correctly for $fullname: expected >=10, got $available_rolls" + return 1 + fi + else + write_test_log "ERROR" "✗ Failed to get credits for $fullname - HTTP $get_credits_status: $get_credits_body" + return 1 + fi + else + write_test_log "ERROR" "✗ Failed to add credits for $fullname - HTTP $add_credits_status: $add_credits_body" + return 1 + fi + else + write_test_log "ERROR" "✗ Failed to get user_id for $fullname from profile response" + fi + else + write_test_log "ERROR" "✗ Failed to get user profile for $fullname - HTTP $user_profile_status" + fi printf "\n${BLUE}--- Testing dengan $fullname (Expected results berdasarkan role) ---${NC}\n" @@ -680,10 +740,32 @@ test_user_management_endpoints() { --arg is_active true \ --arg role_id "$new_user_role_id" \ '{email: $email, password: $pass, fullname: $fullname, phone_number: $phone, is_active: $is_active | fromjson, role_id: $role_id}') - test_api_endpoint "Create New User" "POST" "/v1/users/create" 201 "$create_user_data" true - # Assuming the created user can be fetched by email for update/delete - local created_user_id=$(curl -s -X GET -H "Authorization: Bearer $AUTH_TOKEN" "$BASE_URL/v1/users?search=$new_user_email" | jq -r '.data[0].id // empty') + # Create user and capture response directly + local temp_file=$(mktemp) + local status_file=$(mktemp) + + curl -s -X "POST" -H "Content-Type: application/json" -H "Authorization: Bearer $AUTH_TOKEN" -d "$create_user_data" "$BASE_URL/v1/users/create" \ + -D "$status_file" -o "$temp_file" + + local create_response_body=$(cat "$temp_file") + local create_status=$(head -n 1 "$status_file" | cut -d' ' -f2) + + rm -f "$temp_file" "$status_file" + + if [[ "$create_status" =~ ^[0-9]+$ ]] && [ "$create_status" -eq 201 ]; then + ((PASS_COUNT++)) + write_test_log "SUCCESS" "✓ Create New User - Sukses (Status: $create_status, Waktu: ${duration}ms)" + + # Extract user ID directly from create response + local created_user_id=$(echo "$create_response_body" | jq -r '.data.id // empty') + else + ((FAIL_COUNT++)) + write_test_log "ERROR" "✗ Create New User - Gagal (Status: $create_status)" + write_test_log "ERROR" " Response Body: $create_response_body" + FAILED_TESTS_SUMMARY+=("✗ Create New User - HTTP $create_status") + return + fi if [ -n "$created_user_id" ]; then local updated_user_fullname="Updated Test User $(date +%s%N)" diff --git a/tests/src/gacha/gacha_credits_comprehensive_test.rs b/tests/src/gacha/gacha_credits_comprehensive_test.rs new file mode 100644 index 0000000..a4d87f1 --- /dev/null +++ b/tests/src/gacha/gacha_credits_comprehensive_test.rs @@ -0,0 +1,226 @@ +#[cfg(test)] +mod tests { + use crate::{generate_unique_email, get_role_id, setup_all_test_environment, UsersRepository}; + use axum::{http::StatusCode, response::Response}; + use imphnen_entities::{AppState, ResponseSuccessDto}; + use imphnen_gacha::{ + gacha_credits_controller::GachaCreditController, + gacha_credits_dto::GachaCreditRequestDto, + gacha_rolls_controller::GachaRollController, + }; + use imphnen_iam::users_service::UsersService; + use serde_json::json; + + #[tokio::test] + async fn test_comprehensive_gacha_credits_flow() { + let app_state = setup_all_test_environment().await; + let user_repo = UsersRepository::new(&app_state); + + // Create test user + let email = generate_unique_email("test_comprehensive_credits"); + let password = "Password123!".to_string(); + + let user_dto = imphnen_iam::users_dto::UserCreateRequestDto { + email: email.clone(), + password: password.clone(), + fullname: "Test Comprehensive Credits".to_string(), + phone_number: Some("1234567890".to_string()), + role_id: get_role_id(&app_state, "user").await.unwrap(), + }; + + let _ = UsersService::create_user(&app_state, user_dto).await; + let user = user_repo.query_user_by_email(email.clone()).await.unwrap(); + + // Test 1: Get initial credits (should be 0) + let headers = axum::http::HeaderMap::new(); + headers.insert("Authorization", "Bearer test_token".parse().unwrap()); + + let response = GachaCreditController::get_user_credits(headers.clone(), &app_state).await; + assert_eq!(response.status(), StatusCode::OK); + + let response_body: ResponseSuccessDto = response.json().await.unwrap(); + let available_rolls = response_body.data["available_rolls"].as_i64().unwrap(); + assert_eq!(available_rolls, 0); + + // Test 2: Add credits + let add_credits_dto = GachaCreditRequestDto { + user_id: user.id.id.to_raw(), + amount: 10, + }; + + let add_response = GachaCreditController::add_user_credits( + headers.clone(), + &app_state, + add_credits_dto + ).await; + assert_eq!(add_response.status(), StatusCode::OK); + + // Test 3: Verify credits were added + let get_response = GachaCreditController::get_user_credits(headers.clone(), &app_state).await; + let response_body: ResponseSuccessDto = get_response.json().await.unwrap(); + let available_rolls = response_body.data["available_rolls"].as_i64().unwrap(); + assert_eq!(available_rolls, 10); + + // Test 4: Consume one credit + let consume_response = GachaCreditController::consume_user_credit(headers.clone(), &app_state).await; + assert_eq!(consume_response.status(), StatusCode::OK); + + // Test 5: Verify credit was consumed + let get_response = GachaCreditController::get_user_credits(headers.clone(), &app_state).await; + let response_body: ResponseSuccessDto = get_response.json().await.unwrap(); + let available_rolls = response_body.data["available_rolls"].as_i64().unwrap(); + assert_eq!(available_rolls, 9); + + // Test 6: Try to execute a gacha roll (should consume another credit) + let roll_response = GachaRollController::execute_roll_once(headers.clone(), &app_state).await; + + // This might fail if there are no active rolls in test environment, but should not fail due to credits + if roll_response.status() == StatusCode::OK { + // Verify credits were consumed if roll was successful + let get_response = GachaCreditController::get_user_credits(headers.clone(), &app_state).await; + let response_body: ResponseSuccessDto = get_response.json().await.unwrap(); + let available_rolls = response_body.data["available_rolls"].as_i64().unwrap(); + assert!(available_rolls <= 8, "Credits should be reduced after successful roll"); + } + + // Clean up + let _ = user_repo.query_delete_user(user.id.id.to_raw()).await; + } + + #[tokio::test] + async fn test_add_negative_credits() { + let app_state = setup_all_test_environment().await; + let user_repo = UsersRepository::new(&app_state); + + // Create test user + let email = generate_unique_email("test_negative_credits"); + let password = "Password123!".to_string(); + + let user_dto = imphnen_iam::users_dto::UserCreateRequestDto { + email: email.clone(), + password: password.clone(), + fullname: "Test Negative Credits".to_string(), + phone_number: Some("1234567890".to_string()), + role_id: get_role_id(&app_state, "user").await.unwrap(), + }; + + let _ = UsersService::create_user(&app_state, user_dto).await; + let user = user_repo.query_user_by_email(email.clone()).await.unwrap(); + + let headers = axum::http::HeaderMap::new(); + headers.insert("Authorization", "Bearer test_token".parse().unwrap()); + + // Add negative credits (should still work as i32 allows negative values) + let negative_credits_dto = GachaCreditRequestDto { + user_id: user.id.id.to_raw(), + amount: -5, + }; + + let response = GachaCreditController::add_user_credits( + headers.clone(), + &app_state, + negative_credits_dto + ).await; + + // Should succeed (negative credits are allowed by the system) + assert_eq!(response.status(), StatusCode::OK); + + // Verify negative credits were added + let get_response = GachaCreditController::get_user_credits(headers.clone(), &app_state).await; + let response_body: ResponseSuccessDto = get_response.json().await.unwrap(); + let available_rolls = response_body.data["available_rolls"].as_i64().unwrap(); + assert_eq!(available_rolls, -5); + + // Clean up + let _ = user_repo.query_delete_user(user.id.id.to_raw()).await; + } + + #[tokio::test] + async fn test_consume_credits_when_none_available() { + let app_state = setup_all_test_environment().await; + let user_repo = UsersRepository::new(&app_state); + + // Create test user + let email = generate_unique_email("test_no_credits"); + let password = "Password123!".to_string(); + + let user_dto = imphnen_iam::users_dto::UserCreateRequestDto { + email: email.clone(), + password: password.clone(), + fullname: "Test No Credits".to_string(), + phone_number: Some("1234567890".to_string()), + role_id: get_role_id(&app_state, "user").await.unwrap(), + }; + + let _ = UsersService::create_user(&app_state, user_dto).await; + let user = user_repo.query_user_by_email(email.clone()).await.unwrap(); + + let headers = axum::http::HeaderMap::new(); + headers.insert("Authorization", "Bearer test_token".parse().unwrap()); + + // Try to consume credits when none available + let response = GachaCreditController::consume_user_credit(headers.clone(), &app_state).await; + + // Should return error + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // Clean up + let _ = user_repo.query_delete_user(user.id.id.to_raw()).await; + } + + #[tokio::test] + async fn test_credits_integration_with_gacha_rolls() { + let app_state = setup_all_test_environment().await; + let user_repo = UsersRepository::new(&app_state); + + // Create test user + let email = generate_unique_email("test_credits_integration"); + let password = "Password123!".to_string(); + + let user_dto = imphnen_iam::users_dto::UserCreateRequestDto { + email: email.clone(), + password: password.clone(), + fullname: "Test Credits Integration".to_string(), + phone_number: Some("1234567890".to_string()), + role_id: get_role_id(&app_state, "user").await.unwrap(), + }; + + let _ = UsersService::create_user(&app_state, user_dto).await; + let user = user_repo.query_user_by_email(email.clone()).await.unwrap(); + + let headers = axum::http::HeaderMap::new(); + headers.insert("Authorization", "Bearer test_token".parse().unwrap()); + + // Add initial credits + let add_credits_dto = GachaCreditRequestDto { + user_id: user.id.id.to_raw(), + amount: 5, + }; + + let _ = GachaCreditController::add_user_credits( + headers.clone(), + &app_state, + add_credits_dto + ).await; + + // Check initial credits + let get_response = GachaCreditController::get_user_credits(headers.clone(), &app_state).await; + let response_body: ResponseSuccessDto = get_response.json().await.unwrap(); + let initial_credits = response_body.data["available_rolls"].as_i64().unwrap(); + assert_eq!(initial_credits, 5); + + // Try to execute a gacha roll + let roll_response = GachaRollController::execute_roll_once(headers.clone(), &app_state).await; + + // If roll is successful, check that credits were reduced + if roll_response.status() == StatusCode::OK { + let get_response = GachaCreditController::get_user_credits(headers.clone(), &app_state).await; + let response_body: ResponseSuccessDto = get_response.json().await.unwrap(); + let final_credits = response_body.data["available_rolls"].as_i64().unwrap(); + assert_eq!(final_credits, 4, "One credit should be consumed for the roll"); + } + + // Clean up + let _ = user_repo.query_delete_user(user.id.id.to_raw()).await; + } +} \ No newline at end of file diff --git a/tests/src/rate_limiting_middleware_test.rs b/tests/src/rate_limiting_middleware_test.rs new file mode 100644 index 0000000..de3a9c2 --- /dev/null +++ b/tests/src/rate_limiting_middleware_test.rs @@ -0,0 +1,296 @@ +#[cfg(test)] +mod rate_limiting_middleware_tests { + use axum::{http::Request, middleware::Next, response::Response}; + use imphnen_libs::{AppState, environment::Environment}; + use imphnen_middleware::rate_limiting_middleware::{ + RateLimitConfig, RateLimitStore, TokenBucket, create_rate_limiting_middleware, + auth_rate_limiting_middleware, + }; + use std::{sync::Arc, time::Duration}; + use tower::ServiceExt; + + #[tokio::test] + async fn test_token_bucket_basic_functionality() { + let bucket = TokenBucket::new(5, 2); // Capacity 5, refill 2 per second + + // Should have full tokens initially + assert_eq!(bucket.tokens, 5); + + // Consume some tokens + assert!(bucket.try_consume()); + assert_eq!(bucket.tokens, 4); + + assert!(bucket.try_consume()); + assert_eq!(bucket.tokens, 3); + + assert!(bucket.try_consume()); + assert_eq!(bucket.tokens, 2); + + assert!(bucket.try_consume()); + assert_eq!(bucket.tokens, 1); + + assert!(bucket.try_consume()); + assert_eq!(bucket.tokens, 0); + + // Should not consume when empty + assert!(!bucket.try_consume()); + assert_eq!(bucket.tokens, 0); + } + + #[tokio::test] + async fn test_token_bucket_refill() { + let mut bucket = TokenBucket::new(3, 1); // Capacity 3, refill 1 per second + + // Consume all tokens + for _ in 0..3 { + assert!(bucket.try_consume()); + } + + assert!(!bucket.try_consume()); + assert_eq!(bucket.tokens, 0); + + // Wait for 1 second to allow refill + tokio::time::sleep(Duration::from_secs(1)).await; + + // Should have 1 token after refill + bucket.refill_tokens(); + assert_eq!(bucket.tokens, 1); + + // Consume the refilled token + assert!(bucket.try_consume()); + assert_eq!(bucket.tokens, 0); + + // Wait another second + tokio::time::sleep(Duration::from_secs(1)).await; + + // Should have another token + bucket.refill_tokens(); + assert_eq!(bucket.tokens, 1); + } + + #[tokio::test] + async fn test_rate_limit_store_basic() { + let config = RateLimitConfig::test(); + let store = Arc::new(RateLimitStore::new(config)); + + let client_ip = "127.0.0.1"; + + // First request should succeed + let result = store.check_limit(client_ip).await; + assert!(result.is_ok()); + + // Multiple requests should succeed within limits + for _ in 0..config.bucket_size { + let result = store.check_limit(client_ip).await; + assert!(result.is_ok()); + } + + // Next request should fail + let result = store.check_limit(client_ip).await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), axum::http::StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn test_rate_limit_store_window_reset() { + let config = RateLimitConfig { + max_requests: 10, + window_duration: Duration::from_secs(2), + bucket_size: 2, + refill_rate: 1, + }; + let store = Arc::new(RateLimitStore::new(config)); + + let client_ip = "127.0.0.1"; + + // Consume all tokens + assert!(store.check_limit(client_ip).await.is_ok()); + assert!(store.check_limit(client_ip).await.is_ok()); + assert!(store.check_limit(client_ip).await.is_err()); + + // Wait for window to reset + tokio::time::sleep(Duration::from_secs(3)).await; + + // Should be able to make requests again + assert!(store.check_limit(client_ip).await.is_ok()); + assert!(store.check_limit(client_ip).await.is_ok()); + assert!(store.check_limit(client_ip).await.is_err()); + } + + #[tokio::test] + async fn test_different_clients_have_separate_limits() { + let config = RateLimitConfig::test(); + let store = Arc::new(RateLimitStore::new(config)); + + let client_ip_1 = "127.0.0.1"; + let client_ip_2 = "127.0.0.2"; + + // Client 1 should be able to make requests + for _ in 0..config.bucket_size { + assert!(store.check_limit(client_ip_1).await.is_ok()); + } + assert!(store.check_limit(client_ip_1).await.is_err()); + + // Client 2 should still be able to make requests + for _ in 0..config.bucket_size { + assert!(store.check_limit(client_ip_2).await.is_ok()); + } + assert!(store.check_limit(client_ip_2).await.is_err()); + } + + #[tokio::test] + async fn test_auth_rate_limiting_middleware_success() { + // Create a mock AppState with test environment + let state = AppState { + surrealdb_ws: Default::default(), + surrealdb_mem: Default::default(), + user_lookup_service: Default::default(), + auth_repository: Default::default(), + env: Environment::Test, + }; + + // Create a mock request to /auth/login + let mut request = Request::builder() + .uri("/v1/auth/login") + .header("x-forwarded-for", "127.0.0.1") + .body(()) + .unwrap(); + + // Create a mock next service + let next = Next::new(|req| async move { + let response = Response::builder() + .status(200) + .body("Login successful") + .unwrap(); + Ok::<_, axum::http::StatusCode>((req, response)) + }); + + // Call the middleware + let result = auth_rate_limiting_middleware( + axum::Extension(state.clone()), + request, + next, + ).await; + + // Should succeed + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[tokio::test] + async fn test_auth_rate_limiting_middleware_429() { + // Create test configuration with very low limits for testing + let config = RateLimitConfig { + max_requests: 1, + window_duration: Duration::from_secs(10), + bucket_size: 1, + refill_rate: 1, + }; + + // Create a mock AppState with test environment + let state = AppState { + surrealdb_ws: Default::default(), + surrealdb_mem: Default::default(), + user_lookup_service: Default::default(), + auth_repository: Default::default(), + env: Environment::Test, + }; + + // Create a mock request to /auth/login + let mut request = Request::builder() + .uri("/v1/auth/login") + .header("x-forwarded-for", "127.0.0.1") + .body(()) + .unwrap(); + + // Create a mock next service + let next = Next::new(|req| async move { + let response = Response::builder() + .status(200) + .body("Login successful") + .unwrap(); + Ok::<_, axum::http::StatusCode>((req, response)) + }); + + // First request should succeed + let result = auth_rate_limiting_middleware( + axum::Extension(state.clone()), + request.clone(), + next.clone(), + ).await; + assert!(result.is_ok()); + + // Second request should fail with 429 + let result = auth_rate_limiting_middleware( + axum::Extension(state), + request, + next, + ).await; + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 429); + assert_eq!(response.headers().get("Retry-After").unwrap(), "60"); + } + + #[tokio::test] + async fn test_non_auth_endpoints_not_rate_limited() { + // Create a mock AppState with test environment + let state = AppState { + surrealdb_ws: Default::default(), + surrealdb_mem: Default::default(), + user_lookup_service: Default::default(), + auth_repository: Default::default(), + env: Environment::Test, + }; + + // Create a mock request to a non-auth endpoint + let mut request = Request::builder() + .uri("/v1/users/me") + .header("x-forwarded-for", "127.0.0.1") + .body(()) + .unwrap(); + + // Create a mock next service + let next = Next::new(|req| async move { + let response = Response::builder() + .status(200) + .body("User data") + .unwrap(); + Ok::<_, axum::http::StatusCode>((req, response)) + }); + + // Call the middleware - should not apply rate limiting + let result = auth_rate_limiting_middleware( + axum::Extension(state), + request, + next, + ).await; + + // Should succeed + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.status(), 200); + } + + #[tokio::test] + async fn test_environment_specific_configurations() { + // Test development config + let dev_config = RateLimitConfig::development(); + assert_eq!(dev_config.max_requests, 100); + assert_eq!(dev_config.bucket_size, 50); + assert_eq!(dev_config.refill_rate, 10); + + // Test production config + let prod_config = RateLimitConfig::production(); + assert_eq!(prod_config.max_requests, 10); + assert_eq!(prod_config.bucket_size, 5); + assert_eq!(prod_config.refill_rate, 1); + + // Test test config + let test_config = RateLimitConfig::test(); + assert_eq!(test_config.max_requests, 1000); + assert_eq!(test_config.bucket_size, 100); + assert_eq!(test_config.refill_rate, 20); + } +} \ No newline at end of file diff --git a/tests/src/security_headers_middleware_test.rs b/tests/src/security_headers_middleware_test.rs new file mode 100644 index 0000000..d2db57e --- /dev/null +++ b/tests/src/security_headers_middleware_test.rs @@ -0,0 +1,89 @@ +use axum::{ + http::{Request, StatusCode}, + middleware::Next, + response::Response, + Extension, +}; +use imphnen_libs::{AppState, ENV}; +use imphnen_middleware::security_headers_middleware::security_headers_middleware; +use tower::ServiceExt; + +#[tokio::test] +async fn test_security_headers_middleware_adds_headers() { + // Create a mock request + let req = Request::builder() + .uri("/test") + .body(axum::body::empty()) + .unwrap(); + + // Create a mock response for the next middleware + let next = Next::new(|req| async move { + let res = Response::builder() + .status(StatusCode::OK) + .body(axum::body::empty()) + .unwrap(); + Ok::<_, axum::http::Error>((req, res)) + }); + + // Run the middleware + let res = security_headers_middleware(Extension(AppState::default()), req, next).await.unwrap(); + + // Check that security headers are added + let headers = res.headers(); + + // Check X-Frame-Options + assert_eq!( + headers.get("X-Frame-Options").unwrap(), + "DENY" + ); + + // Check X-Content-Type-Options + assert_eq!( + headers.get("X-Content-Type-Options").unwrap(), + "nosniff" + ); + + // Check Referrer-Policy + assert_eq!( + headers.get("Referrer-Policy").unwrap(), + "strict-origin-when-cross-origin" + ); + + // Check that Content-Security-Policy is added + assert!(headers.contains_key("Content-Security-Policy")); + + // Check that Strict-Transport-Security is added + assert!(headers.contains_key("Strict-Transport-Security")); +} + +#[tokio::test] +async fn test_security_headers_middleware_environment_specific_headers() { + // Temporarily set environment to production for testing + let original_env = ENV.rust_env.clone(); + std::env::set_var("RUST_ENV", "production"); + + // Create a mock request + let req = Request::builder() + .uri("/test") + .body(axum::body::empty()) + .unwrap(); + + // Create a mock response for the next middleware + let next = Next::new(|req| async move { + let res = Response::builder() + .status(StatusCode::OK) + .body(axum::body::empty()) + .unwrap(); + Ok::<_, axum::http::Error>((req, res)) + }); + + // Run the middleware + let res = security_headers_middleware(Extension(AppState::default()), req, next).await.unwrap(); + + // Check that HSTS header is set for production + let hsts_header = headers.get("Strict-Transport-Security").unwrap(); + assert!(hsts_header.to_str().unwrap().contains("max-age=31536000")); + + // Restore original environment + std::env::set_var("RUST_ENV", original_env); +} \ No newline at end of file diff --git a/tests/src/validation_comprehensive_test.rs b/tests/src/validation_comprehensive_test.rs new file mode 100644 index 0000000..9550d71 --- /dev/null +++ b/tests/src/validation_comprehensive_test.rs @@ -0,0 +1,252 @@ +use axum::http::StatusCode; +use imphnen_gacha::v1::gacha_credits::gacha_credits_dto::GachaCreditRequestDto; +use imphnen_gacha::v1::gacha_rolls::gacha_rolls_dto::GachaRollRequestDto; +use imphnen_gacha::v1::gacha_claims::gacha_claims_dto::GachaClaimRequestDto; +use imphnen_gacha::v1::gacha_items::gacha_items_dto::{GachaItemRequestDto, GachaItemUpdateRequestDto}; +use imphnen_cms::v1::landing::events::events_dto::{EventsCreateRequestDto, validate_url}; +use imphnen_utils::validator::validate_request; +use chrono::{DateTime, Utc}; +use validator::ValidationError; + +#[tokio::test] +async fn test_gacha_credit_request_validation() { + // Test valid case + let valid_dto = GachaCreditRequestDto { + user_id: "user-123".to_string(), + amount: 10, + }; + + let result = validate_request(&valid_dto); + assert!(result.is_ok()); + + // Test empty user_id + let invalid_dto = GachaCreditRequestDto { + user_id: "".to_string(), + amount: 10, + }; + + let result = validate_request(&invalid_dto); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().0, StatusCode::BAD_REQUEST); + assert!(result.unwrap_err().1.contains("User ID must not be empty")); + + // Test negative amount + let invalid_dto = GachaCreditRequestDto { + user_id: "user-123".to_string(), + amount: -5, + }; + + let result = validate_request(&invalid_dto); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().0, StatusCode::BAD_REQUEST); + assert!(result.unwrap_err().1.contains("Amount must be at least 1 credit")); +} + +#[tokio::test] +async fn test_gacha_roll_request_validation() { + // Test valid case + let valid_dto = GachaRollRequestDto { + item_id: "item-123".to_string(), + weight: 0.5, + quantity: 5, + }; + + let result = validate_request(&valid_dto); + assert!(result.is_ok()); + + // Test empty item_id + let invalid_dto = GachaRollRequestDto { + item_id: "".to_string(), + weight: 0.5, + quantity: 5, + }; + + let result = validate_request(&invalid_dto); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().0, StatusCode::BAD_REQUEST); + assert!(result.unwrap_err().1.contains("Item ID must not be empty")); + + // Test invalid weight range + let invalid_dto = GachaRollRequestDto { + item_id: "item-123".to_string(), + weight: 1.5, + quantity: 5, + }; + + let result = validate_request(&invalid_dto); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().0, StatusCode::BAD_REQUEST); + assert!(result.unwrap_err().1.contains("Weight must be between 0.0 and 1.0")); +} + +#[tokio::test] +async fn test_gacha_claim_request_validation() { + // Test valid case + let valid_dto = GachaClaimRequestDto { + user_id: "user-123".to_string(), + item_id: "item-456".to_string(), + }; + + let result = validate_request(&valid_dto); + assert!(result.is_ok()); + + // Test empty item_id + let invalid_dto = GachaClaimRequestDto { + user_id: "user-123".to_string(), + item_id: "".to_string(), + }; + + let result = validate_request(&invalid_dto); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().0, StatusCode::BAD_REQUEST); + assert!(result.unwrap_err().1.contains("Item ID must not be empty")); +} + +#[tokio::test] +async fn test_gacha_item_request_validation() { + // Test valid case + let valid_dto = GachaItemRequestDto { + name: "Test Item".to_string(), + image_url: "https://example.com/image.jpg".to_string(), + }; + + let result = validate_request(&valid_dto); + assert!(result.is_ok()); + + // Test empty name + let invalid_dto = GachaItemRequestDto { + name: "".to_string(), + image_url: "https://example.com/image.jpg".to_string(), + }; + + let result = validate_request(&invalid_dto); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().0, StatusCode::BAD_REQUEST); + assert!(result.unwrap_err().1.contains("Item name must not be empty")); + + // Test invalid image URL + let invalid_dto = GachaItemRequestDto { + name: "Test Item".to_string(), + image_url: "not-a-url".to_string(), + }; + + let result = validate_request(&invalid_dto); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().0, StatusCode::BAD_REQUEST); + assert!(result.unwrap_err().1.contains("Image URL must be a valid URL")); +} + +#[tokio::test] +async fn test_custom_url_validator() { + // Test valid URLs + let valid_urls = [ + "https://example.com", + "http://example.com", + "https://example.com/path", + "https://example.com/path?query=value", + ]; + + for url in valid_urls.iter() { + let result = validate_url(url); + assert!(result.is_ok(), "URL should be valid: {}", url); + } + + // Test invalid URLs + let invalid_urls = [ + "not-a-url", + "example.com", + "https://", + "http://.com", + ]; + + for url in invalid_urls.iter() { + let result = validate_url(url); + assert!(result.is_err(), "URL should be invalid: {}", url); + assert_eq!(result.unwrap_err().code(), "invalid_url"); + } +} + +#[tokio::test] +async fn test_events_create_request_validation() { + let now = Utc::now(); + let future = now + chrono::Duration::days(1); + + // Test valid case + let valid_dto = EventsCreateRequestDto { + name: "Test Event".to_string(), + description: "Test description".to_string(), + detail_link: "https://example.com/event".to_string(), + price: 99.99, + end_date: future, + start_date: now, + location: Some("Test Location".to_string()), + is_online: false, + }; + + let result = validate_request(&valid_dto); + assert!(result.is_ok()); + + // Test empty name + let mut invalid_dto = valid_dto.clone(); + invalid_dto.name = "".to_string(); + + let result = validate_request(&invalid_dto); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().0, StatusCode::BAD_REQUEST); + assert!(result.unwrap_err().1.contains("Name must be between 1 and 100 characters")); + + // Test negative price + let mut invalid_dto = valid_dto.clone(); + invalid_dto.price = -10.0; + + let result = validate_request(&invalid_dto); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().0, StatusCode::BAD_REQUEST); + assert!(result.unwrap_err().1.contains("Price cannot be negative")); +} + +#[tokio::test] +async fn test_gacha_item_update_request_validation() { + // Test valid case with Some values + let valid_dto = GachaItemUpdateRequestDto { + name: Some("Updated Item".to_string()), + image_url: Some("https://example.com/updated.jpg".to_string()), + }; + + let result = validate_request(&valid_dto); + assert!(result.is_ok()); + + // Test invalid image URL + let invalid_dto = GachaItemUpdateRequestDto { + name: Some("Updated Item".to_string()), + image_url: Some("not-a-url".to_string()), + }; + + let result = validate_request(&invalid_dto); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().0, StatusCode::BAD_REQUEST); + assert!(result.unwrap_err().1.contains("Image URL must be a valid URL")); +} + +#[tokio::test] +async fn test_all_dto_types_have_validation() { + // Test that all DTOs derive Validate trait + let _: &dyn Validate = &GachaCreditRequestDto { user_id: "".to_string(), amount: 0 }; + let _: &dyn Validate = &GachaRollRequestDto { item_id: "".to_string(), weight: 0.0, quantity: 0 }; + let _: &dyn Validate = &GachaClaimRequestDto { user_id: "".to_string(), item_id: "".to_string() }; + let _: &dyn Validate = &GachaItemRequestDto { name: "".to_string(), image_url: "".to_string() }; + let _: &dyn Validate = &GachaItemUpdateRequestDto { name: None, image_url: None }; + let _: &dyn Validate = &EventsCreateRequestDto { + name: "".to_string(), + description: "".to_string(), + detail_link: "".to_string(), + price: 0.0, + end_date: Utc::now(), + start_date: Utc::now(), + location: None, + is_online: false, + }; + + // If we get here without panicking, all DTOs implement Validate + assert!(true); +} \ No newline at end of file