diff --git a/README.md b/README.md index 728fc99..bcc86cc 100644 --- a/README.md +++ b/README.md @@ -134,17 +134,17 @@ Endpoints for managing user profiles and accounts. | `PUT` | `/manage/users/{id}` | `RequireTenantManager` | Activate/Deactivate user | | `DELETE` | `/manage/users/{id}` | `RequireTenantManager` | Delete a user | -### System & Tenant Access (`/sys`) +### System & Tenant Access (`/admin`) System-level endpoints for managing tenant access. -* **Authorization**: `RequireSysUser` (SysAdmin or SysSupport roles). +* **Authorization**: `RequireAdminUser` (SysAdmin or SysSupport roles). | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | `/sys/info` | Get system version and environment info | -| `GET` | `/sys/users/{id}/tenants` | List tenants accessible by a user | -| `POST` | `/sys/users/{id}/tenants/{tenantId}` | Grant user access to a tenant | -| `DELETE` | `/sys/users/{id}/tenants/{tenantId}` | Revoke user access to a tenant | +| `GET` | `/admin/info` | Get system version and environment info | +| `GET` | `/admin/users/{id}/tenants` | List tenants accessible by a user | +| `POST` | `/admin/users/{id}/tenants/{tenantId}` | Grant user access to a tenant | +| `DELETE` | `/admin/users/{id}/tenants/{tenantId}` | Revoke user access to a tenant | ## Authorization Policies diff --git a/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs b/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs index 9e4a7b0..4cd2a27 100644 --- a/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs @@ -2,6 +2,7 @@ using Finbuckle.MultiTenant.AspNetCore.Extensions; using Idmt.Plugin.Configuration; using Idmt.Plugin.Features; +using Idmt.Plugin.Features.Admin; using Idmt.Plugin.Middleware; using Idmt.Plugin.Models; using Idmt.Plugin.Persistence; @@ -76,7 +77,7 @@ public static IEndpointRouteBuilder MapIdmtEndpoints(this IEndpointRouteBuilder { endpoints.MapAuthEndpoints(); endpoints.MapAuthManageEndpoints(); - endpoints.MapSysEndpoints(); + endpoints.MapAdminEndpoints(); endpoints.MapHealthChecks("/healthz").RequireAuthorization(AuthOptions.RequireSysUserPolicy); return endpoints; } @@ -162,8 +163,8 @@ public static async Task SeedIdmtDataAsync(this IApplicatio private static async Task SeedDefaultDataAsync(IServiceProvider services) { var options = services.GetRequiredService>(); - var createTenantHandler = services.GetRequiredService(); - await createTenantHandler.HandleAsync(new Features.Sys.CreateTenant.CreateTenantRequest( + var createTenantHandler = services.GetRequiredService(); + await createTenantHandler.HandleAsync(new CreateTenant.CreateTenantRequest( MultiTenantOptions.DefaultTenantIdentifier, MultiTenantOptions.DefaultTenantIdentifier, options.Value.MultiTenant.DefaultTenantDisplayName)); diff --git a/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs b/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs index 4603109..7f70089 100644 --- a/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs +++ b/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions; using Finbuckle.MultiTenant.Extensions; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Features.Admin; using Idmt.Plugin.Features.Auth; using Idmt.Plugin.Features.Manage; using Idmt.Plugin.Middleware; @@ -401,12 +402,13 @@ private static void RegisterFeatures(IServiceCollection services) services.AddScoped(); services.AddScoped(); - // Sys - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + // Admin + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Health services.AddHealthChecks() diff --git a/src/Idmt.Plugin/Features/Sys/CreateTenant.cs b/src/Idmt.Plugin/Features/Admin/CreateTenant.cs similarity index 95% rename from src/Idmt.Plugin/Features/Sys/CreateTenant.cs rename to src/Idmt.Plugin/Features/Admin/CreateTenant.cs index 7be78ff..be18c50 100644 --- a/src/Idmt.Plugin/Features/Sys/CreateTenant.cs +++ b/src/Idmt.Plugin/Features/Admin/CreateTenant.cs @@ -1,6 +1,7 @@ using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Configuration; using Idmt.Plugin.Models; +using Idmt.Plugin.Validation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; @@ -11,7 +12,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Idmt.Plugin.Features.Sys; +namespace Idmt.Plugin.Features.Admin; public static class CreateTenant { @@ -147,6 +148,10 @@ private async Task GuaranteeTenantRolesAsync(IdmtTenantInfo tenantInfo) { errors["Identifier"] = ["Identifier is required"]; } + else if (!Validators.IsValidTenantIdentifier(request.Identifier)) + { + errors["Identifier"] = ["Identifier can only contain lowercase alphanumeric characters, dashes, and underscores"]; + } if (string.IsNullOrEmpty(request.Name)) { errors["Name"] = ["Name is required"]; @@ -177,7 +182,7 @@ public static RouteHandlerBuilder MapCreateTenantEndpoint(this IEndpointRouteBui } return TypedResults.Ok(response.Value); }) - .RequireAuthorization(AuthOptions.RequireSysAdminPolicy) + .RequireAuthorization(AuthOptions.RequireSysUserPolicy) .WithSummary("Create Tenant") .WithDescription("Create a new tenant in the system or reactivate an existing inactive tenant"); } diff --git a/src/Idmt.Plugin/Features/Sys/DeleteTenant.cs b/src/Idmt.Plugin/Features/Admin/DeleteTenant.cs similarity index 83% rename from src/Idmt.Plugin/Features/Sys/DeleteTenant.cs rename to src/Idmt.Plugin/Features/Admin/DeleteTenant.cs index 98720c3..080cc8b 100644 --- a/src/Idmt.Plugin/Features/Sys/DeleteTenant.cs +++ b/src/Idmt.Plugin/Features/Admin/DeleteTenant.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; -namespace Idmt.Plugin.Features.Sys; +namespace Idmt.Plugin.Features.Admin; public static class DeleteTenant { @@ -25,6 +25,10 @@ public async Task HandleAsync(string tenantIdentifier, CancellationToken { try { + if (string.Compare(tenantIdentifier, MultiTenantOptions.DefaultTenantIdentifier, StringComparison.OrdinalIgnoreCase) == 0) + { + return Result.Failure("Cannot delete the default tenant", StatusCodes.Status403Forbidden); + } var tenant = await tenantStore.GetByIdentifierAsync(tenantIdentifier); if (tenant is null) { @@ -48,7 +52,7 @@ public async Task HandleAsync(string tenantIdentifier, CancellationToken public static RouteHandlerBuilder MapDeleteTenantEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapDelete("/tenants/{tenantIdentifier}", async Task> ( + return endpoints.MapDelete("/tenants/{tenantIdentifier}", async Task> ( [FromRoute] string tenantIdentifier, [FromServices] IDeleteTenantHandler handler, CancellationToken cancellationToken = default) => @@ -58,13 +62,14 @@ public static RouteHandlerBuilder MapDeleteTenantEndpoint(this IEndpointRouteBui { return result.StatusCode switch { + StatusCodes.Status403Forbidden => TypedResults.Forbid(), StatusCodes.Status404NotFound => TypedResults.NotFound(), _ => TypedResults.InternalServerError(), }; } return TypedResults.NoContent(); }) - .RequireAuthorization(AuthOptions.RequireSysUserPolicy) + .RequireAuthorization(AuthOptions.RequireSysAdminPolicy) .WithSummary("Delete tenant") .WithDescription("Soft deletes a tenant by its identifier"); } diff --git a/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs b/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs new file mode 100644 index 0000000..6ee7193 --- /dev/null +++ b/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs @@ -0,0 +1,62 @@ +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Configuration; +using Idmt.Plugin.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; + +namespace Idmt.Plugin.Features.Admin; + +public static class GetAllTenants +{ + public interface IGetAllTenantsHandler + { + Task> HandleAsync(CancellationToken cancellationToken = default); + } + + internal sealed class GetAllTenantsHandler( + IMultiTenantStore tenantStore, + ILogger logger) : IGetAllTenantsHandler + { + public async Task> HandleAsync(CancellationToken cancellationToken = default) + { + try + { + var tenants = await tenantStore.GetAllAsync(); + var res = tenants.Where(t => t is not null).Select(t => new TenantInfoResponse( + t!.Id ?? string.Empty, + t.Identifier ?? string.Empty, + t.Name ?? string.Empty, + t.DisplayName ?? string.Empty, + t.Plan ?? string.Empty, + t.IsActive)).ToArray(); + + return Result.Success(res); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while retrieving all tenants"); + return Result.Failure($"An error occurred while retrieving tenants: {ex.Message}", StatusCodes.Status500InternalServerError); + } + } + } + + public static RouteHandlerBuilder MapGetAllTenantsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/tenants", async Task, InternalServerError>> ( + IGetAllTenantsHandler handler, + CancellationToken cancellationToken) => + { + var result = await handler.HandleAsync(cancellationToken); + if (!result.IsSuccess) + { + return TypedResults.InternalServerError(); + } + return TypedResults.Ok(result.Value!); + }) + .RequireAuthorization(AuthOptions.RequireSysUserPolicy) + .WithSummary("Get tenants accessible by user"); + } +} \ No newline at end of file diff --git a/src/Idmt.Plugin/Features/Sys/GetSystemInfo.cs b/src/Idmt.Plugin/Features/Admin/GetSystemInfo.cs similarity index 98% rename from src/Idmt.Plugin/Features/Sys/GetSystemInfo.cs rename to src/Idmt.Plugin/Features/Admin/GetSystemInfo.cs index 82eae2f..5f00e7b 100644 --- a/src/Idmt.Plugin/Features/Sys/GetSystemInfo.cs +++ b/src/Idmt.Plugin/Features/Admin/GetSystemInfo.cs @@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -namespace Idmt.Plugin.Features.Sys; +namespace Idmt.Plugin.Features.Admin; public static class GetSystemInfo { diff --git a/src/Idmt.Plugin/Features/Sys/GetUserTenants.cs b/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs similarity index 90% rename from src/Idmt.Plugin/Features/Sys/GetUserTenants.cs rename to src/Idmt.Plugin/Features/Admin/GetUserTenants.cs index 79ee18c..e13a4d8 100644 --- a/src/Idmt.Plugin/Features/Sys/GetUserTenants.cs +++ b/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs @@ -9,18 +9,19 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -namespace Idmt.Plugin.Features.Sys; +namespace Idmt.Plugin.Features.Admin; + +public sealed record TenantInfoResponse( + string Id, + string Identifier, + string Name, + string DisplayName, + string Plan, + bool IsActive +); public static class GetUserTenants { - public sealed record TenantInfoResponse( - string Id, - string Identifier, - string Name, - string DisplayName, - string Plan - ); - public interface IGetUserTenantsHandler { Task> HandleAsync(Guid userId, CancellationToken cancellationToken = default); @@ -48,7 +49,8 @@ public async Task> HandleAsync(Guid userId, Cancell t.Identifier ?? string.Empty, t.Name ?? string.Empty, t.DisplayName ?? string.Empty, - t.Plan ?? string.Empty)).ToArray(); + t.Plan ?? string.Empty, + t.IsActive)).ToArray(); return Result.Success(res); } diff --git a/src/Idmt.Plugin/Features/Sys/GrantTenantAccess.cs b/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs similarity index 99% rename from src/Idmt.Plugin/Features/Sys/GrantTenantAccess.cs rename to src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs index 00c800e..2b1b71c 100644 --- a/src/Idmt.Plugin/Features/Sys/GrantTenantAccess.cs +++ b/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs @@ -12,7 +12,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Idmt.Plugin.Features.Sys; +namespace Idmt.Plugin.Features.Admin; public static class GrantTenantAccess { diff --git a/src/Idmt.Plugin/Features/Sys/RevokeTenantAccess.cs b/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs similarity index 99% rename from src/Idmt.Plugin/Features/Sys/RevokeTenantAccess.cs rename to src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs index a0bc9c1..7d04ed2 100644 --- a/src/Idmt.Plugin/Features/Sys/RevokeTenantAccess.cs +++ b/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs @@ -11,7 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Idmt.Plugin.Features.Sys; +namespace Idmt.Plugin.Features.Admin; public static class RevokeTenantAccess { diff --git a/src/Idmt.Plugin/Features/AdminEndpoints.cs b/src/Idmt.Plugin/Features/AdminEndpoints.cs new file mode 100644 index 0000000..1286941 --- /dev/null +++ b/src/Idmt.Plugin/Features/AdminEndpoints.cs @@ -0,0 +1,23 @@ +using Idmt.Plugin.Features.Admin; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Idmt.Plugin.Features; + +public static class AdminEndpoints +{ + public static void MapAdminEndpoints(this IEndpointRouteBuilder endpoints) + { + var admin = endpoints.MapGroup("/admin") + .WithTags("Admin"); + + admin.MapCreateTenantEndpoint(); + admin.MapDeleteTenantEndpoint(); + admin.MapGetUserTenantsEndpoint(); + admin.MapGrantTenantAccessEndpoint(); + admin.MapRevokeTenantAccessEndpoint(); + admin.MapGetSystemInfoEndpoint(); + admin.MapGetAllTenantsEndpoint(); + } +} \ No newline at end of file diff --git a/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs b/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs index 1636f27..b9f05c4 100644 --- a/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs +++ b/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs @@ -1,4 +1,3 @@ -using System.Text.Encodings.Web; using Idmt.Plugin.Models; using Idmt.Plugin.Services; using Idmt.Plugin.Validation; diff --git a/src/Idmt.Plugin/Features/SysEndpoints.cs b/src/Idmt.Plugin/Features/SysEndpoints.cs deleted file mode 100644 index d92715d..0000000 --- a/src/Idmt.Plugin/Features/SysEndpoints.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Idmt.Plugin.Features.Sys; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace Idmt.Plugin.Features; - -public static class SysEndpoints -{ - public static void MapSysEndpoints(this IEndpointRouteBuilder endpoints) - { - var sys = endpoints.MapGroup("/sys") - .WithTags("System"); - - sys.MapCreateTenantEndpoint(); - sys.MapDeleteTenantEndpoint(); - sys.MapGetUserTenantsEndpoint(); - sys.MapGrantTenantAccessEndpoint(); - sys.MapRevokeTenantAccessEndpoint(); - sys.MapGetSystemInfoEndpoint(); - } -} \ No newline at end of file diff --git a/src/Idmt.Plugin/Validation/Validators.cs b/src/Idmt.Plugin/Validation/Validators.cs index c456137..fd30ae0 100644 --- a/src/Idmt.Plugin/Validation/Validators.cs +++ b/src/Idmt.Plugin/Validation/Validators.cs @@ -23,6 +23,9 @@ public static partial class Validators [GeneratedRegex(@"[^a-zA-Z0-9]", RegexOptions.Compiled)] public static partial Regex OneNonAlphaRegex(); + [GeneratedRegex("^[a-z0-9-_]+$", RegexOptions.Compiled)] + public static partial Regex ValidIdentifier(); + /// /// Validates an email address. /// @@ -117,4 +120,14 @@ public static bool IsValidUsername(string? username) { return !string.IsNullOrWhiteSpace(username) && username.Length >= 3; } + + /// + /// Validates a tenant identifier (lowercase alphanumeric, dashes, underscores). + /// + /// The tenant identifier to validate. + /// True if the identifier is valid, false otherwise. + public static bool IsValidTenantIdentifier(string? identifier) + { + return !string.IsNullOrWhiteSpace(identifier) && ValidIdentifier().IsMatch(identifier); + } } \ No newline at end of file diff --git a/src/samples/Idmt.BasicSample/wwwroot/README.md b/src/samples/Idmt.BasicSample/wwwroot/README.md index 77d9462..6f226c1 100644 --- a/src/samples/Idmt.BasicSample/wwwroot/README.md +++ b/src/samples/Idmt.BasicSample/wwwroot/README.md @@ -117,15 +117,15 @@ The application supports multiple tenant resolution strategies: | `PUT /manage/users/{id}` | Update user (activate/deactivate) | Yes | RequireTenantManager | | `DELETE /manage/users/{id}` | Delete user | Yes | RequireTenantManager | -### System (`/sys`) +### System (`/admin`) | Endpoint | Description | Auth Required | Policy | |----------|-------------|---------------|--------| -| `GET /sys/info` | Get system information | Yes | Authenticated | +| `GET /admin/info` | Get system information | Yes | Authenticated | | `GET /healthz` | Health check | Yes | Authenticated | -| `GET /sys/users/{id}/tenants` | List user's tenants | Yes | RequireSysUser | -| `POST /sys/users/{id}/tenants/{tenantId}` | Grant tenant access | Yes | RequireSysUser | -| `DELETE /sys/users/{id}/tenants/{tenantId}` | Revoke tenant access | Yes | RequireSysUser | +| `GET /admin/users/{id}/tenants` | List user's tenants | Yes | RequireAdminUser | +| `POST /admin/users/{id}/tenants/{tenantId}` | Grant tenant access | Yes | RequireAdminUser | +| `DELETE /admin/users/{id}/tenants/{tenantId}` | Revoke tenant access | Yes | RequireAdminUser | ## Typical Testing Workflow diff --git a/src/samples/Idmt.BasicSample/wwwroot/index.html b/src/samples/Idmt.BasicSample/wwwroot/index.html index 97eb295..f4408d2 100644 --- a/src/samples/Idmt.BasicSample/wwwroot/index.html +++ b/src/samples/Idmt.BasicSample/wwwroot/index.html @@ -229,10 +229,10 @@

