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:
@@ -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,
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user