feat: Thêm các controller và query quản trị cho Storage Service, cải tiến quản lý cấp độ thành viên với các bài kiểm tra mới, và cập nhật các controller cùng chính sách ủy quyền

This commit is contained in:
Ho Ngoc Hai
2026-01-15 19:23:31 +07:00
parent 0358ca255a
commit 85bd4d6f58
37 changed files with 2204 additions and 92 deletions

View File

@@ -24,6 +24,9 @@ STORAGE_DATABASE_URL="Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.te
# Social Service Database (if separate)
SOCIAL_DATABASE_URL="Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=social_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require"
# Wallet Service Database
WALLET_DATABASE_URL="Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=wallet_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require"
# =============================================================================
# REDIS CACHE / BỘ NHỚ ĐỆM REDIS
# =============================================================================

View File

@@ -261,6 +261,52 @@ services:
- "traefik.http.services.iam-service-net.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.iam-service-net.loadbalancer.healthcheck.interval=10s"
# Wallet Service .NET - Wallet & PPoint Management
wallet-service-net:
build:
context: ../../services/wallet-service-net
dockerfile: Dockerfile
image: goodgo/wallet-service-net:latest
container_name: wallet-service-net-local
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_URLS=http://+:8080
# EN: Database - Neon PostgreSQL
# VI: Cơ sở dữ liệu - Neon PostgreSQL
- ConnectionStrings__DefaultConnection=Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=wallet_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require
# EN: IAM Service Communication
# VI: Giao tiếp IAM Service
- IamService__BaseUrl=http://iam-service-net:8080
- IamService__ServiceName=wallet-service
# EN: JWT Configuration
# VI: Cấu hình JWT
- Jwt__Authority=http://iam-service-net:8080
- Jwt__Audience=goodgo-api
- Jwt__RequireHttpsMetadata=false
ports:
- "5004:8080"
depends_on:
iam-service-net:
condition: service_healthy
traefik:
condition: service_started
networks:
- microservices-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "traefik.enable=true"
- "traefik.http.routers.wallet-service.rule=PathPrefix(`/api/v1/wallets`) || PathPrefix(`/api/v1/points`)"
- "traefik.http.routers.wallet-service.entrypoints=web"
- "traefik.http.services.wallet-service.loadbalancer.server.port=8080"
- "traefik.http.services.wallet-service.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.wallet-service.loadbalancer.healthcheck.interval=10s"
# ===========================================================================
# OBSERVABILITY (Optional - Uncomment to enable)

View File

@@ -468,6 +468,106 @@ graph TD
style SOCIAL fill:#e67e22,stroke:#d35400,color:#fff
```
## Authorization Policies
### Overview
IAM Service uses **Policy-Based Authorization** to protect API endpoints:
```mermaid
graph TB
subgraph "Request Flow"
REQ[HTTP Request] --> AUTH[Authentication<br/>JWT Bearer]
AUTH --> POLICY[Policy Check]
POLICY --> HANDLER[Authorization Handler]
end
subgraph "Policies"
SUPER[RequireSuperAdmin]
ADMIN[RequireAdmin]
AUDITOR[RequireAuditor]
OWNER[OwnerOrAdmin]
end
subgraph "Roles"
R_SUPER[SuperAdmin]
R_ADMIN[Admin]
R_AUDITOR[Auditor]
R_USER[User]
end
HANDLER --> SUPER
HANDLER --> ADMIN
HANDLER --> AUDITOR
HANDLER --> OWNER
SUPER --> R_SUPER
ADMIN --> R_SUPER
ADMIN --> R_ADMIN
AUDITOR --> R_SUPER
AUDITOR --> R_ADMIN
AUDITOR --> R_AUDITOR
OWNER --> R_SUPER
OWNER --> R_ADMIN
OWNER --> R_USER
style SUPER fill:#c0392b,stroke:#922b21,color:#fff
style ADMIN fill:#e74c3c,stroke:#c0392b,color:#fff
style AUDITOR fill:#9b59b6,stroke:#7d3c98,color:#fff
style OWNER fill:#3498db,stroke:#2980b9,color:#fff
```
### Policy Definitions
| Policy | Required Roles | Description |
|--------|---------------|-------------|
| `RequireSuperAdmin` | SuperAdmin | Highest level - PAM, system config |
| `RequireAdmin` | SuperAdmin, Admin | User, role, organization management |
| `RequireAuditor` | SuperAdmin, Admin, Auditor | Audit logs, compliance reports |
| `OwnerOrAdmin` | Admin or resource owner | User self-service profile |
### Authorization Flow
```mermaid
sequenceDiagram
participant Client
participant Controller
participant AuthorizationService
participant PolicyHandler
participant ClaimsPrincipal
Client->>Controller: Request with JWT
Controller->>AuthorizationService: Authorize(Policy)
AuthorizationService->>PolicyHandler: EvaluatePolicy()
PolicyHandler->>ClaimsPrincipal: GetRoles()
ClaimsPrincipal-->>PolicyHandler: ["Admin", "User"]
alt Role Match
PolicyHandler-->>AuthorizationService: Success
AuthorizationService-->>Controller: Authorized
Controller-->>Client: 200 OK
else Role Not Match
PolicyHandler-->>AuthorizationService: Fail
AuthorizationService-->>Controller: Forbidden
Controller-->>Client: 403 Forbidden
end
```
### Controller Policy Mapping
| Controller | Policy | Endpoints |
|------------|--------|-----------|
| UsersController | RequireAdmin / OwnerOrAdmin | GET /users (Admin), GET/PUT /{id} (Owner or Admin) |
| RolesController | RequireAdmin | All endpoints |
| OrganizationsController | RequireAdmin | All endpoints |
| GroupsController | RequireAdmin | All endpoints |
| AccessRequestsController | RequireAdmin | All endpoints |
| AccessReviewsController | RequireAdmin | All endpoints |
| PrivilegedAccessController | RequireSuperAdmin | All endpoints (most sensitive) |
| AuditController | RequireAuditor | GET /audit/logs |
| ComplianceController | RequireAuditor | All endpoints |
| VerificationsController | RequireAdmin | All endpoints |
## Email Verification Flow
```mermaid

View File

@@ -92,6 +92,34 @@ dotnet ef database update \
## API Endpoints
### Authorization Policies
> **Note:** All API endpoints require authentication (Bearer JWT Token).
> Some endpoints require specific roles as shown below.
| Policy | Required Role | Applied To |
|--------|---------------|------------|
| `RequireSuperAdmin` | SuperAdmin | PAM (Privileged Access Management) |
| `RequireAdmin` | Admin, SuperAdmin | User/Role/Group/Organization management |
| `RequireAuditor` | Auditor, Admin, SuperAdmin | Audit logs, Compliance reports |
| `OwnerOrAdmin` | Owner or Admin | User self-service profile management |
**Authorization by Controller:**
| Controller | Policy | Description |
|------------|--------|-------------|
| Users (GET /users, DELETE) | RequireAdmin | List users, delete user |
| Users (GET/PUT /{id}) | OwnerOrAdmin | User access own profile or Admin access any |
| Roles | RequireAdmin | Role management |
| Organizations | RequireAdmin | Organization management |
| Groups | RequireAdmin | Group management |
| Access Requests | RequireAdmin | Access request workflow |
| Access Reviews | RequireAdmin | Periodic access review |
| Privileged Access | RequireSuperAdmin | PAM - most sensitive |
| Audit | RequireAuditor | View audit logs |
| Compliance | RequireAuditor | Compliance reports |
| Verifications | RequireAdmin | Identity verification |
### Authentication (`/api/v1/auth`)
| Method | Endpoint | Description | Auth |

View File

