using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Security; using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class PlatformAdminEndpointTests { [Fact] public async Task Platform_admin_can_manage_tenant_subscription_domain_recheck_and_audit() { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" }); var platform = await SeedPlatformAdminAsync(factory); var tenantId = Guid.NewGuid(); var domainId = Guid.NewGuid(); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = "tenant-six-a", Name = "Tenant Six A", Status = TenantStatus.Active, BillingStatus = BillingStatus.Trial }, new TenantDomain { Id = domainId, TenantId = tenantId, Host = "six-a.example.test", Status = TenantDomainStatus.Active, IsPrimary = true, VerificationToken = "verify-six-a", VerifiedAt = DateTimeOffset.UtcNow, DnsVerifiedAt = DateTimeOffset.UtcNow, TlsReadyAt = DateTimeOffset.UtcNow }, new PlatformSaasPlan { Code = "standard", Name = "Standard", BaseAmountCents = 99900, Status = PlatformSaasPlanStatus.Active }); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); var overview = await client.GetAsync("/api/platform-admin/overview"); var tenants = await client.GetAsync("/api/platform-admin/tenants?search=six-a"); var planModules = await client.PutAsJsonAsync( "/api/platform-admin/plans/standard/modules", new ReplacePlatformPlanModulesDto { ModuleCodes = ["content", "job", "settings"] }); var subscription = await client.PostAsJsonAsync( "/api/platform-admin/subscriptions", new UpsertPlatformSubscriptionDto { TenantId = tenantId, PlanCode = "standard", Status = TenantSubscriptionStatus.Active, StartsAt = DateTimeOffset.UtcNow.AddDays(-1), ExpiresAt = DateTimeOffset.UtcNow.AddDays(30), AmountCents = 99900 }); var moduleOverride = await client.PutAsJsonAsync( $"/api/platform-admin/tenants/{tenantId}/module-overrides/content", new UpsertPlatformTenantModuleOverrideDto { Mode = TenantModuleOverrideMode.Disabled, Reason = "integration test capability revocation" }); var recheck = await client.PostAsync($"/api/platform-admin/domains/{domainId}/recheck", null); var suspended = await client.PatchAsJsonAsync( "/api/platform-admin/tenants/status", new UpdatePlatformTenantStatusDto { TenantId = tenantId, Status = TenantStatus.Suspended, BillingStatus = BillingStatus.PastDue, Reason = "integration test suspension" }); using var runtimeRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap"); runtimeRequest.Headers.Host = "six-a.example.test"; var runtimeAfterSuspend = await client.SendAsync(runtimeRequest); Assert.Equal(HttpStatusCode.OK, overview.StatusCode); Assert.Equal(HttpStatusCode.OK, tenants.StatusCode); Assert.Equal(HttpStatusCode.OK, planModules.StatusCode); Assert.Equal(HttpStatusCode.OK, subscription.StatusCode); Assert.Equal(HttpStatusCode.OK, moduleOverride.StatusCode); Assert.Equal(HttpStatusCode.OK, recheck.StatusCode); Assert.Equal(HttpStatusCode.OK, suspended.StatusCode); Assert.Equal(HttpStatusCode.NotFound, runtimeAfterSuspend.StatusCode); using var scope = factory.CreateSystemScope("Verify platform admin side effects"); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.True(await dbContext.BackgroundJobs.AnyAsync(job => job.TenantId == tenantId && job.JobType == "tenant_domain_recheck")); Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.ActorUserId == platform.UserId && log.Action == "platform.tenant.status_changed")); Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.ActorUserId == platform.UserId && log.Action == "platform.plan.modules.replaced")); Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.ActorUserId == platform.UserId && log.Action == "platform.tenant.module_override.updated")); } [Fact] public async Task Tenant_token_cannot_access_platform_admin_endpoints() { await using var factory = new ApiTestFactory(); var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13866660000"; await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Tenant Realm" }, new User { Id = userId, Phone = phone, Name = "Tenant Admin" }.WithTestPassword(), new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active }); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone)); var response = await client.GetAsync("/api/platform-admin/overview"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task Platform_admin_can_manage_dunning_channels_and_retry_events() { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" }); var platform = await SeedPlatformAdminAsync(factory); var tenantId = Guid.NewGuid(); var invoiceId = Guid.NewGuid(); var reminderId = Guid.NewGuid(); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = "tenant-dunning-a", Name = "Tenant Dunning A", Status = TenantStatus.Active, BillingStatus = BillingStatus.PastDue }, new TenantInvoice { Id = invoiceId, TenantId = tenantId, InvoiceNo = "INV-DUNNING-1", Status = TenantInvoiceStatus.Overdue, TotalCents = 10_000, BalanceCents = 10_000 }, new TenantInvoiceReminder { Id = reminderId, TenantId = tenantId, InvoiceId = invoiceId, ReminderType = TenantInvoiceReminderType.Overdue, Channel = TenantInvoiceReminderChannel.Wechat, Status = TenantInvoiceReminderStatus.Failed, ReminderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date), BalanceCentsSnapshot = 10_000 }); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); var upsertResponse = await client.PutAsJsonAsync( "/api/platform-admin/dunning-notification-channels", new UpsertPlatformDunningChannelDto { ChannelCode = "wecom-overdue", Name = "企业微信逾期提醒", Provider = PlatformDunningProvider.Wecom, WebhookUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret-key", SecretRef = "platform_secrets:dunning:wecom:default", ReminderTypes = ["overdue"], ReminderChannels = ["wechat"], MinReminderLevel = 2, TenantIds = [tenantId] }); var upsertBody = await upsertResponse.Content.ReadAsStringAsync(); var channelJson = JsonDocument.Parse(upsertBody); var channelId = channelJson.RootElement.GetProperty("id").GetGuid(); await factory.SeedAsync(new PlatformDunningNotificationEvent { TenantId = tenantId, ChannelId = channelId, ReminderId = reminderId, InvoiceId = invoiceId, Provider = PlatformDunningProvider.Wecom, Status = PlatformDunningNotificationStatus.Failed, Attempts = 2, LastError = "timeout", LastHttpCode = 500, LastResponseSummary = "server error", RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 }) }); var channelsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-channels?search=wecom"); var eventsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-events?status=failed"); var eventsJson = await JsonDocument.ParseAsync(await eventsResponse.Content.ReadAsStreamAsync()); var eventId = eventsJson.RootElement.GetProperty("items")[0].GetProperty("id").GetGuid(); var detailResponse = await client.GetAsync($"/api/platform-admin/dunning-notification-events/detail?eventId={eventId}"); var retryResponse = await client.PostAsJsonAsync( "/api/platform-admin/dunning-notification-events/retry", new RetryPlatformDunningEventDto { EventId = eventId, Reason = "manual retry" }); var disableResponse = await client.PostAsJsonAsync( "/api/platform-admin/dunning-notification-channels/disable", new DisablePlatformDunningChannelDto { ChannelId = channelId, Reason = "disable test" }); Assert.Equal(HttpStatusCode.OK, upsertResponse.StatusCode); Assert.DoesNotContain("secret-key", upsertBody, StringComparison.OrdinalIgnoreCase); Assert.Contains("https://qyapi.weixin.qq.com/****", upsertBody, StringComparison.OrdinalIgnoreCase); Assert.Equal(HttpStatusCode.OK, channelsResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, retryResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); using var scope = factory.CreateSystemScope("Verify platform dunning side effects"); var dbContext = scope.ServiceProvider.GetRequiredService(); var storedEvent = await dbContext.PlatformDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId); var storedChannel = await dbContext.PlatformDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId); Assert.Equal(PlatformDunningNotificationStatus.Pending, storedEvent.Status); Assert.Null(storedEvent.LastError); Assert.False(storedChannel.Enabled); Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.ActorUserId == platform.UserId && log.Action == "platform.dunning_event.retry_requested")); Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.ActorUserId == platform.UserId && log.Action == "platform.dunning_channel.disabled")); } private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory) { var userId = Guid.NewGuid(); var roleId = Guid.NewGuid(); var email = $"platform-{Guid.NewGuid():N}@example.test"; var permissions = BackendPermissions.Platform.Select(code => new BackendPermission { Code = code, Name = code, Area = BackendPermissionArea.Platform, Module = code.Split(':')[1], IsSystem = true }).Cast().ToList(); await factory.SeedAsync( [ ..permissions, new User { Id = userId, Email = email, NormalizedEmail = email.ToUpperInvariant(), UserName = email, NormalizedUserName = email.ToUpperInvariant(), Name = "Platform Admin", PrimaryRole = "platform_admin", RawProfile = JsonDefaults.Object() }.WithTestPassword(), new PlatformBackendRole { Id = roleId, Code = "platform_super_admin", Name = "Platform Super Admin", Status = BackendRoleStatus.Active, IsSystem = true }, ..BackendPermissions.Platform.Select(code => new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = code }), new PlatformBackendUserRole { UserId = userId, RoleId = roleId } ]); return (userId, email); } }