Implement rate limiting middleware for authentication endpoints, adding security headers middleware, and comprehensive error handling. Enhance validation tests for various DTOs and ensure proper functionality of gacha credits and rolls. Add unit tests for rate limiting and security headers middleware to validate behavior under different conditions.

This commit is contained in:
MythEclipse
2025-10-11 23:23:51 +07:00
parent 789b20c278
commit b6b5f48055
32 changed files with 1734 additions and 163 deletions
+3 -3
View File
@@ -26,7 +26,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
(
"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<dyn Error>> {
(
"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"),
),
];
@@ -46,6 +46,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadListPermissions,
],
),
(
@@ -65,6 +67,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadListPermissions,
],
),
(
@@ -85,6 +89,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::ManageAllTeams,
],
),
(
+49 -13
View File
@@ -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<Utc>) -> 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<Utc>, end_date: &DateTime<Utc>) -> 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<Utc>,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub start_date: DateTime<Utc>,
#[validate(length(max = 200, message = "Location name cannot exceed 200 characters"))]
pub location: Option<String>,
pub is_online: bool,
}
@@ -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,
}
+1 -6
View File
@@ -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;
@@ -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,
}
@@ -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<AppState>,
) -> Response {
GachaCreditService::get_user_credits(&headers, &state).await
}
pub async fn add_user_credits(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Json(payload): Json<GachaCreditRequestDto>,
) -> Response {
GachaCreditService::add_user_credits(&headers, &state, payload).await
}
pub async fn consume_user_credit(
headers: HeaderMap,
Extension(state): Extension<AppState>,
) -> Response {
GachaCreditService::consume_user_credit(&headers, &state).await
}
}
@@ -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<String>,
pub updated_at: Option<String>,
}
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(),
}
}
}
@@ -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<GachaCreditSchema> =
@@ -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))
}
@@ -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())),
}
}
}
+8 -1
View File
@@ -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;
@@ -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<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"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
}
@@ -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,
}
@@ -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);
+8 -6
View File
@@ -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
}
+8 -7
View File
@@ -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))
}
@@ -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<Utc>,
#[schema(value_type = String, format = DateTime)]
pub end_date: DateTime<Utc>,
#[schema(value_type = String, format = DateTime)]
pub registration_deadline: DateTime<Utc>,
#[validate(range(min = 1, max = 10000, message = "Max participants must be between 1 and 10000"))]
pub max_participants: Option<u32>,
#[validate(length(max = 200, message = "Theme cannot exceed 200 characters"))]
pub theme: Option<String>,
#[validate(length(max = 2000, message = "Rules cannot exceed 2000 characters"))]
pub rules: Option<String>,
pub prizes: Option<Vec<PrizeDto>>,
pub previous_winners: Option<Vec<WinnerDto>>,
#[validate(length(min = 1, message = "Organizers list cannot be empty"))]
pub organizers: Vec<String>,
}
+57 -47
View File
@@ -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(
@@ -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();
+26 -24
View File
@@ -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,
+4
View File
@@ -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;
@@ -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<AppState>,
mut req: Request<axum::body::Body>,
next: Next,
) -> Result<Response, StatusCode> {
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)
}
@@ -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<AppState>,
mut req: Request<axum::body::Body>,
next: Next,
) -> Result<Response<axum::body::Body>, 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<axum::body::Body>) -> Response<axum::body::Body> {
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
}
+72
View File
@@ -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<T, E = AppError> = std::result::Result<T, E>;
+3 -1
View File
@@ -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,
+20 -9
View File
@@ -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<T: Serialize>(params: ResponseSuccessDto<T>) -> Response {
(
@@ -40,14 +40,25 @@ pub fn success_list_response<T: Serialize>(
}
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<T: Serialize>(params: ResponseSuccessDto<T>) -> Response {
+85 -3
View File
@@ -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)"
@@ -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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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;
}
}
+296
View File
@@ -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);
}
}
@@ -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);
}
+252
View File
@@ -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);
}