From 7205afe43b9100df2923ec990d63fe8d22756db4 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 22 Sep 2025 17:25:02 +0700 Subject: [PATCH] feat: Implement admin team management endpoints with permissions and DTOs --- .../src/v1/permissions/permissions_enum.rs | 4 + imphnen-iam/src/v1/teams/teams_controller.rs | 101 +++++- imphnen-iam/src/v1/teams/teams_dto.rs | 183 ++++++++-- imphnen-iam/src/v1/teams/teams_service.rs | 109 +++++- .../iam/teams/admin_teams_controller_tests.rs | 326 ++++++++++++++++++ 5 files changed, 697 insertions(+), 26 deletions(-) create mode 100644 tests/src/iam/teams/admin_teams_controller_tests.rs diff --git a/imphnen-iam/src/v1/permissions/permissions_enum.rs b/imphnen-iam/src/v1/permissions/permissions_enum.rs index 5e34a07..c37f778 100644 --- a/imphnen-iam/src/v1/permissions/permissions_enum.rs +++ b/imphnen-iam/src/v1/permissions/permissions_enum.rs @@ -20,6 +20,8 @@ pub enum PermissionsEnum { CreatePermissions, DeletePermissions, UpdatePermissions, + ReadListTeams, + ReadDetailTeams, CreateGachaClaims, ReadDetailGachaClaims, ReadListGachaItems, @@ -126,6 +128,8 @@ impl PermissionsEnum { PermissionsEnum::DeleteGachaRolls => "12345678-ABCD-EFAB-CDEF-0123456789AB", PermissionsEnum::ReadListMentors => "a1b2c3d4-5e6f-7890-abcd-ef1234567890", PermissionsEnum::ReadDetailMentors => "b2c3d4e5-6f78-9012-bcde-f23456789012", + PermissionsEnum::ReadListTeams => "e1f2g3h4-5i6j-7k8l-9m0n-op1q2r3s4t5u", + PermissionsEnum::ReadDetailTeams => "f2g3h4i5-6j7k-8l9m-0n1o-pq2r3s4t5u6", PermissionsEnum::RegisterMentors => "c3d4e5f6-7890-1234-cdef-345678901234", PermissionsEnum::ReadOwnMentorProfile => { "d4e5f6a7-8901-2345-def0-456789012345" diff --git a/imphnen-iam/src/v1/teams/teams_controller.rs b/imphnen-iam/src/v1/teams/teams_controller.rs index 08b4b1a..7433973 100644 --- a/imphnen-iam/src/v1/teams/teams_controller.rs +++ b/imphnen-iam/src/v1/teams/teams_controller.rs @@ -3,7 +3,8 @@ use crate::{ MessageResponseDto, ResponseListSuccessDto, ResponseSuccessDto, TeamsCreateRequestDto, TeamsDetailItemDto, TeamsListItemDto, permissions_guard, TeamsUpdateRequestDto, TeamInviteRequestDto, TeamAcceptInvitationRequestDto, - TeamMemberDto, TeamsSearchQueryDto, PublicTeamsListItemDto, PublicTeamsDetailItemDto + TeamMemberDto, TeamsSearchQueryDto, PublicTeamsListItemDto, PublicTeamsDetailItemDto, + AdminTeamsListItemDto, AdminTeamsDetailItemDto, PermissionsEnum }; use axum::extract::Path; use axum::http::HeaderMap; @@ -363,4 +364,102 @@ pub async fn get_public_team_by_id( Path(id): Path, ) -> impl IntoResponse { TeamsService::get_team_by_id(&state, id).await +#[utoipa::path( + get, + security( + ("Bearer" = []) + ), + path = "/v1/teams/admin", + params( + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page"), + ("search" = Option, Query, description = "Search keyword"), + ("sort_by" = Option, Query, description = "Sort by field"), + ("order" = Option, Query, description = "Order ASC or DESC"), + ("filter" = Option, Query, description = "Filter value"), + ("filter_by" = Option, Query, description = "Field to filter by"), + ), + responses( + (status = 200, description = "Get admin team list", body = ResponseListSuccessDto>) + ), + tag = "Teams - Admin" +)] +pub async fn get_admin_team_list( + headers: HeaderMap, + Extension(state): Extension, + axum::extract::Query(meta): axum::extract::Query, +) -> impl IntoResponse { + match permissions_guard( + headers, + Extension(state), + vec![PermissionsEnum::ReadListTeams.to_string()], + ) + .await + { + Ok((_claims, state)) => TeamsService::get_admin_team_list(&state, meta).await, + Err(response) => response, + } +} + +#[utoipa::path( + get, + security( + ("Bearer" = []) + ), + path = "/v1/teams/admin/{id}", + params( + ("id" = String, Path, description = "Team ID") + ), + responses( + (status = 200, description = "Get admin team by ID", body = ResponseSuccessDto) + ), + tag = "Teams - Admin" +)] +pub async fn get_admin_team_by_id( + headers: HeaderMap, + Extension(state): Extension, + Path(id): Path, +) -> impl IntoResponse { + match permissions_guard( + headers, + Extension(state), + vec![PermissionsEnum::ReadDetailTeams.to_string()], + ) + .await + { + Ok((_claims, state)) => TeamsService::get_admin_team_by_id(&state, id).await, + Err(response) => response, + } +} + +#[utoipa::path( + get, + security( + ("Bearer" = []) + ), + path = "/v1/teams/admin/{id}/members", + params( + ("id" = String, Path, description = "Team ID") + ), + responses( + (status = 200, description = "Get admin team members", body = ResponseSuccessDto>) + ), + tag = "Teams - Admin" +)] +pub async fn get_admin_team_members( + headers: HeaderMap, + Extension(state): Extension, + Path(id): Path, +) -> impl IntoResponse { + match permissions_guard( + headers, + Extension(state), + vec![PermissionsEnum::ReadDetailTeams.to_string()], + ) + .await + { + Ok((_claims, state)) => TeamsService::get_admin_team_members(&state, id).await, + Err(response) => response, + } +} } \ No newline at end of file diff --git a/imphnen-iam/src/v1/teams/teams_dto.rs b/imphnen-iam/src/v1/teams/teams_dto.rs index e626bb9..14a2054 100644 --- a/imphnen-iam/src/v1/teams/teams_dto.rs +++ b/imphnen-iam/src/v1/teams/teams_dto.rs @@ -266,28 +266,163 @@ impl TeamsDetailQueryDto { } impl TeamsListQueryDto { - pub fn from(self) -> TeamsListItemDto { - TeamsListItemDto { - id: self.id.id.to_raw(), - name: self.name, - description: self.description, - leader: TeamMemberDto { - id: String::new(), - user_id: self.leader_id.id.to_raw(), - fullname: String::new(), - email: None, - avatar: None, - role: "leader".to_string(), - skills: None, - joined_at: self.created_at.clone(), - }, - is_open: self.is_open, - current_member_count: 1, - max_members: self.max_members, - skills_required: self.skills_required, - location: self.location, - avatar: self.avatar, - created_at: self.created_at, - } - } + pub fn from(self) -> TeamsListItemDto { + TeamsListItemDto { + id: self.id.id.to_raw(), + name: self.name, + description: self.description, + leader: TeamMemberDto { + id: String::new(), + user_id: self.leader_id.id.to_raw(), + fullname: String::new(), + email: None, + avatar: None, + role: "leader".to_string(), + skills: None, + joined_at: self.created_at.clone(), + }, + is_open: self.is_open, + current_member_count: 1, + max_members: self.max_members, + skills_required: self.skills_required, + location: self.location, + avatar: self.avatar, + created_at: self.created_at, + } + } + + pub fn to_admin_list_dto(self) -> AdminTeamsListItemDto { + let members = vec![]; + AdminTeamsListItemDto { + id: self.id.id.to_raw(), + name: self.name, + description: self.description, + leader: TeamMemberDto { + id: String::new(), + user_id: self.leader_id.id.to_raw(), + fullname: String::new(), + email: None, + avatar: None, + role: "leader".to_string(), + skills: None, + joined_at: self.created_at.clone(), + }, + is_open: self.is_open, + current_member_count: members.len() as i32 + 1, + max_members: self.max_members, + skills_required: self.skills_required, + location: self.location, + avatar: self.avatar, + website_url: None, + github_url: None, + is_active: true, + is_deleted: false, + created_at: self.created_at, + } + } +} +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct AdminTeamsListItemDto { + pub id: String, + pub name: String, + pub description: Option, + pub leader: TeamMemberDto, + pub is_open: bool, + pub current_member_count: i32, + pub max_members: Option, + pub skills_required: Option>, + pub location: Option, + pub avatar: Option, + pub website_url: Option, + pub github_url: Option, + pub is_active: bool, + pub is_deleted: bool, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct AdminTeamsDetailItemDto { + pub id: String, + pub name: String, + pub description: Option, + pub leader: TeamMemberDto, + pub is_open: bool, + pub max_members: Option, + pub current_member_count: i32, + pub skills_required: Option>, + pub location: Option, + pub avatar: Option, + pub website_url: Option, + pub github_url: Option, + pub members: Vec, + pub is_active: bool, + pub is_deleted: bool, + pub created_at: String, + pub updated_at: String, +} + +impl TeamsListQueryDto { + pub fn to_admin_list_dto(self) -> AdminTeamsListItemDto { + let members = vec![]; + AdminTeamsListItemDto { + id: self.id.id.to_raw(), + name: self.name, + description: self.description, + leader: TeamMemberDto { + id: String::new(), + user_id: self.leader_id.id.to_raw(), + fullname: String::new(), + email: None, + avatar: None, + role: "leader".to_string(), + skills: None, + joined_at: self.created_at.clone(), + }, + is_open: self.is_open, + current_member_count: members.len() as i32 + 1, + max_members: self.max_members, + skills_required: self.skills_required, + location: self.location, + avatar: self.avatar, + website_url: None, + github_url: None, + is_active: true, + is_deleted: false, + created_at: self.created_at, + } + } +} + +impl TeamsDetailQueryDto { + pub fn to_admin_detail_dto(self, members: Vec) -> AdminTeamsDetailItemDto { + AdminTeamsDetailItemDto { + id: self.id.id.to_raw(), + name: self.name, + description: self.description, + leader: TeamMemberDto { + id: String::new(), + user_id: self.leader_id.id.to_raw(), + fullname: String::new(), + email: None, + avatar: None, + role: "leader".to_string(), + skills: None, + joined_at: self.created_at.clone(), + }, + is_open: self.is_open, + current_member_count: members.len() as i32 + 1, + max_members: self.max_members, + skills_required: self.skills_required, + location: self.location, + avatar: self.avatar, + website_url: self.website_url, + github_url: self.github_url, + members, + is_active: self.is_active, + is_deleted: self.is_deleted, + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} } \ No newline at end of file diff --git a/imphnen-iam/src/v1/teams/teams_service.rs b/imphnen-iam/src/v1/teams/teams_service.rs index ca22797..2c79705 100644 --- a/imphnen-iam/src/v1/teams/teams_service.rs +++ b/imphnen-iam/src/v1/teams/teams_service.rs @@ -2,7 +2,8 @@ use super::{ TeamsCreateRequestDto, TeamsUpdateRequestDto, TeamInviteRequestDto, TeamAcceptInvitationRequestDto, TeamsDetailItemDto, TeamMemberDto, TeamsRepository, TeamsSchema, TeamMembersSchema, - TeamInvitationsSchema, TeamsSearchQueryDto, PublicTeamsListItemDto, PublicTeamsDetailItemDto + TeamInvitationsSchema, TeamsSearchQueryDto, PublicTeamsListItemDto, PublicTeamsDetailItemDto, + AdminTeamsListItemDto, AdminTeamsDetailItemDto }; use crate::{ AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto, @@ -33,6 +34,9 @@ pub trait TeamsServiceTrait: Send + Sync + 'static { fn get_team_members(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin + Send>>; fn leave_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin + Send>>; fn search_teams(state: &AppState, search_params: TeamsSearchQueryDto) -> Pin + Send>>; + fn get_admin_team_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>>; + fn get_admin_team_by_id(state: &AppState, id: String) -> Pin + Send>>; + fn get_admin_team_members(state: &AppState, team_id: String) -> Pin + Send>>; } #[derive(Clone)] @@ -654,6 +658,109 @@ impl TeamsServiceTrait for TeamsService { }; success_list_response(response) } + fn get_admin_team_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + let repo = TeamsRepository::new(&state); + match repo.query_team_list(meta).await { + Ok(data) => { + let response = ResponseListSuccessDto { + data: data.data.into_iter().map(|team| team.to_admin_list_dto()).collect(), + meta: data.meta, + }; + success_list_response(response) + } + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + } + }) + } + + fn get_admin_team_by_id(state: &AppState, id: String) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + if Uuid::parse_str(&id).is_err() { + return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); + } + let repo = TeamsRepository::new(&state); + let thing_id = make_thing_from_enum(ResourceEnum::Teams, &id); + match repo.query_team_by_id(&thing_id).await { + Ok(team) if !team.is_deleted => { + let members = repo.query_team_members(&team.id).await.unwrap_or_default(); + let mut member_dtos = Vec::new(); + + for member in members { + match Self::get_user_info_with_privacy( + &member.user_id.id.to_raw(), + "system", // Admin context - show all sensitive data + true, // Admin context - always show sensitive data + &state, + ).await { + Ok(mut member_dto) => { + member_dto.role = member.role; + member_dto.joined_at = member.joined_at; + member_dtos.push(member_dto); + } + Err(_) => continue, + } + } + + // Add leader with full sensitive info + match Self::get_user_info_with_privacy( + &team.leader_id.id.to_raw(), + "system", + true, + &state, + ).await { + Ok(mut leader_dto) => { + leader_dto.role = "leader".to_string(); + member_dtos.insert(0, leader_dto); + } + Err(_) => {} + } + + let team_dto = team.to_admin_detail_dto(member_dtos); + success_response(ResponseSuccessDto { data: team_dto }) + } + Ok(_) => common_response(StatusCode::NOT_FOUND, "Team not found"), + Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), + } + }) + } + + fn get_admin_team_members(state: &AppState, team_id: String) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + if Uuid::parse_str(&team_id).is_err() { + return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); + } + let repo = TeamsRepository::new(&state); + let thing_id = make_thing_from_enum(ResourceEnum::Teams, &team_id); + + let members = match repo.query_team_members(&thing_id).await { + Ok(members) => members, + Err(e) => return common_response(StatusCode::BAD_REQUEST, &e.to_string()), + }; + + let mut member_dtos = Vec::new(); + for member in members { + match Self::get_user_info_with_privacy( + &member.user_id.id.to_raw(), + "system", // Admin context - show all sensitive data + true, // Admin context - always show sensitive data + &state, + ).await { + Ok(mut member_dto) => { + member_dto.role = member.role; + member_dto.joined_at = member.joined_at; + member_dtos.push(member_dto); + } + Err(_) => continue, + } + } + + success_response(ResponseSuccessDto { data: member_dtos }) + }) + } Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), } }) diff --git a/tests/src/iam/teams/admin_teams_controller_tests.rs b/tests/src/iam/teams/admin_teams_controller_tests.rs new file mode 100644 index 0000000..a759b5a --- /dev/null +++ b/tests/src/iam/teams/admin_teams_controller_tests.rs @@ -0,0 +1,326 @@ +use crate::get_app_state; +use axum::{http::HeaderMap, response::Response}; +use imphnen_iam::{ + AppState, Claims, PermissionsEnum, ResponseSuccessDto, ResponseListSuccessDto, + AdminTeamsListItemDto, AdminTeamsDetailItemDto, TeamMemberDto +}; +use imphnen_libs::jsonwebtoken::{encode, Header}; +use imphnen_utils::make_thing_from_enum; +use serde_json::json; +use std::sync::Arc; +use uuid::Uuid; +use chrono::Utc; + +#[tokio::test] +async fn test_admin_team_endpoints_sensitive_data_exposure() { + let app_state = get_app_state().await; + let repo = imphnen_iam::TeamsRepository::new(&app_state); + + // Create test data + let team_id = Uuid::new_v4().to_string(); + let leader_id = Uuid::new_v4().to_string(); + let member_id_1 = Uuid::new_v4().to_string(); + let member_id_2 = Uuid::new_v4().to_string(); + + // Create test team + let team = imphnen_iam::TeamsSchema { + id: make_thing_from_enum(imphnen_libs::ResourceEnum::Teams, &team_id), + name: "Admin Test Team".to_string(), + description: Some("Test team for admin endpoints".to_string()), + leader_id: make_thing_from_enum(imphnen_libs::ResourceEnum::Users, &leader_id), + is_open: true, + max_members: Some(10), + skills_required: Some(vec!["Rust".to_string(), "Backend".to_string()]), + location: Some("Remote".to_string()), + avatar: Some("https://example.com/avatar.jpg".to_string()), + website_url: Some("https://example.com".to_string()), + github_url: Some("https://github.com/example".to_string()), + is_active: true, + is_deleted: false, + created_at: Utc::now().to_rfc3339(), + updated_at: Utc::now().to_rfc3339(), + }; + + let create_result = repo.query_create_team(team.clone()).await; + assert!(create_result.is_ok(), "Failed to create test team"); + + // Create test members + let member_1 = imphnen_iam::TeamMembersSchema { + id: make_thing_from_enum(imphnen_libs::ResourceEnum::TeamMembers, &Uuid::new_v4().to_string()), + team_id: make_thing_from_enum(imphnen_libs::ResourceEnum::Teams, &team_id), + user_id: make_thing_from_enum(imphnen_libs::ResourceEnum::Users, &member_id_1), + role: "member".to_string(), + joined_at: Utc::now().to_rfc3339(), + is_active: true, + }; + + let member_2 = imphnen_iam::TeamMembersSchema { + id: make_thing_from_enum(imphnen_libs::ResourceEnum::TeamMembers, &Uuid::new_v4().to_string()), + team_id: make_thing_from_enum(imphnen_libs::ResourceEnum::Teams, &team_id), + user_id: make_thing_from_enum(imphnen_libs::ResourceEnum::Users, &member_id_2), + role: "contributor".to_string(), + joined_at: Utc::now().to_rfc3339(), + is_active: true, + }; + + let add_member_result_1 = repo.query_add_team_member(member_1).await; + let add_member_result_2 = repo.query_add_team_member(member_2).await; + + assert!(add_member_result_1.is_ok(), "Failed to add test member 1"); + assert!(add_member_result_2.is_ok(), "Failed to add test member 2"); + + // Create admin user with proper permissions + let admin_claims = Claims { + user_id: "admin_user_123".to_string(), + email: "admin@example.com".to_string(), + fullname: "Admin User".to_string(), + avatar: None, + role: imphnen_iam::RoleSchema { + id: make_thing_from_enum(imphnen_libs::ResourceEnum::Roles, "admin_role"), + name: "Admin".to_string(), + permissions: vec![ + imphnen_iam::PermissionSchema { + id: make_thing_from_enum(imphnen_libs::ResourceEnum::Permissions, "read_list_teams"), + name: PermissionsEnum::ReadListTeams.to_string(), + }, + imphnen_iam::PermissionSchema { + id: make_thing_from_enum(imphnen_libs::ResourceEnum::Permissions, "read_detail_teams"), + name: PermissionsEnum::ReadDetailTeams.to_string(), + }, + ], + }, + exp: 1_000_000_000, + iat: 0, + }; + + let admin_token = encode(&Header::default(), &admin_claims, &app_state.jwt_secret).unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert("Authorization", format!("Bearer {}", admin_token).parse().unwrap()); + + // Test 1: Admin team list endpoint should expose sensitive fields + let response = imphnen_iam::teams_controller::get_admin_team_list( + headers.clone(), + axum::extract::Extension(app_state.clone()), + axum::extract::Query(imphnen_iam::MetaRequestDto { + page: Some(1), + per_page: Some(10), + search: None, + sort_by: None, + order: None, + filter: None, + filter_by: None, + }), + ).await; + + assert!(response.status().is_success(), "Admin team list should return success"); + + let response_body = match response.into_body().into_string().await { + Ok(body) => body, + Err(e) => panic!("Failed to read response body: {}", e), + }; + + let response_json: ResponseListSuccessDto> = + serde_json::from_str(&response_body).unwrap(); + + // Verify sensitive fields are present in admin response + assert!(response_json.data.iter().any(|team| { + team.is_deleted == false && // Should show is_deleted field + team.is_active == true && // Should show is_active field + team.website_url.is_some() && // Should show website_url + team.github_url.is_some() // Should show github_url + }), "Admin team list should expose sensitive fields"); + + // Test 2: Admin team detail endpoint should expose sensitive fields and full member info + let response = imphnen_iam::teams_controller::get_admin_team_by_id( + headers.clone(), + axum::extract::Extension(app_state.clone()), + axum::extract::Path(team_id.clone()), + ).await; + + assert!(response.status().is_success(), "Admin team detail should return success"); + + let response_body = match response.into_body().into_string().await { + Ok(body) => body, + Err(e) => panic!("Failed to read response body: {}", e), + }; + + let response_json: ResponseSuccessDto = + serde_json::from_str(&response_body).unwrap(); + + let admin_team = response_json.data; + + // Verify sensitive fields are present + assert!(admin_team.is_deleted == false, "Admin team detail should show is_deleted field"); + assert!(admin_team.is_active == true, "Admin team detail should show is_active field"); + assert!(admin_team.website_url.is_some(), "Admin team detail should show website_url"); + assert!(admin_team.github_url.is_some(), "Admin team detail should show github_url"); + assert!(admin_team.members.len() >= 2, "Admin team detail should show all members"); + + // Verify all members have sensitive info (email should be present for admins) + let has_all_member_info = admin_team.members.iter().all(|member| { + member.email.is_some() && // Admin should see member emails + member.fullname != "" && // Admin should see fullnames + member.role != "" // Admin should see roles + }); + + assert!(has_all_member_info, "Admin team detail should expose all member sensitive information"); + + // Test 3: Admin team members endpoint should expose sensitive info + let response = imphnen_iam::teams_controller::get_admin_team_members( + headers, + axum::extract::Extension(app_state), + axum::extract::Path(team_id), + ).await; + + assert!(response.status().is_success(), "Admin team members should return success"); + + let response_body = match response.into_body().into_string().await { + Ok(body) => body, + Err(e) => panic!("Failed to read response body: {}", e), + }; + + let response_json: ResponseSuccessDto> = + serde_json::from_str(&response_body).unwrap(); + + let admin_members = response_json.data; + + // Verify all members have sensitive info + let has_all_member_info = admin_members.iter().all(|member| { + member.email.is_some() && // Admin should see member emails + member.fullname != "" && // Admin should see fullnames + member.role != "" // Admin should see roles + }); + + assert!(has_all_member_info, "Admin team members endpoint should expose all member sensitive information"); + + // Clean up + let _ = repo.query_delete_team(team_id).await; +} + +#[tokio::test] +async fn test_admin_team_endpoints_permission_guard() { + let app_state = get_app_state().await; + + // Create test team first + let team_id = Uuid::new_v4().to_string(); + let leader_id = Uuid::new_v4().to_string(); + + let team = imphnen_iam::TeamsSchema { + id: make_thing_from_enum(imphnen_libs::ResourceEnum::Teams, &team_id), + name: "Permission Test Team".to_string(), + description: Some("Test team for permission checks".to_string()), + leader_id: make_thing_from_enum(imphnen_libs::ResourceEnum::Users, &leader_id), + is_open: true, + max_members: Some(10), + skills_required: Some(vec!["Rust".to_string()]), + location: Some("Remote".to_string()), + avatar: None, + website_url: None, + github_url: None, + is_active: true, + is_deleted: false, + created_at: Utc::now().to_rfc3339(), + updated_at: Utc::now().to_rfc3339(), + }; + + let repo = imphnen_iam::TeamsRepository::new(&app_state); + let create_result = repo.query_create_team(team).await; + assert!(create_result.is_ok(), "Failed to create test team"); + + // Create regular user without admin permissions + let regular_claims = Claims { + user_id: "regular_user_123".to_string(), + email: "user@example.com".to_string(), + fullname: "Regular User".to_string(), + avatar: None, + role: imphnen_iam::RoleSchema { + id: make_thing_from_enum(imphnen_libs::ResourceEnum::Roles, "user_role"), + name: "User".to_string(), + permissions: vec![], // No admin permissions + }, + exp: 1_000_000_000, + iat: 0, + }; + + let regular_token = encode(&Header::default(), ®ular_claims, &app_state.jwt_secret).unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert("Authorization", format!("Bearer {}", regular_token).parse().unwrap()); + + // Test that regular user gets forbidden for admin endpoints + let response = imphnen_iam::teams_controller::get_admin_team_list( + headers, + axum::extract::Extension(app_state), + axum::extract::Query(imphnen_iam::MetaRequestDto { + page: Some(1), + per_page: Some(10), + search: None, + sort_by: None, + order: None, + filter: None, + filter_by: None, + }), + ).await; + + assert_eq!(response.status().as_u16(), 403, "Regular user should get forbidden for admin endpoints"); + + // Clean up + let _ = repo.query_delete_team(team_id).await; +} + +#[tokio::test] +async fn test_admin_team_dto_conversion_edge_cases() { + // Test DTO conversion with empty member list + let team_query_dto = imphnen_iam::TeamsDetailQueryDto { + id: make_thing_from_enum(imphnen_libs::ResourceEnum::Teams, &Uuid::new_v4().to_string()), + name: "Test Team".to_string(), + description: Some("Test description".to_string()), + leader_id: make_thing_from_enum(imphnen_libs::ResourceEnum::Users, &Uuid::new_v4().to_string()), + is_open: true, + max_members: Some(10), + skills_required: Some(vec!["Rust".to_string()]), + location: Some("Remote".to_string()), + avatar: Some("https://example.com/avatar.jpg".to_string()), + website_url: Some("https://example.com".to_string()), + github_url: Some("https://github.com/example".to_string()), + is_active: true, + is_deleted: false, + created_at: Utc::now().to_rfc3339(), + updated_at: Utc::now().to_rfc3339(), + }; + + let admin_dto = team_query_dto.to_admin_detail_dto(vec![]); // Empty member list + + // Should handle empty member list gracefully + assert_eq!(admin_dto.members.len(), 0, "Should handle empty member list"); + assert_eq!(admin_dto.current_member_count, 1, "Current member count should be 1 (leader only)"); + assert_eq!(admin_dto.is_deleted, false, "Should preserve is_deleted field"); + assert_eq!(admin_dto.is_active, true, "Should preserve is_active field"); + assert!(admin_dto.website_url.is_some(), "Should preserve website_url"); + assert!(admin_dto.github_url.is_some(), "Should preserve github_url"); + + // Test DTO conversion with deleted team + let deleted_team_query_dto = imphnen_iam::TeamsDetailQueryDto { + id: make_thing_from_enum(imphnen_libs::ResourceEnum::Teams, &Uuid::new_v4().to_string()), + name: "Deleted Team".to_string(), + description: Some("This team is deleted".to_string()), + leader_id: make_thing_from_enum(imphnen_libs::ResourceEnum::Users, &Uuid::new_v4().to_string()), + is_open: true, + max_members: Some(10), + skills_required: Some(vec!["Rust".to_string()]), + location: Some("Remote".to_string()), + avatar: Some("https://example.com/avatar.jpg".to_string()), + website_url: Some("https://example.com".to_string()), + github_url: Some("https://github.com/example".to_string()), + is_active: false, + is_deleted: true, // Mark as deleted + created_at: Utc::now().to_rfc3339(), + updated_at: Utc::now().to_rfc3339(), + }; + + let deleted_admin_dto = deleted_team_query_dto.to_admin_detail_dto(vec![]); + + assert_eq!(deleted_admin_dto.is_deleted, true, "Should preserve is_deleted field for deleted teams"); + assert_eq!(deleted_admin_dto.is_active, false, "Should preserve is_active field for deleted teams"); +} \ No newline at end of file