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.Growth; 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_publish_immutable_saas_offering_and_manage_tenant_operations() { 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 }); 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 feature = await client.PutAsJsonAsync( "/api/platform-admin/saas/features", new UpsertSaasFeatureDto( null, SaasFeatureCatalog.Exam, "考试", "learning", "租户考试模块", 60_000, "CNY", SaasFeatureStatus.Active, 30)); var offering = await client.PutAsJsonAsync( "/api/platform-admin/saas/offerings", new UpsertSaasOfferingDto( null, $"standard-{Guid.NewGuid():N}", "标准套餐", SaasOfferingType.BasePlan, SaasOfferingStatus.Draft, "集成测试套餐", 10)); Assert.Equal(HttpStatusCode.OK, feature.StatusCode); Assert.Equal(HttpStatusCode.OK, offering.StatusCode); var offeringJson = await JsonDocument.ParseAsync(await offering.Content.ReadAsStreamAsync()); var offeringId = offeringJson.RootElement.GetProperty("id").GetGuid(); var versionRequest = new UpsertSaasOfferingVersionDto( null, offeringId, PlatformBillingCycle.Yearly, 72_000, 60_000, "CNY", null, [SaasFeatureCatalog.Exam], new Dictionary(), JsonDefaults.Object()); var version = await client.PutAsJsonAsync("/api/platform-admin/saas/offering-versions", versionRequest); Assert.Equal(HttpStatusCode.OK, version.StatusCode); var versionJson = await JsonDocument.ParseAsync(await version.Content.ReadAsStreamAsync()); var versionId = versionJson.RootElement.GetProperty("id").GetGuid(); var published = await client.PostAsync($"/api/platform-admin/saas/offering-versions/{versionId}/publish", null); var immutableUpdate = await client.PutAsJsonAsync( "/api/platform-admin/saas/offering-versions", versionRequest with { Id = versionId, AmountCents = 50_000 }); var catalog = await client.GetAsync("/api/platform-admin/saas/catalog"); 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, published.StatusCode); Assert.Equal(HttpStatusCode.Conflict, immutableUpdate.StatusCode); Assert.Equal(HttpStatusCode.OK, catalog.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.saas.feature.upserted")); Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.ActorUserId == platform.UserId && log.Action == "platform.saas.offering_version.published")); Assert.Equal( 60_000, await dbContext.SaasOfferingVersions .Where(item => item.Id == versionId) .Select(item => item.AmountCents) .SingleAsync()); } [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/saas/catalog"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task Platform_admin_can_manage_crm_sms_and_payment_settings() { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" }); var platform = await SeedPlatformAdminAsync(factory); var tenantA = Guid.NewGuid(); var tenantB = Guid.NewGuid(); var failedQueueId = Guid.NewGuid(); var otherQueueId = Guid.NewGuid(); await factory.SeedAsync( new Tenant { Id = tenantA, Slug = "capability-a", Name = "Capability A", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, new Tenant { Id = tenantB, Slug = "capability-b", Name = "Capability B", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, new CrmWebhookQueueItem { Id = failedQueueId, TenantId = tenantA, RecordId = "lead-a", Source = "tenant.student.crm_push", Status = CrmWebhookQueueStatus.Failed, Attempts = 2, IdempotencyKey = "platform-capability-lead-a", LastError = "timeout" }, new CrmWebhookQueueItem { Id = otherQueueId, TenantId = tenantB, RecordId = "lead-b", Source = "tenant.student.crm_push", Status = CrmWebhookQueueStatus.Failed, Attempts = 1, IdempotencyKey = "platform-capability-lead-b", LastError = "still failed" }); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); var crm = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/crm/configs", new UpsertPlatformCrmConfigDto { TenantId = tenantA, Enabled = true, Url = "https://crm.example.test/webhook", SecretRef = "tenant_secret:crm:redacted", AssignmentMode = "round_robin", AssignmentPool = JsonSerializer.SerializeToElement(new[] { "sales-a" }), AssignmentConfig = JsonSerializer.SerializeToElement(new { retry = 3 }) }); var retry = await client.PostAsJsonAsync("/api/platform-admin/tenant-capabilities/crm/leads/retry", new RetryPlatformCrmLeadDto { QueueId = failedQueueId, Note = "retry" }); var smsChannel = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/sms/channels", new UpsertPlatformSmsChannelDto { TenantId = tenantA, Provider = "aliyun", Name = "阿里云短信", Signature = "题库测试", Scene = "login", Status = TenantExternalProviderStatus.Active, SecretRef = "tenant_secret:sms:aliyun:redacted", MonthlyQuota = 1000 }); var smsJson = JsonDocument.Parse(await smsChannel.Content.ReadAsStringAsync()); var channelId = smsJson.RootElement.GetProperty("id").GetGuid(); var template = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/sms/templates", new UpsertPlatformSmsTemplateDto { TenantId = tenantA, ChannelId = channelId, Code = "login_code", Name = "登录验证码", Type = SmsTemplateType.VerificationCode, AuditStatus = SmsTemplateAuditStatus.Draft, Status = SmsTemplateStatus.Active, Content = "验证码 ${code}" }); var paymentApp = await client.PutAsJsonAsync("/api/platform-admin/payment-settings/apps", new UpsertPlatformPaymentAppDto { AppCode = "platform_collect_test", AppName = "平台收款测试", Status = PlatformPaymentAppStatus.Active, SettlementMode = "PlatformCollect" }); var tenantPayment = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/payments/apps", new UpsertPlatformTenantPaymentAppDto { TenantId = tenantA, Provider = "manual", Status = TenantExternalProviderStatus.Active, DisplayName = "线下收款", SecretRef = "tenant_payment:manual:redacted" }); var reads = await Task.WhenAll( client.GetAsync("/api/platform-admin/tenant-capabilities/crm/configs"), client.GetAsync("/api/platform-admin/tenant-capabilities/crm/leads"), client.GetAsync("/api/platform-admin/tenant-capabilities/sms/channels"), client.GetAsync("/api/platform-admin/tenant-capabilities/sms/templates"), client.GetAsync("/api/platform-admin/payment-settings/apps"), client.GetAsync("/api/platform-admin/tenant-capabilities/payments/apps")); Assert.Equal(HttpStatusCode.OK, crm.StatusCode); Assert.Equal(HttpStatusCode.OK, retry.StatusCode); Assert.Equal(HttpStatusCode.OK, smsChannel.StatusCode); Assert.Equal(HttpStatusCode.OK, template.StatusCode); Assert.Equal(HttpStatusCode.OK, paymentApp.StatusCode); Assert.Equal(HttpStatusCode.OK, tenantPayment.StatusCode); Assert.All(reads, response => Assert.Equal(HttpStatusCode.OK, response.StatusCode)); using var scope = factory.CreateSystemScope("Verify platform capability endpoints"); var db = scope.ServiceProvider.GetRequiredService(); Assert.Equal(CrmWebhookQueueStatus.Retrying, await db.CrmWebhookQueue.Where(item => item.Id == failedQueueId).Select(item => item.Status).SingleAsync()); Assert.Equal(CrmWebhookQueueStatus.Failed, await db.CrmWebhookQueue.Where(item => item.Id == otherQueueId).Select(item => item.Status).SingleAsync()); Assert.True(await db.AuditLogs.AnyAsync(item => item.ActorUserId == platform.UserId && item.Action == "platform.crm.lead.retry")); Assert.True(await db.AuditLogs.AnyAsync(item => item.ActorUserId == platform.UserId && item.Action == "platform.sms.channel.upserted")); Assert.True(await db.AuditLogs.AnyAsync(item => item.ActorUserId == platform.UserId && item.Action == "platform.payment.app.upserted")); } [Fact] public async Task Platform_capability_read_permissions_cannot_write() { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" }); var platform = await SeedPlatformUserAsync(factory, [ BackendPermissions.PlatformCrmRead, BackendPermissions.PlatformSmsRead, BackendPermissions.PlatformPaymentRead ]); var tenantId = Guid.NewGuid(); await factory.SeedAsync(new Tenant { Id = tenantId, Slug = "readonly-capability", Name = "Readonly Capability" }); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); var read = await client.GetAsync("/api/platform-admin/tenant-capabilities/sms/channels"); var write = await client.PutAsJsonAsync("/api/platform-admin/tenant-capabilities/sms/channels", new UpsertPlatformSmsChannelDto { TenantId = tenantId, Provider = "aliyun", Name = "只读不应写入", Signature = "题库", Scene = "login", Status = TenantExternalProviderStatus.Active }); Assert.Equal(HttpStatusCode.OK, read.StatusCode); Assert.Equal(HttpStatusCode.Forbidden, write.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 PlatformBillingInvoice { Id = invoiceId, TenantId = tenantId, InvoiceNo = "INV-DUNNING-1", Status = PlatformBillingInvoiceStatus.Overdue, TotalAmountCents = 10_000 }, new PlatformBillingInvoiceReminder { Id = reminderId, TenantId = tenantId, InvoiceId = invoiceId, ReminderType = PlatformBillingInvoiceReminderType.Overdue, Channel = PlatformBillingInvoiceReminderChannel.Wechat, Status = PlatformBillingInvoiceReminderStatus.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/saas/dunning/channels", new UpsertPlatformBillingDunningChannelDto { ChannelCode = "wecom-overdue", Name = "企业微信逾期提醒", Provider = PlatformBillingDunningProvider.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 PlatformBillingDunningNotificationEvent { TenantId = tenantId, ChannelId = channelId, ReminderId = reminderId, InvoiceId = invoiceId, Provider = PlatformBillingDunningProvider.Wecom, Status = PlatformBillingDunningNotificationStatus.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/saas/dunning/channels?search=wecom"); var eventsResponse = await client.GetAsync("/api/platform-admin/saas/dunning/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/saas/dunning/events/detail?eventId={eventId}"); var retryResponse = await client.PostAsJsonAsync( "/api/platform-admin/saas/dunning/events/retry", new RetryPlatformBillingDunningEventDto { EventId = eventId, Reason = "manual retry" }); var disableResponse = await client.PostAsJsonAsync( "/api/platform-admin/saas/dunning/channels/disable", new DisablePlatformBillingDunningChannelDto { 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.PlatformBillingDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId); var storedChannel = await dbContext.PlatformBillingDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId); Assert.Equal(PlatformBillingDunningNotificationStatus.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.billing_dunning_event.retry_requested")); Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.ActorUserId == platform.UserId && log.Action == "platform.billing_dunning_channel.disabled")); } private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory) { return await SeedPlatformUserAsync(factory, BackendPermissions.Platform); } private static async Task<(Guid UserId, string Email)> SeedPlatformUserAsync( ApiTestFactory factory, IEnumerable platformPermissions) { var userId = Guid.NewGuid(); var roleId = Guid.NewGuid(); var email = $"platform-{Guid.NewGuid():N}@example.test"; var permissionCodes = platformPermissions.Distinct(StringComparer.Ordinal).ToArray(); var modules = permissionCodes .Select(PermissionModuleCatalog.ResolvePermissionModuleCode) .Distinct(StringComparer.Ordinal) .Select(code => new PermissionModule { Code = code, Name = code, Area = BackendPermissionArea.Platform, RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code] }) .Cast() .ToList(); var permissions = permissionCodes.Select(code => new BackendPermission { Code = code, Name = code, Area = BackendPermissionArea.Platform, PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code), IsSystem = true }).Cast().ToList(); await factory.SeedAsync( [ ..modules, ..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 }, ..permissionCodes.Select(code => new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = code }), new PlatformBackendUserRole { UserId = userId, RoleId = roleId } ]); return (userId, email); } }