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:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace IamService.Infrastructure.Authorization;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -6,7 +6,7 @@
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"applicationUrl": "http://localhost:5004",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user