DELETE /manage/users/{id}

-

System (/sys)

+

System (/admin)

-

GET /sys/info

+

GET /admin/info

@@ -242,7 +242,7 @@

GET /healthz

-

POST /sys/tenants (Create Tenant)

+

POST /admin/tenants (Create Tenant)

@@ -256,7 +256,7 @@

POST /sys/tenants (Create Tenant)

-

DELETE /sys/tenants/{tenantIdentifier}

+

DELETE /admin/tenants/{tenantIdentifier}

@@ -264,7 +264,7 @@

DELETE /sys/tenants/{tenantIdentifier}

-

GET /sys/users/{id}/tenants

+

GET /admin/users/{id}/tenants

@@ -272,7 +272,7 @@

GET /sys/users/{id}/tenants

-

POST /sys/users/{id}/tenants/{tenantIdentifier}

+

POST /admin/users/{id}/tenants/{tenantIdentifier}

@@ -287,7 +287,7 @@

POST /sys/users/{id}/tenants/{tenantIdentifier}

-

DELETE /sys/users/{id}/tenants/{tenantIdentifier}

+

DELETE /admin/users/{id}/tenants/{tenantIdentifier}

diff --git a/src/samples/Idmt.BasicSample/wwwroot/js/api-client.js b/src/samples/Idmt.BasicSample/wwwroot/js/api-client.js index cd66a1c..2e0eaf8 100644 --- a/src/samples/Idmt.BasicSample/wwwroot/js/api-client.js +++ b/src/samples/Idmt.BasicSample/wwwroot/js/api-client.js @@ -317,7 +317,7 @@ async function deleteUser() { // ============================================ async function getSystemInfo() { - await apiRequest('/sys/info', { + await apiRequest('/admin/info', { method: 'GET' }); } @@ -342,7 +342,7 @@ async function createTenant() { body.displayName = displayName; } - await apiRequest('/sys/tenants', { + await apiRequest('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }); @@ -355,7 +355,7 @@ async function deleteTenant() { return; } - await apiRequest(`/sys/tenants/${encodeURIComponent(tenantIdentifier)}`, { + await apiRequest(`/admin/tenants/${encodeURIComponent(tenantIdentifier)}`, { method: 'DELETE' }); } @@ -363,7 +363,7 @@ async function deleteTenant() { async function getUserTenants() { const userId = document.getElementById('userTenantsId').value; - await apiRequest(`/sys/users/${encodeURIComponent(userId)}/tenants`, { + await apiRequest(`/admin/users/${encodeURIComponent(userId)}/tenants`, { method: 'GET' }); } @@ -377,7 +377,7 @@ async function grantTenantAccess() { expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null }; - await apiRequest(`/sys/users/${encodeURIComponent(userId)}/tenants/${encodeURIComponent(tenantIdentifier)}`, { + await apiRequest(`/admin/users/${encodeURIComponent(userId)}/tenants/${encodeURIComponent(tenantIdentifier)}`, { method: 'POST', body: JSON.stringify(body) }); @@ -391,7 +391,7 @@ async function revokeTenantAccess() { return; } - await apiRequest(`/sys/users/${encodeURIComponent(userId)}/tenants/${encodeURIComponent(tenantIdentifier)}`, { + await apiRequest(`/admin/users/${encodeURIComponent(userId)}/tenants/${encodeURIComponent(tenantIdentifier)}`, { method: 'DELETE' }); } diff --git a/src/tests/Idmt.BasicSample.Tests/SysIntegrationTests.cs b/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs similarity index 85% rename from src/tests/Idmt.BasicSample.Tests/SysIntegrationTests.cs rename to src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs index aa3f9df..33b429a 100644 --- a/src/tests/Idmt.BasicSample.Tests/SysIntegrationTests.cs +++ b/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs @@ -1,7 +1,7 @@ using System.Net; using System.Net.Http.Json; +using Idmt.Plugin.Features.Admin; using Idmt.Plugin.Features.Manage; -using Idmt.Plugin.Features.Sys; using Idmt.Plugin.Models; using Microsoft.Extensions.DependencyInjection; @@ -9,11 +9,11 @@ namespace Idmt.BasicSample.Tests; /// /// Integration tests for System Management endpoints. -/// Covers: /sys/tenants, /sys/users/{userId}/tenants, /sys/info, /healthz +/// Covers: /admin/tenants, /admin/users/{userId}/tenants, /admin/info, /healthz /// -public class SysIntegrationTests : BaseIntegrationTest +public class AdminIntegrationTests : BaseIntegrationTest { - public SysIntegrationTests(IdmtApiFactory factory) : base(factory) { } + public AdminIntegrationTests(IdmtApiFactory factory) : base(factory) { } #region Health Check Tests @@ -42,7 +42,7 @@ public async Task GetSystemInfo_returns_system_details() { var client = await CreateAuthenticatedClientAsync(); - var response = await client.GetAsync("/sys/info"); + var response = await client.GetAsync("/admin/info"); await response.AssertSuccess(); var sysInfo = await response.Content.ReadFromJsonAsync(); @@ -58,7 +58,7 @@ public async Task GetSystemInfo_returns_current_tenant_info() { var client = await CreateAuthenticatedClientAsync(); - var response = await client.GetAsync("/sys/info"); + var response = await client.GetAsync("/admin/info"); await response.AssertSuccess(); var sysInfo = await response.Content.ReadFromJsonAsync(); @@ -74,7 +74,7 @@ public async Task GetSystemInfo_requires_authentication() { var client = Factory.CreateClientWithTenant(); - var response = await client.GetAsync("/sys/info"); + var response = await client.GetAsync("/admin/info"); Assert.Contains(response.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden, HttpStatusCode.Found }); } @@ -170,7 +170,7 @@ public async Task GrantTenantAccess_with_valid_data_succeeds() // Grant access var grantResponse = await sysClient.PostAsJsonAsync( - $"/sys/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", + $"/admin/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", new { ExpiresAt = (DateTime?)null }); await grantResponse.AssertSuccess(); @@ -193,11 +193,11 @@ public async Task GrantTenantAccess_allows_user_to_access_tenant() // Grant access await sysClient.PostAsJsonAsync( - $"/sys/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", + $"/admin/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", new { ExpiresAt = (DateTime?)null }); // Verify user can access tenant - var tenants = await sysClient.GetFromJsonAsync($"/sys/users/{userId}/tenants"); + var tenants = await sysClient.GetFromJsonAsync($"/admin/users/{userId}/tenants"); Assert.NotNull(tenants); Assert.Contains(tenants!, t => t.Identifier == IdmtApiFactory.DefaultTenantIdentifier); } @@ -208,7 +208,7 @@ public async Task GrantTenantAccess_with_nonexistent_user_fails() var sysClient = await CreateAuthenticatedClientAsync(); var response = await sysClient.PostAsJsonAsync( - $"/sys/users/{Guid.NewGuid()}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", + $"/admin/users/{Guid.NewGuid()}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", new { ExpiresAt = (DateTime?)null }); Assert.False(response.IsSuccessStatusCode); @@ -231,7 +231,7 @@ public async Task GrantTenantAccess_with_nonexistent_tenant_fails() // Try to grant access to nonexistent tenant var response = await sysClient.PostAsJsonAsync( - $"/sys/users/{userId}/tenants/nonexistent-tenant", + $"/admin/users/{userId}/tenants/nonexistent-tenant", new { ExpiresAt = (DateTime?)null }); Assert.False(response.IsSuccessStatusCode); @@ -255,7 +255,7 @@ public async Task GrantTenantAccess_with_expiration_date_succeeds() // Grant access with expiration var expiresAt = DateTime.UtcNow.AddDays(1); var grantResponse = await sysClient.PostAsJsonAsync( - $"/sys/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", + $"/admin/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", new { ExpiresAt = expiresAt }); await grantResponse.AssertSuccess(); @@ -267,7 +267,7 @@ public async Task GrantTenantAccess_requires_authorization() var client = Factory.CreateClientWithTenant(); var response = await client.PostAsJsonAsync( - $"/sys/users/{Guid.NewGuid()}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", + $"/admin/users/{Guid.NewGuid()}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", new { ExpiresAt = (DateTime?)null }); Assert.Contains(response.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); @@ -294,11 +294,11 @@ public async Task RevokeTenantAccess_with_valid_data_succeeds() // Grant access await sysClient.PostAsJsonAsync( - $"/sys/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", + $"/admin/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", new { ExpiresAt = (DateTime?)null }); // Revoke access - var revokeResponse = await sysClient.DeleteAsync($"/sys/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); + var revokeResponse = await sysClient.DeleteAsync($"/admin/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); await revokeResponse.AssertSuccess(); } @@ -319,18 +319,18 @@ public async Task RevokeTenantAccess_removes_access() // Grant access await sysClient.PostAsJsonAsync( - $"/sys/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", + $"/admin/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", new { ExpiresAt = (DateTime?)null }); // Verify access exists - var tenantsBeforeRevoke = await sysClient.GetFromJsonAsync($"/sys/users/{userId}/tenants"); + var tenantsBeforeRevoke = await sysClient.GetFromJsonAsync($"/admin/users/{userId}/tenants"); Assert.Contains(tenantsBeforeRevoke!, t => t.Identifier == IdmtApiFactory.DefaultTenantIdentifier); // Revoke access - await sysClient.DeleteAsync($"/sys/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); + await sysClient.DeleteAsync($"/admin/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); // Verify access is removed - var tenantsAfterRevoke = await sysClient.GetFromJsonAsync($"/sys/users/{userId}/tenants"); + var tenantsAfterRevoke = await sysClient.GetFromJsonAsync($"/admin/users/{userId}/tenants"); Assert.DoesNotContain(tenantsAfterRevoke!, t => t.Identifier == IdmtApiFactory.DefaultTenantIdentifier); } @@ -339,7 +339,7 @@ public async Task RevokeTenantAccess_with_nonexistent_user_fails() { var sysClient = await CreateAuthenticatedClientAsync(); - var response = await sysClient.DeleteAsync($"/sys/users/{Guid.NewGuid()}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); + var response = await sysClient.DeleteAsync($"/admin/users/{Guid.NewGuid()}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); Assert.False(response.IsSuccessStatusCode); } @@ -348,7 +348,7 @@ public async Task RevokeTenantAccess_requires_authorization() { var client = Factory.CreateClientWithTenant(); - var response = await client.DeleteAsync($"/sys/users/{Guid.NewGuid()}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); + var response = await client.DeleteAsync($"/admin/users/{Guid.NewGuid()}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); Assert.Contains(response.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); } @@ -373,14 +373,14 @@ public async Task GetUserTenants_returns_user_accessible_tenants() // Grant access to a tenant await sysClient.PostAsJsonAsync( - $"/sys/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", + $"/admin/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", new { ExpiresAt = (DateTime?)null }); // Get user tenants - var response = await sysClient.GetAsync($"/sys/users/{userId}/tenants"); + var response = await sysClient.GetAsync($"/admin/users/{userId}/tenants"); await response.AssertSuccess(); - var tenants = await response.Content.ReadFromJsonAsync(); + var tenants = await response.Content.ReadFromJsonAsync(); Assert.NotNull(tenants); Assert.NotEmpty(tenants); Assert.Contains(tenants!, t => t.Identifier == IdmtApiFactory.DefaultTenantIdentifier); @@ -402,10 +402,10 @@ public async Task GetUserTenants_returns_empty_for_user_without_access() var userId = Guid.Parse((await registerResponse.Content.ReadFromJsonAsync())!.UserId!); // Get user tenants - var response = await sysClient.GetAsync($"/sys/users/{userId}/tenants"); + var response = await sysClient.GetAsync($"/admin/users/{userId}/tenants"); await response.AssertSuccess(); - var tenants = await response.Content.ReadFromJsonAsync(); + var tenants = await response.Content.ReadFromJsonAsync(); Assert.NotNull(tenants); Assert.Empty(tenants!); } @@ -415,7 +415,7 @@ public async Task GetUserTenants_with_nonexistent_user_succeeds_empty() { var sysClient = await CreateAuthenticatedClientAsync(); - var response = await sysClient.GetAsync($"/sys/users/{Guid.NewGuid()}/tenants"); + var response = await sysClient.GetAsync($"/admin/users/{Guid.NewGuid()}/tenants"); // May return 200 with empty or 404 Assert.True(response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NotFound); } @@ -425,7 +425,7 @@ public async Task GetUserTenants_requires_authorization() { var client = Factory.CreateClientWithTenant(); - var response = await client.GetAsync($"/sys/users/{Guid.NewGuid()}/tenants"); + var response = await client.GetAsync($"/admin/users/{Guid.NewGuid()}/tenants"); Assert.Contains(response.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); } diff --git a/src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs b/src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs index 9f906da..22a5461 100644 --- a/src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs +++ b/src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Finbuckle.MultiTenant.Abstractions; using Finbuckle.MultiTenant.EntityFrameworkCore; using Idmt.Plugin.Configuration; diff --git a/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs b/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs index e2e44fa..d51e49b 100644 --- a/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs +++ b/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs @@ -3,9 +3,9 @@ using System.Net.Http.Json; using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Features.Admin; using Idmt.Plugin.Features.Auth; using Idmt.Plugin.Features.Manage; -using Idmt.Plugin.Features.Sys; using Idmt.Plugin.Models; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; @@ -201,13 +201,13 @@ public async Task User_in_other_tenant_cannot_see_system_info_for_current_tenant var tokens = await loginA.Content.ReadFromJsonAsync(); clientA.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); - var infoResponseA = await clientA.GetAsync("/sys/info"); + var infoResponseA = await clientA.GetAsync("/admin/info"); var infoA = await infoResponseA.Content.ReadFromJsonAsync(); // Try to access Tenant B with Tenant A token var clientB = Factory.CreateClientWithTenant(TenantB); clientB.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); - var infoResponseB = await clientB.GetAsync("/sys/info"); + var infoResponseB = await clientB.GetAsync("/admin/info"); Assert.Contains(infoResponseB.StatusCode, new[] { HttpStatusCode.NotFound, HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); } diff --git a/src/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs b/src/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs index 6634906..0aa75d9 100644 --- a/src/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs +++ b/src/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs @@ -146,4 +146,19 @@ public void IsValidEmailOrUsername_ValidatesCorrectly(string? input, bool expect var result = Validators.IsValidEmail(input) || Validators.IsValidUsername(input); Assert.Equal(expected, result); } + + [Theory] + [InlineData("tenant-1", true)] + [InlineData("tenant_1", true)] + [InlineData("tenant1", true)] + [InlineData("Tenant1", false)] // Contains uppercase letters + [InlineData("tenant 1", false)] // Contains space + [InlineData("tenant@1", false)] // Contains invalid character + [InlineData("", false)] + [InlineData(null, false)] + public void IsValidTenantIdentifier_ValidatesCorrectly(string? identifier, bool expected) + { + var result = Validators.IsValidTenantIdentifier(identifier); + Assert.Equal(expected, result); + } }