@@ -468,6 +468,143 @@ graph TD
style SOCIAL fill:#e67e22,stroke:#d35400,color:#fff
```
## Authorization Policies
### Tổng Quan Authorization
IAM Service sử dụng **Policy-Based Authorization** để phân quyền API endpoints:
```mermaid
graph TB
subgraph "Request Flow"
REQ[HTTP Request] --> AUTH[Authentication<br/>JWT Bearer]
AUTH --> POLICY[Policy Check]
POLICY --> HANDLER[Authorization Handler]
end
subgraph "Policies"
SUPER[RequireSuperAdmin]
ADMIN[RequireAdmin]
AUDITOR[RequireAuditor]
OWNER[OwnerOrAdmin]
end
subgraph "Roles"
R_SUPER[SuperAdmin]
R_ADMIN[Admin]
R_AUDITOR[Auditor]
R_USER[User]
end
HANDLER --> SUPER
HANDLER --> ADMIN
HANDLER --> AUDITOR
HANDLER --> OWNER
SUPER --> R_SUPER
ADMIN --> R_SUPER
ADMIN --> R_ADMIN
AUDITOR --> R_SUPER
AUDITOR --> R_ADMIN
AUDITOR --> R_AUDITOR
OWNER --> R_SUPER
OWNER --> R_ADMIN
OWNER --> R_USER
style SUPER fill:#c0392b,stroke:#922b21,color:#fff
style ADMIN fill:#e74c3c,stroke:#c0392b,color:#fff
style AUDITOR fill:#9b59b6,stroke:#7d3c98,color:#fff
style OWNER fill:#3498db,stroke:#2980b9,color:#fff
```
### Bảng Policies
| Policy | Required Roles | Mô tả |
|--------|---------------|-------|
| `RequireSuperAdmin` | SuperAdmin | Quyền cao nhất - PAM, system config |
| `RequireAdmin` | SuperAdmin, Admin | Quản lý users, roles, organizations |
| `RequireAuditor` | SuperAdmin, Admin, Auditor | Xem audit logs, compliance reports |
| `OwnerOrAdmin` | Admin hoặc chính user | User tự quản lý profile |
### Luồng Authorization
```mermaid
sequenceDiagram
participant Client
participant Controller
participant AuthorizationService
participant PolicyHandler
participant ClaimsPrincipal
Client->>Controller: Request với JWT
Controller->>AuthorizationService: Authorize(Policy)
AuthorizationService->>PolicyHandler: EvaluatePolicy()
PolicyHandler->>ClaimsPrincipal: GetRoles()
ClaimsPrincipal-->>PolicyHandler: ["Admin", "User"]
alt Role Match
PolicyHandler-->>AuthorizationService: Success
AuthorizationService-->>Controller: Authorized
Controller-->>Client: 200 OK
else Role Not Match
PolicyHandler-->>AuthorizationService: Fail
AuthorizationService-->>Controller: Forbidden
Controller-->>Client: 403 Forbidden
end
```
### Áp Dụng Theo Controller
| Controller | Policy | Endpoints |
|------------|--------|-----------|
| UsersController | RequireAdmin / OwnerOrAdmin | GET /users (Admin), GET/PUT /{id} (Owner or Admin) |
| RolesController | RequireAdmin | Tất cả endpoints |
| OrganizationsController | RequireAdmin | Tất cả endpoints |
| GroupsController | RequireAdmin | Tất cả endpoints |
| AccessRequestsController | RequireAdmin | Tất cả endpoints |
| AccessReviewsController | RequireAdmin | Tất cả endpoints |
| PrivilegedAccessController | RequireSuperAdmin | Tất cả endpoints (nhạy cảm nhất) |
| AuditController | RequireAuditor | GET /audit/logs |
| ComplianceController | RequireAuditor | Tất cả endpoints |
| VerificationsController | RequireAdmin | Tất cả endpoints |
### Custom Authorization Handler: OwnerOrAdmin
```csharp
// EN: OwnerOrAdmin allows:
// 1. Admin/SuperAdmin - always allowed
// 2. User accessing their own resource (userId in route matches current user)
// VI: OwnerOrAdmin cho phép:
// 1. Admin/SuperAdmin - luôn được phép
// 2. User truy cập resource của chính mình
public class OwnerOrAdminHandler : AuthorizationHandler<OwnerOrAdminRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OwnerOrAdminRequirement requirement)
{
// Admin/SuperAdmin always allowed
if (context.User.IsInRole("Admin") || context.User.IsInRole("SuperAdmin"))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
// Check if user accessing own resource
var currentUserId = context.User.FindFirst("sub")?.Value;
var routeUserId = httpContext.GetRouteValue("id")?.ToString();
if (currentUserId == routeUserId)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
```
## Luồng Xác Thực Email
```mermaid

View File

@@ -105,6 +105,34 @@ dotnet ef migrations list \
## API Endpoints
### Authorization Policies (Phân Quyền API)
> **Lưu ý:** Tất cả API endpoints yêu cầu xác thực (Bearer JWT Token).
> Một số endpoints yêu cầu role cụ thể như bảng dưới đây.
| Policy | Required Role | Áp Dụng Cho |
|--------|---------------|-------------|
| `RequireSuperAdmin` | SuperAdmin | PAM (Privileged Access Management) |
| `RequireAdmin` | Admin, SuperAdmin | User/Role/Group/Organization management |
| `RequireAuditor` | Auditor, Admin, SuperAdmin | Audit logs, Compliance reports |
| `OwnerOrAdmin` | Owner hoặc Admin | User tự quản lý profile của mình |
**Chi tiết phân quyền theo Controller:**
| Controller | Policy | Mô tả |
|------------|--------|-------|
| Users (GET /users, DELETE) | RequireAdmin | Xem danh sách users, xóa user |
| Users (GET/PUT /{id}) | OwnerOrAdmin | User xem/sửa profile mình hoặc Admin |
| Roles | RequireAdmin | Quản lý roles |
| Organizations | RequireAdmin | Quản lý tổ chức |
| Groups | RequireAdmin | Quản lý nhóm |
| Access Requests | RequireAdmin | Xử lý yêu cầu truy cập |
| Access Reviews | RequireAdmin | Xem xét truy cập định kỳ |
| Privileged Access | RequireSuperAdmin | PAM - nhạy cảm nhất |
| Audit | RequireAuditor | Xem audit logs |
| Compliance | RequireAuditor | Báo cáo tuân thủ |
| Verifications | RequireAdmin | Xác thực danh tính |
### Authentication (`/api/v1/auth`)
| Method | Endpoint | Mô Tả | Auth |

View File

@@ -17,7 +17,8 @@ namespace IamService.API.Controllers;
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/access-requests")]
[Authorize(AuthenticationSchemes = "Bearer")]
[SwaggerTag("Access request management - requires authentication")]
[Authorize(Policy = "RequireAdmin")]
[SwaggerTag("Access request management - requires Admin role")]
public class AccessRequestsController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -12,7 +12,8 @@ namespace IamService.API.Controllers;
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/access-reviews")]
[Authorize(AuthenticationSchemes = "Bearer")]
[SwaggerTag("Access review management - periodic access certification")]
[Authorize(Policy = "RequireAdmin")]
[SwaggerTag("Access review management - requires Admin role")]
public class AccessReviewsController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -12,7 +12,8 @@ namespace IamService.API.Controllers;
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/audit")]
[Authorize(AuthenticationSchemes = "Bearer")]
[SwaggerTag("Audit log management")]
[Authorize(Policy = "RequireAuditor")]
[SwaggerTag("Audit log management - requires Auditor role")]
public class AuditController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -13,7 +13,8 @@ namespace IamService.API.Controllers;
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/compliance")]
[Authorize(AuthenticationSchemes = "Bearer")]
[SwaggerTag("Compliance reporting")]
[Authorize(Policy = "RequireAuditor")]
[SwaggerTag("Compliance reporting - requires Auditor role")]
public class ComplianceController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -17,7 +17,8 @@ namespace IamService.API.Controllers;
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/groups")]
[Authorize(AuthenticationSchemes = "Bearer")]
[SwaggerTag("Group management endpoints - requires authentication")]
[Authorize(Policy = "RequireAdmin")]
[SwaggerTag("Group management endpoints - requires Admin role")]
public class GroupsController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -18,7 +18,8 @@ namespace IamService.API.Controllers;
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/organizations")]
[Authorize(AuthenticationSchemes = "Bearer")]
[SwaggerTag("Organization management endpoints - requires authentication")]
[Authorize(Policy = "RequireAdmin")]
[SwaggerTag("Organization management endpoints - requires Admin role")]
public class OrganizationsController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -13,7 +13,8 @@ namespace IamService.API.Controllers;
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/privileged-access")]
[Authorize(AuthenticationSchemes = "Bearer")]
[SwaggerTag("Privileged Access Management (PAM) - Just-In-Time elevated access")]
[Authorize(Policy = "RequireSuperAdmin")]
[SwaggerTag("Privileged Access Management (PAM) - requires SuperAdmin role")]
public class PrivilegedAccessController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -18,7 +18,8 @@ namespace IamService.API.Controllers;
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/roles")]
[Authorize(AuthenticationSchemes = "Bearer")]
[SwaggerTag("Role management endpoints - requires authentication")]
[Authorize(Policy = "RequireAdmin")]
[SwaggerTag("Role management endpoints - requires Admin role")]
public class RolesController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -46,14 +46,17 @@ public class UsersController : ControllerBase
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Paginated list of users</returns>
[HttpGet]
[Authorize(Policy = "RequireAdmin")]
[SwaggerOperation(
Summary = "Get all users",
Description = "Retrieves a paginated list of all users. Requires authentication.",
Description = "Retrieves a paginated list of all users. Requires Admin role.",
OperationId = "GetUsers")]
[SwaggerResponse(StatusCodes.Status200OK, "Successfully retrieved users", typeof(ApiResponse<IEnumerable<UserDto>>))]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[SwaggerResponse(StatusCodes.Status403Forbidden, "Insufficient permissions")]
[ProducesResponseType(typeof(ApiResponse<IEnumerable<UserDto>>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetUsers(
[FromQuery, SwaggerParameter("Page number (1-based)", Required = false)] int pageNumber = 1,
[FromQuery, SwaggerParameter("Number of items per page", Required = false)] int pageSize = 10,
@@ -93,15 +96,18 @@ public class UsersController : ControllerBase
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>User information</returns>
[HttpGet("{id:guid}")]
[Authorize(Policy = "OwnerOrAdmin")]
[SwaggerOperation(
Summary = "Get user by ID",
Description = "Retrieves a specific user by their unique identifier.",
Description = "Retrieves a specific user. Users can access their own profile; Admins can access any.",
OperationId = "GetUserById")]
[SwaggerResponse(StatusCodes.Status200OK, "Successfully retrieved user", typeof(ApiResponse<UserDto>))]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[SwaggerResponse(StatusCodes.Status403Forbidden, "Insufficient permissions")]
[SwaggerResponse(StatusCodes.Status404NotFound, "User not found")]
[ProducesResponseType(typeof(ApiResponse<UserDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetUserById(
[FromRoute, SwaggerParameter("User ID", Required = true)] Guid id,
@@ -137,17 +143,20 @@ public class UsersController : ControllerBase
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Updated user information</returns>
[HttpPut("{id:guid}")]
[Authorize(Policy = "OwnerOrAdmin")]
[SwaggerOperation(
Summary = "Update user",
Description = "Updates a user's information (first name, last name).",
Description = "Updates a user's information. Users can update their own profile; Admins can update any.",
OperationId = "UpdateUser")]
[SwaggerResponse(StatusCodes.Status200OK, "User updated successfully", typeof(ApiResponse<UserDto>))]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request data")]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[SwaggerResponse(StatusCodes.Status403Forbidden, "Insufficient permissions")]
[SwaggerResponse(StatusCodes.Status404NotFound, "User not found")]
[ProducesResponseType(typeof(ApiResponse<UserDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateUser(
[FromRoute, SwaggerParameter("User ID to update", Required = true)] Guid id,
@@ -183,15 +192,18 @@ public class UsersController : ControllerBase
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Deletion result</returns>
[HttpDelete("{id:guid}")]
[Authorize(Policy = "RequireAdmin")]
[SwaggerOperation(
Summary = "Delete user",
Description = "Soft deletes (deactivates) a user. The user data is retained but marked as inactive.",
Description = "Soft deletes (deactivates) a user. Requires Admin role.",
OperationId = "DeleteUser")]
[SwaggerResponse(StatusCodes.Status200OK, "User deleted successfully")]
[SwaggerResponse(StatusCodes.Status401Unauthorized, "Authentication required")]
[SwaggerResponse(StatusCodes.Status403Forbidden, "Insufficient permissions")]
[SwaggerResponse(StatusCodes.Status404NotFound, "User not found")]
[ProducesResponseType(typeof(ApiResponse<DeleteUserResult>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteUser(
[FromRoute, SwaggerParameter("User ID to delete", Required = true)] Guid id,

View File

@@ -16,7 +16,8 @@ namespace IamService.API.Controllers;
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/verifications")]
[Authorize(AuthenticationSchemes = "Bearer")]
[SwaggerTag("Identity verification endpoints - requires authentication")]
[Authorize(Policy = "RequireAdmin")]
[SwaggerTag("Identity verification endpoints - requires Admin role")]
public class VerificationsController : ControllerBase
{
private readonly IMediator _mediator;

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace IamService.Infrastructure.Authorization;

View File

@@ -0,0 +1,304 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using Xunit;
using IamService.Domain.AggregatesModel.UserAggregate;
namespace IamService.FunctionalTests.Controllers;
/// <summary>
/// EN: Functional tests for Authorization Policies.
/// VI: Functional tests cho Authorization Policies.
/// </summary>
/// <remarks>
/// EN: These tests require additional authentication configuration in CustomWebApplicationFactory
/// to properly inject role claims into the test JWT. The tests verify that:
/// - RequireSuperAdmin: Only SuperAdmin can access PAM endpoints
/// - RequireAdmin: Admin/SuperAdmin can access management endpoints
/// - RequireAuditor: Auditor/Admin/SuperAdmin can access audit endpoints
/// - OwnerOrAdmin: User can access own profile or Admin can access any
///
/// TODO: Configure test authentication to properly inject role claims.
/// Current unit tests in IamService.UnitTests cover authorization handler logic.
/// VI: Các tests này yêu cầu cấu hình thêm authentication trong CustomWebApplicationFactory
/// để inject role claims vào test JWT. Unit tests trong IamService.UnitTests đã cover
/// logic authorization handler.
/// </remarks>
[Collection("Sequential")]
public class AuthorizationPolicyTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly CustomWebApplicationFactory _factory;
public AuthorizationPolicyTests(CustomWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
#region Token Generation Helpers
/// <summary>
/// EN: Generate a test JWT token with specified roles.
/// VI: Tạo test JWT token với các roles được chỉ định.
/// </summary>
/// <remarks>
/// EN: Creates an unsigned JWT that the CustomWebApplicationFactory accepts.
/// The test factory is configured to skip signature validation.
/// VI: Tạo unsigned JWT mà CustomWebApplicationFactory chấp nhận.
/// Test factory được cấu hình để bỏ qua signature validation.
/// </remarks>
private string GenerateTestToken(Guid userId, string email, params string[] roles)
{
var claims = new List<Claim>
{
new("sub", userId.ToString()),
new("email", email),
new("name", "Test User"),
new("iss", "http://localhost"),
new("aud", "api"),
new("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
new("exp", DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds().ToString())
};
// Add role claims
foreach (var role in roles)
{
claims.Add(new Claim("role", role));
}
// Create unsigned token descriptor
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddHours(1),
Issuer = "http://localhost",
Audience = "api"
};
// Generate JWT without signature (for test purposes)
var handler = new JwtSecurityTokenHandler();
var token = handler.CreateJwtSecurityToken(tokenDescriptor);
return handler.WriteToken(token);
}
private HttpRequestMessage CreateRequest(HttpMethod method, string url, string token)
{
var request = new HttpRequestMessage(method, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return request;
}
#endregion
#region RequireAdmin Policy Tests
[Fact(Skip = "Requires test authentication configuration for role claims")]
public async Task GetUsers_WithoutAdminRole_ShouldReturn403()
{
// Arrange - User with no admin role
var token = GenerateTestToken(Guid.NewGuid(), "user@example.com", "User");
var request = CreateRequest(HttpMethod.Get, "/api/v1/users", token);
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact(Skip = "Requires test authentication configuration for role claims")]
public async Task GetUsers_WithAdminRole_ShouldReturn200()
{
// Arrange - User with Admin role
var token = GenerateTestToken(Guid.NewGuid(), "admin@example.com", "Admin");
var request = CreateRequest(HttpMethod.Get, "/api/v1/users", token);
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact(Skip = "Requires test authentication configuration for role claims")]
public async Task GetUsers_WithSuperAdminRole_ShouldReturn200()
{
// Arrange - SuperAdmin can access Admin endpoints
var token = GenerateTestToken(Guid.NewGuid(), "superadmin@example.com", "SuperAdmin");
var request = CreateRequest(HttpMethod.Get, "/api/v1/users", token);
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
#endregion
#region RequireSuperAdmin Policy Tests (PAM)
[Fact(Skip = "Requires test authentication configuration for role claims")]
public async Task GetActivePrivilegedAccess_WithAdminRole_ShouldReturn403()
{
// Arrange - Admin cannot access SuperAdmin endpoints
var userId = Guid.NewGuid();
var token = GenerateTestToken(userId, "admin@example.com", "Admin");
var request = CreateRequest(HttpMethod.Get, $"/api/v1/privileged-access/active?userId={userId}", token);
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact(Skip = "Requires test authentication configuration for role claims")]
public async Task GetActivePrivilegedAccess_WithSuperAdminRole_ShouldReturn200()
{
// Arrange - Only SuperAdmin can access PAM
var userId = Guid.NewGuid();
var token = GenerateTestToken(userId, "superadmin@example.com", "SuperAdmin");
var request = CreateRequest(HttpMethod.Get, $"/api/v1/privileged-access/active?userId={userId}", token);
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
#endregion
#region RequireAuditor Policy Tests
[Fact(Skip = "Requires test authentication configuration for role claims")]
public async Task GetAuditLogs_WithUserRole_ShouldReturn403()
{
// Arrange - Regular user cannot access audit logs
var token = GenerateTestToken(Guid.NewGuid(), "user@example.com", "User");
var request = CreateRequest(HttpMethod.Get, "/api/v1/audit/logs", token);
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact(Skip = "Requires test authentication configuration for role claims")]
public async Task GetAuditLogs_WithAuditorRole_ShouldReturn200()
{
// Arrange - Auditor can access audit logs
var token = GenerateTestToken(Guid.NewGuid(), "auditor@example.com", "Auditor");
var request = CreateRequest(HttpMethod.Get, "/api/v1/audit/logs", token);
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact(Skip = "Requires test authentication configuration for role claims")]
public async Task GetComplianceReports_WithAdminRole_ShouldReturn200()
{
// Arrange - Admin can access compliance (auditor policy allows admin)
var token = GenerateTestToken(Guid.NewGuid(), "admin@example.com", "Admin");
var request = CreateRequest(HttpMethod.Get, "/api/v1/compliance/reports", token);
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
#endregion
#region Controller-Level Policy Tests
[Fact(Skip = "Requires test authentication configuration for role claims")]
public async Task RolesController_WithUserRole_ShouldReturn403()
{
// Arrange - User cannot access roles management
var token = GenerateTestToken(Guid.NewGuid(), "user@example.com", "User");
var request = CreateRequest(HttpMethod.Get, "/api/v1/roles", token);
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact(Skip = "Requires test authentication configuration for role claims")]
public async Task OrganizationsController_WithUserRole_ShouldReturn403()
{
// Arrange - User cannot access organizations management
var token = GenerateTestToken(Guid.NewGuid(), "user@example.com", "User");
var orgId = Guid.NewGuid();
var request = CreateRequest(HttpMethod.Get, $"/api/v1/organizations/{orgId}", token);
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact(Skip = "Requires test authentication configuration for role claims")]
public async Task GroupsController_WithUserRole_ShouldReturn403()
{
// Arrange - User cannot access groups management
var token = GenerateTestToken(Guid.NewGuid(), "user@example.com", "User");
var groupId = Guid.NewGuid();
var request = CreateRequest(HttpMethod.Get, $"/api/v1/groups/{groupId}", token);
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
#endregion
#region Edge Cases
[Fact]
public async Task AnyEndpoint_WithoutToken_ShouldReturn401()
{
// Arrange - No token provided
var response = await _client.GetAsync("/api/v1/users");
// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task AnyEndpoint_WithInvalidToken_ShouldReturn401()
{
// Arrange - Invalid token
var request = CreateRequest(HttpMethod.Get, "/api/v1/users", "invalid-token");
// Act
var response = await _client.SendAsync(request);
// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
#endregion
}

View File

@@ -68,6 +68,15 @@ public class LevelsController : ControllerBase
{
return Conflict(new { message = ex.Message });
}
catch (ArgumentException ex)
{
return BadRequest(new { message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating level definition");
return StatusCode(500, new { message = "An error occurred while creating level definition" });
}
}
/// <summary>
@@ -97,6 +106,15 @@ public class LevelsController : ControllerBase
{
return NotFound(new { message = ex.Message });
}
catch (ArgumentException ex)
{
return BadRequest(new { message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating level definition {LevelId}", id);
return StatusCode(500, new { message = "An error occurred while updating level definition" });
}
}
/// <summary>
@@ -121,6 +139,15 @@ public class LevelsController : ControllerBase
{
return NotFound(new { message = ex.Message });
}
catch (ArgumentException ex)
{
return BadRequest(new { message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deactivating level definition {LevelId}", id);
return StatusCode(500, new { message = "An error occurred while deactivating level definition" });
}
}
}

View File

@@ -0,0 +1,248 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using MembershipService.API.Application.Commands;
using Xunit;
namespace MembershipService.FunctionalTests.Controllers;
/// <summary>
/// EN: Functional tests for Admin Level endpoints.
/// VI: Functional tests cho Admin Level endpoints.
/// </summary>
[Collection("Sequential")]
public class AdminLevelsControllerTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly CustomWebApplicationFactory _factory;
public AdminLevelsControllerTests(CustomWebApplicationFactory factory)
{
_factory = factory;
}
#region POST /api/v1/levels - Create Level
[Fact]
public async Task CreateLevel_WithAuth_ShouldReturn201()
{
// Arrange
var client = _factory.CreateAuthenticatedClient();
var command = new CreateLevelDefinitionCommand
{
LevelNumber = 10,
Name = "Test Level",
RequiredExp = 5000,
Description = "Test level for functional tests",
BadgeColor = "#FF5733"
};
// Act
var response = await client.PostAsJsonAsync("/api/v1/levels", command);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<CreateLevelDefinitionResult>();
result.Should().NotBeNull();
result!.LevelNumber.Should().Be(10);
result.Name.Should().Be("Test Level");
result.RequiredExp.Should().Be(5000);
}
[Fact]
public async Task CreateLevel_WithoutAuth_ShouldReturn401()
{
// Arrange
var client = _factory.CreateClient(); // No auth
var command = new CreateLevelDefinitionCommand
{
LevelNumber = 11,
Name = "Unauthorized Level",
RequiredExp = 6000
};
// Act
var response = await client.PostAsJsonAsync("/api/v1/levels", command);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task CreateLevel_DuplicateLevelNumber_ShouldReturn409()
{
// Arrange
var client = _factory.CreateAuthenticatedClient();
// First create a level
var firstCommand = new CreateLevelDefinitionCommand
{
LevelNumber = 50, // Use unique number to avoid conflicts with other tests
Name = "First Level",
RequiredExp = 500
};
var firstResponse = await client.PostAsJsonAsync("/api/v1/levels", firstCommand);
firstResponse.StatusCode.Should().Be(HttpStatusCode.Created);
// Now try to create duplicate
var duplicateCommand = new CreateLevelDefinitionCommand
{
LevelNumber = 50, // Same level number - should conflict
Name = "Duplicate",
RequiredExp = 500
};
// Act
var response = await client.PostAsJsonAsync("/api/v1/levels", duplicateCommand);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
}
#endregion
#region PUT /api/v1/levels/{id} - Update Level
[Fact]
public async Task UpdateLevel_WithAuth_ShouldReturn200()
{
// Arrange
var client = _factory.CreateAuthenticatedClient();
// First create a level to update
var createCommand = new CreateLevelDefinitionCommand
{
LevelNumber = 60,
Name = "ToUpdate",
RequiredExp = 6000,
Description = "Original description"
};
var createResponse = await client.PostAsJsonAsync("/api/v1/levels", createCommand);
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var createdLevel = await createResponse.Content.ReadFromJsonAsync<CreateLevelDefinitionResult>();
var updateCommand = new UpdateLevelDefinitionCommand
{
Id = createdLevel!.Id,
Name = "Updated Level",
Description = "Updated description"
};
// Act
var response = await client.PutAsJsonAsync($"/api/v1/levels/{createdLevel.Id}", updateCommand);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<UpdateLevelDefinitionResult>();
result.Should().NotBeNull();
result!.Name.Should().Be("Updated Level");
result.Description.Should().Be("Updated description");
}
[Fact]
public async Task UpdateLevel_WithoutAuth_ShouldReturn401()
{
// Arrange
var client = _factory.CreateClient(); // No auth
var randomId = Guid.NewGuid();
var command = new UpdateLevelDefinitionCommand
{
Id = randomId,
Name = "Unauthorized Update"
};
// Act
var response = await client.PutAsJsonAsync($"/api/v1/levels/{randomId}", command);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task UpdateLevel_NotFound_ShouldReturn404()
{
// Arrange
var client = _factory.CreateAuthenticatedClient();
var nonExistentId = Guid.NewGuid();
var command = new UpdateLevelDefinitionCommand
{
Id = nonExistentId,
Name = "NonExistent"
};
// Act
var response = await client.PutAsJsonAsync($"/api/v1/levels/{nonExistentId}", command);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
#endregion
#region DELETE /api/v1/levels/{id} - Deactivate Level
[Fact]
public async Task DeactivateLevel_WithAuth_ShouldReturn204()
{
// Arrange
var client = _factory.CreateAuthenticatedClient();
// First create a level to deactivate
var createCommand = new CreateLevelDefinitionCommand
{
LevelNumber = 99,
Name = "ToDeactivate",
RequiredExp = 9999
};
var createResponse = await client.PostAsJsonAsync("/api/v1/levels", createCommand);
var createdLevel = await createResponse.Content.ReadFromJsonAsync<CreateLevelDefinitionResult>();
// Act
var response = await client.DeleteAsync($"/api/v1/levels/{createdLevel!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
public async Task DeactivateLevel_WithoutAuth_ShouldReturn401()
{
// Arrange
var client = _factory.CreateClient(); // No auth
var randomId = Guid.NewGuid();
// Act
var response = await client.DeleteAsync($"/api/v1/levels/{randomId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task DeactivateLevel_NotFound_ShouldReturn404()
{
// Arrange
var client = _factory.CreateAuthenticatedClient();
var nonExistentId = Guid.NewGuid();
// Act
var response = await client.DeleteAsync($"/api/v1/levels/{nonExistentId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
#endregion
#region Helper DTOs
private class LevelDto
{
public Guid Id { get; set; }
public int LevelNumber { get; set; }
public string Name { get; set; } = string.Empty;
}
#endregion
}

View File

@@ -52,6 +52,9 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
services.AddDbContext<MembershipServiceContext>(options =>
{
options.UseInMemoryDatabase(databaseName);
// EN: Suppress transaction warning since in-memory doesn't support transactions
// VI: Bỏ qua cảnh báo transaction vì in-memory không hỗ trợ transactions
options.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning));
});
// EN: Re-register repositories

View File

@@ -0,0 +1,148 @@
using FluentAssertions;
using MembershipService.API.Application.Commands;
using MembershipService.Domain.AggregatesModel.LevelAggregate;
using MembershipService.Domain.SeedWork;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace MembershipService.UnitTests.Handlers;
/// <summary>
/// EN: Unit tests for CreateLevelDefinitionCommandHandler.
/// VI: Unit tests cho CreateLevelDefinitionCommandHandler.
/// </summary>
public class CreateLevelDefinitionCommandHandlerTests
{
private readonly ILevelDefinitionRepository _repository;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<CreateLevelDefinitionCommandHandler> _logger;
private readonly CreateLevelDefinitionCommandHandler _handler;
public CreateLevelDefinitionCommandHandlerTests()
{
_repository = Substitute.For<ILevelDefinitionRepository>();
_unitOfWork = Substitute.For<IUnitOfWork>();
_logger = Substitute.For<ILogger<CreateLevelDefinitionCommandHandler>>();
_repository.UnitOfWork.Returns(_unitOfWork);
_handler = new CreateLevelDefinitionCommandHandler(_repository, _logger);
}
[Fact]
public async Task Handle_ValidCommand_ShouldCreateLevelDefinition()
{
// Arrange
var command = new CreateLevelDefinitionCommand
{
LevelNumber = 6,
Name = "Master",
RequiredExp = 2000,
Description = "Master level",
BadgeColor = "#8B00FF"
};
_repository.ExistsByLevelNumberAsync(command.LevelNumber)
.Returns(false);
_repository.Add(Arg.Any<LevelDefinition>())
.Returns(x => x.Arg<LevelDefinition>());
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>())
.Returns(true);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.LevelNumber.Should().Be(6);
result.Name.Should().Be("Master");
result.RequiredExp.Should().Be(2000);
result.Id.Should().NotBeEmpty();
_repository.Received(1).Add(Arg.Any<LevelDefinition>());
await _unitOfWork.Received(1).SaveEntitiesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_DuplicateLevelNumber_ShouldThrowInvalidOperationException()
{
// Arrange
var command = new CreateLevelDefinitionCommand
{
LevelNumber = 1,
Name = "Duplicate",
RequiredExp = 0
};
_repository.ExistsByLevelNumberAsync(command.LevelNumber)
.Returns(true);
// Act
var act = () => _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*already exists*");
_repository.DidNotReceive().Add(Arg.Any<LevelDefinition>());
}
[Fact]
public async Task Handle_WithOptionalFields_ShouldCreateWithAllFields()
{
// Arrange
var command = new CreateLevelDefinitionCommand
{
LevelNumber = 7,
Name = "Legend",
RequiredExp = 5000,
Description = "Legend tier for elite members",
IconUrl = "https://example.com/legend.png",
BadgeColor = "#FFD700"
};
_repository.ExistsByLevelNumberAsync(command.LevelNumber)
.Returns(false);
_repository.Add(Arg.Any<LevelDefinition>())
.Returns(x => x.Arg<LevelDefinition>());
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>())
.Returns(true);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.LevelNumber.Should().Be(7);
result.Name.Should().Be("Legend");
result.RequiredExp.Should().Be(5000);
}
[Fact]
public async Task Handle_MinimalCommand_ShouldCreateWithDefaults()
{
// Arrange
var command = new CreateLevelDefinitionCommand
{
LevelNumber = 8,
Name = "Minimal",
RequiredExp = 100
};
_repository.ExistsByLevelNumberAsync(command.LevelNumber)
.Returns(false);
_repository.Add(Arg.Any<LevelDefinition>())
.Returns(x => x.Arg<LevelDefinition>());
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>())
.Returns(true);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.LevelNumber.Should().Be(8);
result.Name.Should().Be("Minimal");
}
}

View File

@@ -0,0 +1,113 @@
using FluentAssertions;
using MembershipService.API.Application.Commands;
using MembershipService.Domain.AggregatesModel.LevelAggregate;
using MembershipService.Domain.SeedWork;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace MembershipService.UnitTests.Handlers;
/// <summary>
/// EN: Unit tests for DeactivateLevelDefinitionCommandHandler.
/// VI: Unit tests cho DeactivateLevelDefinitionCommandHandler.
/// </summary>
public class DeactivateLevelDefinitionCommandHandlerTests
{
private readonly ILevelDefinitionRepository _repository;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<DeactivateLevelDefinitionCommandHandler> _logger;
private readonly DeactivateLevelDefinitionCommandHandler _handler;
public DeactivateLevelDefinitionCommandHandlerTests()
{
_repository = Substitute.For<ILevelDefinitionRepository>();
_unitOfWork = Substitute.For<IUnitOfWork>();
_logger = Substitute.For<ILogger<DeactivateLevelDefinitionCommandHandler>>();
_repository.UnitOfWork.Returns(_unitOfWork);
_handler = new DeactivateLevelDefinitionCommandHandler(_repository, _logger);
}
[Fact]
public async Task Handle_ValidLevel_ShouldDeactivate()
{
// Arrange
var levelId = Guid.NewGuid();
var existingLevel = new LevelDefinition(5, "Diamond", 1000, "Top tier");
var command = new DeactivateLevelDefinitionCommand(levelId);
_repository.GetAsync(levelId).Returns(existingLevel);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeTrue();
existingLevel.IsActive.Should().BeFalse();
_repository.Received(1).Update(Arg.Any<LevelDefinition>());
await _unitOfWork.Received(1).SaveEntitiesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_LevelNotFound_ShouldThrowKeyNotFoundException()
{
// Arrange
var command = new DeactivateLevelDefinitionCommand(Guid.NewGuid());
_repository.GetAsync(command.Id).Returns((LevelDefinition?)null);
// Act
var act = () => _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<KeyNotFoundException>()
.WithMessage("*not found*");
_repository.DidNotReceive().Update(Arg.Any<LevelDefinition>());
}
[Fact]
public async Task Handle_AlreadyInactiveLevel_ShouldStillSucceed()
{
// Arrange
var levelId = Guid.NewGuid();
var existingLevel = new LevelDefinition(6, "Master", 2000, "Master tier");
existingLevel.Deactivate(); // Already inactive
var command = new DeactivateLevelDefinitionCommand(levelId);
_repository.GetAsync(levelId).Returns(existingLevel);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeTrue();
existingLevel.IsActive.Should().BeFalse();
}
[Fact]
public async Task Handle_WithDefaultConstructor_ShouldWork()
{
// Arrange
var levelId = Guid.NewGuid();
var existingLevel = new LevelDefinition(7, "Legend", 5000, "Legend tier");
var command = new DeactivateLevelDefinitionCommand { Id = levelId };
_repository.GetAsync(levelId).Returns(existingLevel);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeTrue();
}
}

View File

@@ -0,0 +1,160 @@
using FluentAssertions;
using MembershipService.API.Application.Commands;
using MembershipService.Domain.AggregatesModel.LevelAggregate;
using MembershipService.Domain.SeedWork;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace MembershipService.UnitTests.Handlers;
/// <summary>
/// EN: Unit tests for UpdateLevelDefinitionCommandHandler.
/// VI: Unit tests cho UpdateLevelDefinitionCommandHandler.
/// </summary>
public class UpdateLevelDefinitionCommandHandlerTests
{
private readonly ILevelDefinitionRepository _repository;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<UpdateLevelDefinitionCommandHandler> _logger;
private readonly UpdateLevelDefinitionCommandHandler _handler;
public UpdateLevelDefinitionCommandHandlerTests()
{
_repository = Substitute.For<ILevelDefinitionRepository>();
_unitOfWork = Substitute.For<IUnitOfWork>();
_logger = Substitute.For<ILogger<UpdateLevelDefinitionCommandHandler>>();
_repository.UnitOfWork.Returns(_unitOfWork);
_handler = new UpdateLevelDefinitionCommandHandler(_repository, _logger);
}
[Fact]
public async Task Handle_ValidUpdate_ShouldUpdateLevelDefinition()
{
// Arrange
var levelId = Guid.NewGuid();
var existingLevel = new LevelDefinition(1, "Bronze", 0, "Old description");
var command = new UpdateLevelDefinitionCommand
{
Id = levelId,
Name = "Bronze Elite",
RequiredExp = 50,
Description = "Updated description"
};
_repository.GetAsync(levelId).Returns(existingLevel);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Name.Should().Be("Bronze Elite");
result.RequiredExp.Should().Be(50);
result.Description.Should().Be("Updated description");
_repository.Received(1).Update(Arg.Any<LevelDefinition>());
await _unitOfWork.Received(1).SaveEntitiesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_LevelNotFound_ShouldThrowKeyNotFoundException()
{
// Arrange
var command = new UpdateLevelDefinitionCommand
{
Id = Guid.NewGuid(),
Name = "NonExistent"
};
_repository.GetAsync(command.Id).Returns((LevelDefinition?)null);
// Act
var act = () => _handler.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<KeyNotFoundException>()
.WithMessage("*not found*");
_repository.DidNotReceive().Update(Arg.Any<LevelDefinition>());
}
[Fact]
public async Task Handle_PartialUpdate_ShouldOnlyUpdateProvidedFields()
{
// Arrange
var levelId = Guid.NewGuid();
var existingLevel = new LevelDefinition(2, "Silver", 100, "Original description", null, "#C0C0C0");
var command = new UpdateLevelDefinitionCommand
{
Id = levelId,
Name = "Silver Plus"
// Not updating RequiredExp, Description, etc.
};
_repository.GetAsync(levelId).Returns(existingLevel);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Name.Should().Be("Silver Plus");
result.RequiredExp.Should().Be(100); // Unchanged
result.BadgeColor.Should().Be("#C0C0C0"); // Unchanged
}
[Fact]
public async Task Handle_ClearDescription_ShouldSetToNull()
{
// Arrange
var levelId = Guid.NewGuid();
var existingLevel = new LevelDefinition(3, "Gold", 300, "Has description");
var command = new UpdateLevelDefinitionCommand
{
Id = levelId,
ClearDescription = true
};
_repository.GetAsync(levelId).Returns(existingLevel);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Description.Should().BeNull();
}
[Fact]
public async Task Handle_UpdateBadgeColor_ShouldUpdateColor()
{
// Arrange
var levelId = Guid.NewGuid();
var existingLevel = new LevelDefinition(4, "Platinum", 600, null, null, "#E5E4E2");
var command = new UpdateLevelDefinitionCommand
{
Id = levelId,
BadgeColor = "#00CED1"
};
_repository.GetAsync(levelId).Returns(existingLevel);
_unitOfWork.SaveEntitiesAsync(Arg.Any<CancellationToken>()).Returns(true);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.BadgeColor.Should().Be("#00CED1");
}
}

View File

@@ -96,6 +96,23 @@ Application entry point and CQRS implementation:
| **DeleteFileCommand** | Handle file deletions |
| **Query Handlers** | Handle read operations |
### 4. Admin Backoffice Layer
Controllers and Commands for Admin storage management:
| Component | Purpose |
|-----------|---------|
| **AdminQuotaController** | Manage user quotas (GET all, PUT update) |
| **AdminFilesController** | View/delete all users' files |
| **AdminSharesController** | View/revoke violating file shares |
| **AdminStatisticsController** | Storage statistics dashboard |
| **UpdateUserQuotaCommand** | Admin update quota limits |
| **AdminDeleteFileCommand** | Admin delete file (bypass ownership) |
| **AdminRevokeShareCommand** | Admin revoke violating share |
| **Admin Query Handlers** | Queries with pagination and filtering |
> **Authorization**: All Admin endpoints require `Admin` or `SuperAdmin` role.
## Direct Upload Architecture (Recommended)
For systems with millions of users, Direct Client Upload pattern is recommended over proxy upload.
@@ -722,6 +739,37 @@ erDiagram
|--------|----------|-------------|
| `GET` | `/api/v1/quota` | Get user storage quota |
### Admin API (Requires Role: Admin)
#### Quota Management
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/admin/quotas` | Get all users' quotas |
| `GET` | `/api/v1/admin/quotas/{userId}` | Get quota for specific user |
| `PUT` | `/api/v1/admin/quotas/{userId}` | Update quota limits |
#### Files Management
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/admin/files` | Get all files with filtering |
| `DELETE` | `/api/v1/admin/files/{id}` | Delete file for policy violation |
#### Shares Management
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/admin/shares` | Get all shares |
| `DELETE` | `/api/v1/admin/shares/{id}` | Revoke share for violation |
#### Statistics
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/admin/statistics` | Dashboard aggregated statistics |
| `GET` | `/api/v1/admin/statistics/users-near-limit` | Users near quota limit (>80%) |
## Health Checks

View File

@@ -113,6 +113,39 @@ dotnet run --project src/StorageService.API
|--------|----------|-------------|
| `GET` | `/api/v1/quota` | Get current user's storage quota |
### Admin API (Requires Role: Admin)
> **Note:** These endpoints require `Admin` or `SuperAdmin` role.
#### Quota Management
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/admin/quotas` | Get all users' quotas |
| `GET` | `/api/v1/admin/quotas/{userId}` | Get quota for specific user |
| `PUT` | `/api/v1/admin/quotas/{userId}` | Update quota limits |
#### Files Management
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/admin/files` | Get all files with filtering |
| `DELETE` | `/api/v1/admin/files/{id}` | Delete file for policy violation |
#### Shares Management
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/admin/shares` | Get all shares |
| `DELETE` | `/api/v1/admin/shares/{id}` | Revoke share for violation |
#### Statistics
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/admin/statistics` | Dashboard aggregated statistics |
| `GET` | `/api/v1/admin/statistics/users-near-limit` | Users near quota limit (>80%) |
## Direct Upload Example (Recommended)
### Step 1: Get Pre-signed URL

View File

@@ -96,6 +96,23 @@ Entry point ứng dụng và triển khai CQRS:
| **DeleteFileCommand** | Xử lý xóa file |
| **Query Handlers** | Xử lý các thao tác đọc |
### 4. Admin Backoffice Layer
Controllers và Commands cho Admin quản lý storage:
| Component | Mục đích |
|-----------|----------|
| **AdminQuotaController** | Quản lý quota users (GET all, PUT update) |
| **AdminFilesController** | Xem/xóa files của tất cả users |
| **AdminSharesController** | Xem/revoke file shares vi phạm |
| **AdminStatisticsController** | Dashboard thống kê storage |
| **UpdateUserQuotaCommand** | Admin cập nhật quota limits |
| **AdminDeleteFileCommand** | Admin xóa file (bypass ownership) |
| **AdminRevokeShareCommand** | Admin revoke share vi phạm |
| **Admin Query Handlers** | Queries với phân trang và filter |
> **Authorization**: Tất cả Admin endpoints yêu cầu role `Admin` hoặc `SuperAdmin`.
## Kiến Trúc Direct Upload (Khuyến Nghị)
Cho hệ thống với hàng triệu users, pattern Direct Client Upload được khuyến nghị thay vì proxy upload.
@@ -756,6 +773,37 @@ erDiagram
|--------|----------|-------|
| `GET` | `/api/v1/quota` | Lấy quota storage của user |
### Admin API (Yêu cầu Role: Admin)
#### Quản lý Quota
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `GET` | `/api/v1/admin/quotas` | Lấy quota tất cả users |
| `GET` | `/api/v1/admin/quotas/{userId}` | Lấy quota user cụ thể |
| `PUT` | `/api/v1/admin/quotas/{userId}` | Cập nhật quota limits |
#### Quản lý Files
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `GET` | `/api/v1/admin/files` | Xem tất cả files |
| `DELETE` | `/api/v1/admin/files/{id}` | Xóa file vi phạm |
#### Quản lý Shares
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `GET` | `/api/v1/admin/shares` | Xem tất cả shares |
| `DELETE` | `/api/v1/admin/shares/{id}` | Revoke share |
#### Thống kê
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `GET` | `/api/v1/admin/statistics` | Dashboard thống kê |
| `GET` | `/api/v1/admin/statistics/users-near-limit` | Users gần hết quota |
## Health Checks

View File

@@ -114,6 +114,39 @@ dotnet run --project src/StorageService.API
|--------|----------|-------|
| `GET` | `/api/v1/quota` | Lấy quota storage của user hiện tại |
### Admin API (Yêu cầu Role: Admin)
> **Lưu ý:** Các endpoints này yêu cầu role `Admin` hoặc `SuperAdmin`.
#### Quản lý Quota
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `GET` | `/api/v1/admin/quotas` | Lấy danh sách quota tất cả users |
| `GET` | `/api/v1/admin/quotas/{userId}` | Lấy quota của user cụ thể |
| `PUT` | `/api/v1/admin/quotas/{userId}` | Cập nhật quota limits |
#### Quản lý Files
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `GET` | `/api/v1/admin/files` | Xem tất cả files với filter |
| `DELETE` | `/api/v1/admin/files/{id}` | Xóa file vi phạm |
#### Quản lý Shares
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `GET` | `/api/v1/admin/shares` | Xem tất cả shares |
| `DELETE` | `/api/v1/admin/shares/{id}` | Revoke share vi phạm |
#### Thống kê
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `GET` | `/api/v1/admin/statistics` | Dashboard thống kê tổng hợp |
| `GET` | `/api/v1/admin/statistics/users-near-limit` | Users gần hết quota (>80%) |
## Ví Dụ Direct Upload (Khuyến nghị)
### Bước 1: Lấy Pre-signed URL

View File

@@ -0,0 +1,292 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using StorageService.Infrastructure.Persistence;
namespace StorageService.API.Application.Queries.Admin;
/// <summary>
/// EN: Handler for GetAllUsersQuotaQuery.
/// VI: Handler cho GetAllUsersQuotaQuery.
/// </summary>
public class GetAllUsersQuotaQueryHandler : IRequestHandler<GetAllUsersQuotaQuery, AllUsersQuotaResult>
{
private readonly StorageServiceContext _context;
public GetAllUsersQuotaQueryHandler(StorageServiceContext context)
{
_context = context;
}
public async Task<AllUsersQuotaResult> Handle(GetAllUsersQuotaQuery request, CancellationToken cancellationToken)
{
var query = _context.UserStorageQuotas.AsNoTracking();
// EN: Apply filters / VI: Áp dụng filters
if (!string.IsNullOrEmpty(request.QuotaTier))
{
query = query.Where(q => q.QuotaTier == request.QuotaTier);
}
if (request.MinUsagePercentage.HasValue)
{
query = query.Where(q =>
q.MaxStorageBytes > 0 &&
(double)q.UsedStorageBytes / q.MaxStorageBytes * 100 >= request.MinUsagePercentage.Value);
}
// EN: Get total count / VI: Lấy tổng số
var totalCount = await query.CountAsync(cancellationToken);
// EN: Apply sorting / VI: Áp dụng sắp xếp
query = request.SortBy?.ToLower() switch
{
"usedstoragebytes" => request.Descending
? query.OrderByDescending(q => q.UsedStorageBytes)
: query.OrderBy(q => q.UsedStorageBytes),
"maxstoragebytes" => request.Descending
? query.OrderByDescending(q => q.MaxStorageBytes)
: query.OrderBy(q => q.MaxStorageBytes),
"currentfilecount" => request.Descending
? query.OrderByDescending(q => q.CurrentFileCount)
: query.OrderBy(q => q.CurrentFileCount),
"lastupdatedat" => request.Descending
? query.OrderByDescending(q => q.LastUpdatedAt)
: query.OrderBy(q => q.LastUpdatedAt),
_ => query.OrderByDescending(q => q.UsedStorageBytes) // Default: highest usage first
};
// EN: Apply pagination / VI: Áp dụng phân trang
var skip = (request.PageNumber - 1) * request.PageSize;
var items = await query
.Skip(skip)
.Take(request.PageSize)
.Select(q => new AdminQuotaDto(
q.Id,
q.UserId,
q.MaxStorageBytes,
q.UsedStorageBytes,
q.MaxStorageBytes - q.UsedStorageBytes,
q.MaxFileCount,
q.CurrentFileCount,
q.MaxFileCount - q.CurrentFileCount,
q.MaxStorageBytes > 0 ? Math.Round((double)q.UsedStorageBytes / q.MaxStorageBytes * 100, 2) : 0,
q.QuotaTier,
q.LastUpdatedAt,
q.CreatedAt))
.ToListAsync(cancellationToken);
var totalPages = (int)Math.Ceiling((double)totalCount / request.PageSize);
return new AllUsersQuotaResult(items, totalCount, request.PageNumber, request.PageSize, totalPages);
}
}
/// <summary>
/// EN: Handler for GetStorageStatisticsQuery.
/// VI: Handler cho GetStorageStatisticsQuery.
/// </summary>
public class GetStorageStatisticsQueryHandler : IRequestHandler<GetStorageStatisticsQuery, StorageStatisticsDto>
{
private readonly StorageServiceContext _context;
public GetStorageStatisticsQueryHandler(StorageServiceContext context)
{
_context = context;
}
public async Task<StorageStatisticsDto> Handle(GetStorageStatisticsQuery request, CancellationToken cancellationToken)
{
// EN: Get quota statistics / VI: Lấy thống kê quota
var quotaStats = await _context.UserStorageQuotas
.AsNoTracking()
.GroupBy(_ => 1)
.Select(g => new
{
TotalUsers = g.Count(),
TotalStorageUsedBytes = g.Sum(q => q.UsedStorageBytes),
TotalStorageAllocatedBytes = g.Sum(q => q.MaxStorageBytes),
TotalFileCount = g.Sum(q => q.CurrentFileCount),
AverageUsagePercentage = g.Average(q =>
q.MaxStorageBytes > 0 ? (double)q.UsedStorageBytes / q.MaxStorageBytes * 100 : 0)
})
.FirstOrDefaultAsync(cancellationToken);
// EN: Get users by tier / VI: Lấy users theo tier
var usersByTier = await _context.UserStorageQuotas
.AsNoTracking()
.GroupBy(q => q.QuotaTier ?? "unknown")
.Select(g => new { Tier = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.Tier, g => g.Count, cancellationToken);
// EN: Get users near limit (>80% usage) / VI: Lấy users gần hết quota (>80%)
var usersNearLimit = await _context.UserStorageQuotas
.AsNoTracking()
.Where(q => q.MaxStorageBytes > 0 &&
(double)q.UsedStorageBytes / q.MaxStorageBytes >= 0.8 &&
(double)q.UsedStorageBytes / q.MaxStorageBytes < 1.0)
.CountAsync(cancellationToken);
// EN: Get users over limit (>=100% usage) / VI: Lấy users vượt quota (>=100%)
var usersOverLimit = await _context.UserStorageQuotas
.AsNoTracking()
.Where(q => q.MaxStorageBytes > 0 &&
(double)q.UsedStorageBytes / q.MaxStorageBytes >= 1.0)
.CountAsync(cancellationToken);
return new StorageStatisticsDto(
quotaStats?.TotalUsers ?? 0,
quotaStats?.TotalStorageUsedBytes ?? 0,
quotaStats?.TotalStorageAllocatedBytes ?? 0,
quotaStats?.TotalFileCount ?? 0,
Math.Round(quotaStats?.AverageUsagePercentage ?? 0, 2),
usersByTier,
usersNearLimit,
usersOverLimit);
}
}
/// <summary>
/// EN: Handler for AdminGetFilesQuery.
/// VI: Handler cho AdminGetFilesQuery.
/// </summary>
public class AdminGetFilesQueryHandler : IRequestHandler<AdminGetFilesQuery, AdminFilesResult>
{
private readonly StorageServiceContext _context;
public AdminGetFilesQueryHandler(StorageServiceContext context)
{
_context = context;
}
public async Task<AdminFilesResult> Handle(AdminGetFilesQuery request, CancellationToken cancellationToken)
{
// EN: Include deleted files for admin / VI: Bao gồm files đã xóa cho admin
var query = _context.StorageFiles
.IgnoreQueryFilters()
.AsNoTracking();
// EN: Apply filters / VI: Áp dụng filters
if (!string.IsNullOrEmpty(request.UserId))
{
query = query.Where(f => f.UserId == request.UserId);
}
if (!string.IsNullOrEmpty(request.AccessLevel))
{
query = query.Where(f => f.AccessLevel.ToString() == request.AccessLevel);
}
if (!string.IsNullOrEmpty(request.ContentType))
{
query = query.Where(f => f.ContentType.Contains(request.ContentType));
}
if (request.UploadedAfter.HasValue)
{
query = query.Where(f => f.UploadedAt >= request.UploadedAfter.Value);
}
if (request.UploadedBefore.HasValue)
{
query = query.Where(f => f.UploadedAt <= request.UploadedBefore.Value);
}
// EN: Get total count / VI: Lấy tổng số
var totalCount = await query.CountAsync(cancellationToken);
// EN: Apply sorting / VI: Áp dụng sắp xếp
query = request.SortBy?.ToLower() switch
{
"filesizebytes" => request.Descending
? query.OrderByDescending(f => f.FileSizeBytes)
: query.OrderBy(f => f.FileSizeBytes),
"uploadedat" => request.Descending
? query.OrderByDescending(f => f.UploadedAt)
: query.OrderBy(f => f.UploadedAt),
"filename" => request.Descending
? query.OrderByDescending(f => f.FileName)
: query.OrderBy(f => f.FileName),
_ => query.OrderByDescending(f => f.UploadedAt)
};
// EN: Apply pagination / VI: Áp dụng phân trang
var skip = (request.PageNumber - 1) * request.PageSize;
var items = await query
.Skip(skip)
.Take(request.PageSize)
.Select(f => new AdminFileDto(
f.Id,
f.UserId,
f.FileName,
f.ContentType,
f.FileSizeBytes,
f.Provider.ToString(),
f.AccessLevel.ToString(),
null, // FolderId - not tracked in StorageFile directly
f.UploadedAt,
f.LastAccessedAt,
f.IsDeleted))
.ToListAsync(cancellationToken);
var totalPages = (int)Math.Ceiling((double)totalCount / request.PageSize);
return new AdminFilesResult(items, totalCount, request.PageNumber, request.PageSize, totalPages);
}
}
/// <summary>
/// EN: Handler for AdminGetSharesQuery.
/// VI: Handler cho AdminGetSharesQuery.
/// </summary>
public class AdminGetSharesQueryHandler : IRequestHandler<AdminGetSharesQuery, AdminSharesResult>
{
private readonly StorageServiceContext _context;
public AdminGetSharesQueryHandler(StorageServiceContext context)
{
_context = context;
}
public async Task<AdminSharesResult> Handle(AdminGetSharesQuery request, CancellationToken cancellationToken)
{
var query = _context.FileShares.AsNoTracking();
// EN: Apply filters / VI: Áp dụng filters
if (!string.IsNullOrEmpty(request.Status))
{
query = query.Where(s => s.Status.ToString() == request.Status);
}
if (!string.IsNullOrEmpty(request.SharedBy))
{
query = query.Where(s => s.SharedBy == request.SharedBy);
}
// EN: Get total count / VI: Lấy tổng số
var totalCount = await query.CountAsync(cancellationToken);
// EN: Apply pagination / VI: Áp dụng phân trang
var skip = (request.PageNumber - 1) * request.PageSize;
var items = await query
.OrderByDescending(s => s.CreatedAt)
.Skip(skip)
.Take(request.PageSize)
.Select(s => new AdminShareDto(
s.Id,
s.FileId,
s.SharedBy,
s.SharedWith,
s.Permission.ToString(),
s.Status.ToString(),
s.DownloadCount,
s.MaxDownloads,
s.ExpiresAt,
s.CreatedAt,
s.RevokedAt))
.ToListAsync(cancellationToken);
var totalPages = (int)Math.Ceiling((double)totalCount / request.PageSize);
return new AdminSharesResult(items, totalCount, request.PageNumber, request.PageSize, totalPages);
}
}

View File

@@ -0,0 +1,95 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StorageService.API.Application.Commands.Admin;
using StorageService.API.Application.Queries.Admin;
using Swashbuckle.AspNetCore.Annotations;
using System.Security.Claims;
using Asp.Versioning;
namespace StorageService.API.Controllers.Admin;
/// <summary>
/// EN: Admin controller for file management.
/// VI: Controller admin cho quản lý files.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/admin/files")]
[Authorize(Roles = "Admin,SuperAdmin")]
[SwaggerTag("Admin File Management - View and manage all user files")]
public class AdminFilesController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<AdminFilesController> _logger;
public AdminFilesController(IMediator mediator, ILogger<AdminFilesController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get all files with pagination.
/// VI: Lấy tất cả files với phân trang.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Get all files", Description = "Get all files across all users with filtering")]
[SwaggerResponse(200, "Files retrieved successfully")]
[SwaggerResponse(403, "Forbidden - Admin role required")]
public async Task<ActionResult<ApiResponse<AdminFilesResult>>> GetAll(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? userId = null,
[FromQuery] string? accessLevel = null,
[FromQuery] string? contentType = null,
[FromQuery] DateTime? uploadedAfter = null,
[FromQuery] DateTime? uploadedBefore = null,
[FromQuery] string? sortBy = null,
[FromQuery] bool descending = false,
CancellationToken cancellationToken = default)
{
var query = new AdminGetFilesQuery(
pageNumber, pageSize, userId, accessLevel, contentType,
uploadedAfter, uploadedBefore, sortBy, descending);
var result = await _mediator.Send(query, cancellationToken);
return Ok(new ApiResponse<AdminFilesResult> { Success = true, Data = result });
}
/// <summary>
/// EN: Delete a file (admin action).
/// VI: Xóa file (hành động admin).
/// </summary>
[HttpDelete("{fileId:guid}")]
[SwaggerOperation(Summary = "Delete file", Description = "Delete a file as admin (bypasses ownership check)")]
[SwaggerResponse(200, "File deleted successfully")]
[SwaggerResponse(404, "File not found")]
public async Task<ActionResult<ApiResponse<AdminDeleteFileResult>>> Delete(
Guid fileId,
[FromBody] AdminDeleteRequest request,
CancellationToken cancellationToken = default)
{
var adminUserId = GetUserId();
if (string.IsNullOrEmpty(adminUserId))
return Unauthorized(new ApiResponse<AdminDeleteFileResult> { Success = false, Error = "Admin ID not found" });
_logger.LogWarning("Admin {AdminId} deleting file {FileId} for reason: {Reason}", adminUserId, fileId, request.Reason);
var command = new AdminDeleteFileCommand(fileId, adminUserId, request.Reason);
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
return NotFound(new ApiResponse<AdminDeleteFileResult> { Success = false, Error = result.Error });
return Ok(new ApiResponse<AdminDeleteFileResult> { Success = true, Data = result });
}
private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier);
}
/// <summary>
/// EN: Request model for admin delete action.
/// VI: Request model cho hành động xóa bởi admin.
/// </summary>
public record AdminDeleteRequest(string Reason);

View File

@@ -0,0 +1,112 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StorageService.API.Application.Commands.Admin;
using StorageService.API.Application.Queries.Admin;
using Swashbuckle.AspNetCore.Annotations;
using System.Security.Claims;
using Asp.Versioning;
namespace StorageService.API.Controllers.Admin;
/// <summary>
/// EN: Admin controller for quota management.
/// VI: Controller admin cho quản lý quota.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/admin/quotas")]
[Authorize(Roles = "Admin,SuperAdmin")]
[SwaggerTag("Admin Quota Management - View and manage user storage quotas")]
public class AdminQuotaController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<AdminQuotaController> _logger;
public AdminQuotaController(IMediator mediator, ILogger<AdminQuotaController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get all users' quotas with pagination.
/// VI: Lấy quota của tất cả users với phân trang.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Get all quotas", Description = "Get all users' storage quotas with pagination and filtering")]
[SwaggerResponse(200, "Quotas retrieved successfully")]
[SwaggerResponse(403, "Forbidden - Admin role required")]
public async Task<ActionResult<ApiResponse<AllUsersQuotaResult>>> GetAll(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? quotaTier = null,
[FromQuery] double? minUsagePercentage = null,
[FromQuery] string? sortBy = null,
[FromQuery] bool descending = false,
CancellationToken cancellationToken = default)
{
var query = new GetAllUsersQuotaQuery(pageNumber, pageSize, quotaTier, minUsagePercentage, sortBy, descending);
var result = await _mediator.Send(query, cancellationToken);
return Ok(new ApiResponse<AllUsersQuotaResult> { Success = true, Data = result });
}
/// <summary>
/// EN: Get quota by user ID.
/// VI: Lấy quota theo user ID.
/// </summary>
[HttpGet("{userId}")]
[SwaggerOperation(Summary = "Get user quota", Description = "Get storage quota for a specific user")]
[SwaggerResponse(200, "Quota retrieved successfully")]
[SwaggerResponse(404, "User quota not found")]
public async Task<ActionResult<ApiResponse<AdminQuotaDto>>> GetByUserId(
string userId,
CancellationToken cancellationToken = default)
{
var query = new GetAllUsersQuotaQuery(1, 1);
var result = await _mediator.Send(query, cancellationToken);
var quota = result.Items.FirstOrDefault(q => q.UserId == userId);
if (quota == null)
return NotFound(new ApiResponse<AdminQuotaDto> { Success = false, Error = "Quota not found for user" });
return Ok(new ApiResponse<AdminQuotaDto> { Success = true, Data = quota });
}
/// <summary>
/// EN: Update user quota limits.
/// VI: Cập nhật giới hạn quota của user.
/// </summary>
[HttpPut("{userId}")]
[SwaggerOperation(Summary = "Update user quota", Description = "Update storage quota limits for a user")]
[SwaggerResponse(200, "Quota updated successfully")]
[SwaggerResponse(400, "Invalid request or business rule violation")]
public async Task<ActionResult<ApiResponse<QuotaUpdatedDto>>> Update(
string userId,
[FromBody] UpdateQuotaRequest request,
CancellationToken cancellationToken = default)
{
var adminUserId = GetUserId();
_logger.LogInformation("Admin {AdminId} updating quota for user {UserId}", adminUserId, userId);
var command = new UpdateUserQuotaCommand(userId, request.MaxStorageBytes, request.MaxFileCount, request.QuotaTier);
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
return BadRequest(new ApiResponse<QuotaUpdatedDto> { Success = false, Error = result.Error });
return Ok(new ApiResponse<QuotaUpdatedDto> { Success = true, Data = result.Data });
}
private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier);
}
/// <summary>
/// EN: Request model for updating user quota.
/// VI: Request model để cập nhật quota user.
/// </summary>
public record UpdateQuotaRequest(
long MaxStorageBytes,
int MaxFileCount,
string? QuotaTier = null);

View File

@@ -0,0 +1,88 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StorageService.API.Application.Commands.Admin;
using StorageService.API.Application.Queries.Admin;
using Swashbuckle.AspNetCore.Annotations;
using System.Security.Claims;
using Asp.Versioning;
namespace StorageService.API.Controllers.Admin;
/// <summary>
/// EN: Admin controller for file share management.
/// VI: Controller admin cho quản lý chia sẻ files.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/admin/shares")]
[Authorize(Roles = "Admin,SuperAdmin")]
[SwaggerTag("Admin Share Management - View and manage file shares")]
public class AdminSharesController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<AdminSharesController> _logger;
public AdminSharesController(IMediator mediator, ILogger<AdminSharesController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get all file shares with pagination.
/// VI: Lấy tất cả file shares với phân trang.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Get all shares", Description = "Get all file shares with filtering")]
[SwaggerResponse(200, "Shares retrieved successfully")]
[SwaggerResponse(403, "Forbidden - Admin role required")]
public async Task<ActionResult<ApiResponse<AdminSharesResult>>> GetAll(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? status = null,
[FromQuery] string? sharedBy = null,
CancellationToken cancellationToken = default)
{
var query = new AdminGetSharesQuery(pageNumber, pageSize, status, sharedBy);
var result = await _mediator.Send(query, cancellationToken);
return Ok(new ApiResponse<AdminSharesResult> { Success = true, Data = result });
}
/// <summary>
/// EN: Revoke a file share (admin action).
/// VI: Thu hồi chia sẻ file (hành động admin).
/// </summary>
[HttpDelete("{shareId:guid}")]
[SwaggerOperation(Summary = "Revoke share", Description = "Revoke a file share as admin")]
[SwaggerResponse(200, "Share revoked successfully")]
[SwaggerResponse(404, "Share not found")]
public async Task<ActionResult<ApiResponse<AdminRevokeShareResult>>> Revoke(
Guid shareId,
[FromBody] AdminRevokeRequest request,
CancellationToken cancellationToken = default)
{
var adminUserId = GetUserId();
if (string.IsNullOrEmpty(adminUserId))
return Unauthorized(new ApiResponse<AdminRevokeShareResult> { Success = false, Error = "Admin ID not found" });
_logger.LogWarning("Admin {AdminId} revoking share {ShareId} for reason: {Reason}", adminUserId, shareId, request.Reason);
var command = new AdminRevokeShareCommand(shareId, adminUserId, request.Reason);
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
return NotFound(new ApiResponse<AdminRevokeShareResult> { Success = false, Error = result.Error });
return Ok(new ApiResponse<AdminRevokeShareResult> { Success = true, Data = result });
}
private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier);
}
/// <summary>
/// EN: Request model for admin revoke action.
/// VI: Request model cho hành động thu hồi bởi admin.
/// </summary>
public record AdminRevokeRequest(string Reason);

View File

@@ -0,0 +1,68 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StorageService.API.Application.Queries.Admin;
using Swashbuckle.AspNetCore.Annotations;
using Asp.Versioning;
namespace StorageService.API.Controllers.Admin;
/// <summary>
/// EN: Admin controller for storage statistics.
/// VI: Controller admin cho thống kê storage.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/admin/statistics")]
[Authorize(Roles = "Admin,SuperAdmin")]
[SwaggerTag("Admin Statistics - Dashboard statistics for storage")]
public class AdminStatisticsController : ControllerBase
{
private readonly IMediator _mediator;
public AdminStatisticsController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// EN: Get storage statistics.
/// VI: Lấy thống kê storage.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Get statistics", Description = "Get aggregated storage statistics for dashboard")]
[SwaggerResponse(200, "Statistics retrieved successfully")]
[SwaggerResponse(403, "Forbidden - Admin role required")]
public async Task<ActionResult<ApiResponse<StorageStatisticsDto>>> GetStatistics(
CancellationToken cancellationToken = default)
{
var query = new GetStorageStatisticsQuery();
var result = await _mediator.Send(query, cancellationToken);
return Ok(new ApiResponse<StorageStatisticsDto> { Success = true, Data = result });
}
/// <summary>
/// EN: Get users near storage limit.
/// VI: Lấy users gần hết quota.
/// </summary>
[HttpGet("users-near-limit")]
[SwaggerOperation(Summary = "Get users near limit", Description = "Get users with usage >= 80%")]
[SwaggerResponse(200, "Users retrieved successfully")]
public async Task<ActionResult<ApiResponse<AllUsersQuotaResult>>> GetUsersNearLimit(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var query = new GetAllUsersQuotaQuery(
pageNumber, pageSize,
null, // quotaTier
80, // minUsagePercentage = 80%
"usedStorageBytes",
true // descending - highest usage first
);
var result = await _mediator.Send(query, cancellationToken);
return Ok(new ApiResponse<AllUsersQuotaResult> { Success = true, Data = result });
}
}

View File

@@ -9,8 +9,8 @@ COPY ["src/WalletService.Domain/WalletService.Domain.csproj", "src/WalletService
COPY ["src/WalletService.Infrastructure/WalletService.Infrastructure.csproj", "src/WalletService.Infrastructure/"]
COPY ["Directory.Build.props", "./"]
# EN: Restore dependencies
# VI: Khôi phục dependencies
# EN: Restore dependencies for all projects
# VI: Khôi phục dependencies cho tất cả projects
RUN dotnet restore "src/WalletService.API/WalletService.API.csproj"
# EN: Copy all source code
@@ -19,12 +19,12 @@ COPY src/ ./src/
# EN: Build the application
# VI: Build ứng dụng
WORKDIR "/src/src/WalletService.API"
RUN dotnet build "WalletService.API.csproj" -c Release -o /app/build --no-restore
WORKDIR /src/src/WalletService.API
RUN dotnet build "WalletService.API.csproj" -c Release -o /app/build
# Publish stage / Giai đoạn publish
FROM build AS publish
RUN dotnet publish "WalletService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore
RUN dotnet publish "WalletService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Runtime stage / Giai đoạn runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final

View File

@@ -1,72 +0,0 @@
version: '3.8'
# EN: Docker Compose for local development
# VI: Docker Compose cho phát triển local
services:
myservice-api:
build:
context: .
dockerfile: Dockerfile
container_name: myservice-api
ports:
- "5000:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
- REDIS_URL=redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- myservice-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
postgres:
image: postgres:16-alpine
container_name: myservice-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myservice_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- myservice-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: myservice-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- myservice-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
networks:
myservice-network:
driver: bridge

View File

@@ -6,7 +6,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5000",
"applicationUrl": "http://localhost:5004",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View File

@@ -30,7 +30,7 @@
]
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres"
"DefaultConnection": "Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Database=wallet_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require"
},
"Redis": {
"ConnectionString": "localhost:6379"