using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Security; 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.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class PlatformAdminEndpointTests { [Fact] public async Task Platform_staff_password_reset_revokes_sessions_and_requires_change() { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" }); var administrator = await SeedPlatformAdminAsync(factory); var target = await SeedAdditionalPlatformUserAsync(factory); using var targetClient = factory.CreateClient(); var targetTokens = await targetClient.LoginAsPlatformAsync(target.Email); using var adminClient = factory.CreateClient(); adminClient.UseAccessToken(await adminClient.LoginAsPlatformAsync(administrator.Email)); var reset = await adminClient.PostAsJsonAsync( $"/api/platform/staff/{target.UserId}/password-reset", new AdministrativePasswordResetDto { TemporaryPassword = "TemporaryPassword2026", Reason = "Platform staff recovery verification" }); Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode); targetClient.UseAccessToken(targetTokens); Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/tenant/me")).StatusCode); targetClient.DefaultRequestHeaders.Authorization = null; var login = await targetClient.PostAsJsonAsync( "/api/tenant/auth/login/password", new PasswordLoginDto { Realm = AuthRealm.Platform, Identifier = target.Email, Password = "TemporaryPassword2026" }); Assert.Equal(HttpStatusCode.OK, login.StatusCode); Assert.Contains("password_change_required", await login.Content.ReadAsStringAsync(), StringComparison.Ordinal); using var scope = factory.CreateSystemScope("Verify platform administrative password reset"); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.True(await dbContext.Users.Where(item => item.Id == target.UserId) .Select(item => item.ForcePasswordChange).SingleAsync()); Assert.True(await dbContext.AuditLogs.AnyAsync(item => item.TenantId == null && item.ActorUserId == administrator.UserId && item.Action == "auth.password.reset_by_administrator" && item.TargetId == target.UserId.ToString())); } [Fact] public async Task Platform_super_admin_can_load_every_platform_console_bootstrap_endpoint() { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" }); var platform = await SeedPlatformAdminAsync(factory); await factory.SeedAsync(new Tenant { Id = Guid.NewGuid(), Slug = "platform-console-content", Name = "Platform Console Content", Mode = TenantMode.PlatformOwned, Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); string[] endpoints = [ "/api/platform/access/bootstrap", "/api/platform/overview", "/api/platform/domains?limit=200", "/api/platform/saas/catalog", "/api/platform/tenants?limit=200", "/api/platform/saas/subscriptions?limit=200", "/api/platform/saas/orders?limit=200", "/api/platform/saas/refunds?limit=200", "/api/platform/saas/invoices?limit=200", "/api/platform/saas/payments?limit=200", "/api/platform/saas/usage?limit=200", "/api/platform/saas/invoices/reminders?limit=100", "/api/platform/question-banks?status=all", "/api/platform/staff?limit=200", "/api/platform/saas/dunning/channels?limit=100", "/api/platform/saas/dunning/events?limit=100", "/api/platform/audit-logs?limit=200", "/api/platform/audit-alerts?limit=100", "/api/platform/tenant-capabilities/crm/configs?limit=200", "/api/platform/tenant-capabilities/crm/leads?limit=200", "/api/platform/tenant-capabilities/crm/logs?limit=200", "/api/platform/tenant-capabilities/sms/channels?limit=200", "/api/platform/tenant-capabilities/sms/templates?limit=200", "/api/platform/tenant-capabilities/sms/logs?limit=200", "/api/platform/payment-settings/apps?limit=200", "/api/platform/payment-settings/channels?limit=200", "/api/platform/payment-settings/rebates/summary", "/api/platform/tenant-capabilities/payments/apps?limit=200", "/api/platform/payment-settings/events?limit=100", "/api/platform/tenant-capabilities/payments/events?limit=100" ]; var responses = await Task.WhenAll(endpoints.Select(async endpoint => { using var response = await client.GetAsync(endpoint); return new { Endpoint = endpoint, response.StatusCode, Body = await response.Content.ReadAsStringAsync() }; })); var failures = responses.Where(response => response.StatusCode != HttpStatusCode.OK).ToArray(); Assert.True( failures.Length == 0, string.Join(Environment.NewLine, failures.Select(failure => $"{failure.Endpoint}: {(int)failure.StatusCode} {failure.StatusCode} {failure.Body}"))); } [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/overview"); var tenants = await client.GetAsync("/api/platform/tenants?search=six-a"); var feature = await client.PutAsJsonAsync( "/api/platform/saas/features", new UpsertSaasFeatureDto( null, SaasFeatureCatalog.Exam, "考试", "learning", "租户考试模块", 60_000, "CNY", SaasFeatureStatus.Active, 30)); var offering = await client.PutAsJsonAsync( "/api/platform/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/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/saas/offering-versions/{versionId}/publish", null); var immutableUpdate = await client.PutAsJsonAsync( "/api/platform/saas/offering-versions", versionRequest with { Id = versionId, AmountCents = 50_000 }); var catalog = await client.GetAsync("/api/platform/saas/catalog"); var recheck = await client.PostAsync($"/api/platform/domains/{domainId}/recheck", null); using var suspendRequest = new HttpRequestMessage(HttpMethod.Patch, "/api/platform/tenants/status") { Content = JsonContent.Create(new UpdatePlatformTenantStatusDto { TenantId = tenantId, Status = TenantStatus.Suspended, Reason = "integration test suspension" }) }; suspendRequest.Headers.Add("Idempotency-Key", $"suspend-{tenantId:N}"); var suspended = await client.SendAsync(suspendRequest); using var runtimeRequest = new HttpRequestMessage(HttpMethod.Get, "/api/public/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/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/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/tenant-capabilities/crm/leads/retry", new RetryPlatformCrmLeadDto { QueueId = failedQueueId, Note = "retry" }); var smsChannel = await client.PutAsJsonAsync("/api/platform/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/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/payment-settings/apps", new UpsertPlatformPaymentAppDto { AppCode = "platform_collect_test", AppName = "平台收款测试", Status = PlatformPaymentAppStatus.Active, SettlementMode = "PlatformCollect" }); var tenantPayment = await client.PutAsJsonAsync("/api/platform/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/tenant-capabilities/crm/configs"), client.GetAsync("/api/platform/tenant-capabilities/crm/leads"), client.GetAsync("/api/platform/tenant-capabilities/sms/channels"), client.GetAsync("/api/platform/tenant-capabilities/sms/templates"), client.GetAsync("/api/platform/payment-settings/apps"), client.GetAsync("/api/platform/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/tenant-capabilities/sms/channels"); var write = await client.PutAsJsonAsync("/api/platform/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(); var ignoredReminderId = Guid.NewGuid(); var eventId = Guid.NewGuid(); var ignoredEventId = 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 }, new PlatformBillingInvoiceReminder { Id = ignoredReminderId, TenantId = tenantId, InvoiceId = invoiceId, ReminderType = PlatformBillingInvoiceReminderType.FinalNotice, 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/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 { Id = eventId, TenantId = tenantId, ChannelId = channelId, ReminderId = ignoredReminderId, 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 }) }, new PlatformBillingDunningNotificationEvent { Id = ignoredEventId, TenantId = tenantId, ChannelId = channelId, ReminderId = reminderId, InvoiceId = invoiceId, Provider = PlatformBillingDunningProvider.Wecom, Status = PlatformBillingDunningNotificationStatus.Failed, Attempts = 5, LastError = "permanent failure", RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 }) }); var channelsResponse = await client.GetAsync("/api/platform/saas/dunning/channels?search=wecom"); var eventsResponse = await client.GetAsync("/api/platform/saas/dunning/events?status=failed"); var detailResponse = await client.GetAsync($"/api/platform/saas/dunning/events/detail?eventId={eventId}"); var retryResponse = await client.PostAsJsonAsync( "/api/platform/saas/dunning/events/retry", new RetryPlatformBillingDunningEventDto { EventId = eventId, Reason = "manual retry" }); var acknowledgeResponse = await client.PostAsJsonAsync( "/api/platform/saas/dunning/events/acknowledge", new ResolvePlatformBillingDunningEventDto { EventId = eventId, Reason = "delivery confirmed manually" }); var ignoreResponse = await client.PostAsJsonAsync( "/api/platform/saas/dunning/events/ignore", new ResolvePlatformBillingDunningEventDto { EventId = ignoredEventId, Reason = "tenant requested no further delivery" }); var disableResponse = await client.PostAsJsonAsync( "/api/platform/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, acknowledgeResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, ignoreResponse.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.Acknowledged, storedEvent.Status); Assert.Null(storedEvent.LastError); Assert.Equal(PlatformBillingDunningNotificationStatus.Ignored, await dbContext .PlatformBillingDunningNotificationEvents.AsNoTracking() .Where(item => item.Id == ignoredEventId) .Select(item => item.Status) .SingleAsync()); 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")); Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.ActorUserId == platform.UserId && log.Action == "platform.billing_dunning_event.acknowledged")); Assert.True(await dbContext.AuditLogs.AnyAsync(log => log.ActorUserId == platform.UserId && log.Action == "platform.billing_dunning_event.ignored")); } [Fact] public async Task Tenant_provisioning_and_domain_bound_owner_activation_are_transactional_and_single_use() { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["Tenancy:Resolution:PlatformHosts:0"] = "localhost", ["TenantProvisioning:DefaultBaseOfferingCode"] = "starter", ["TenantProvisioning:DefaultTrialDays"] = "17" }); var platform = await SeedPlatformAdminAsync(factory); await factory.SeedBuiltinBackofficeCatalogAsync(); var offering = new SaasOffering { Code = "starter", Name = "Starter", Type = SaasOfferingType.BasePlan, Status = SaasOfferingStatus.Active }; var offeringVersion = new SaasOfferingVersion { OfferingId = offering.Id, Version = 1, Status = SaasOfferingVersionStatus.Published, OriginalAmountCents = 100, AmountCents = 100, EffectiveAt = DateTimeOffset.UtcNow.AddDays(-1), PublishedAt = DateTimeOffset.UtcNow.AddDays(-1) }; await factory.SeedAsync( offering, offeringVersion, new SaasOfferingVersionFeature { OfferingVersionId = offeringVersion.Id, FeatureCode = SaasFeatureCatalog.CoreBackoffice }); using var client = factory.CreateClient(); client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); var key = $"tenant-create-{Guid.NewGuid():N}"; client.DefaultRequestHeaders.Add("Idempotency-Key", key); var slug = $"activation-{Guid.NewGuid():N}"; var primaryHost = $"{slug}.example.test"; var phone = $"137{Random.Shared.Next(10_000_000, 99_999_999)}"; var request = new CreatePlatformTenantDto { Slug = slug, Name = "Activation Tenant", PrimaryDomainHost = primaryHost, OwnerPhone = phone, OwnerName = "Activation Owner", DefaultPaymentProvider = "manual", CollectionMode = TenantBillingCollectionMode.Manual, RenewalLeadDays = 21 }; var responses = await Task.WhenAll( client.PostAsJsonAsync("/api/platform/tenants", request), client.PostAsJsonAsync("/api/platform/tenants", request)); var failedProvisioning = await Task.WhenAll(responses .Where(response => response.StatusCode != HttpStatusCode.OK) .Select(async response => $"{(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}")); Assert.True(failedProvisioning.Length == 0, string.Join(Environment.NewLine, failedProvisioning)); var payloads = await Task.WhenAll(responses.Select(async response => await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()))); var tenantIds = payloads .Select(payload => payload.RootElement.GetProperty("tenant").GetProperty("id").GetGuid()).Distinct() .ToArray(); Assert.Single(tenantIds); Assert.Single(payloads, payload => payload.RootElement.GetProperty("isReplay").GetBoolean()); Assert.All(payloads, payload => { Assert.Equal(primaryHost, payload.RootElement.GetProperty("primaryDomain").GetProperty("host").GetString()); Assert.Equal("Pending", payload.RootElement.GetProperty("primaryDomain").GetProperty("status").GetString()); Assert.Equal("domain_pending", payload.RootElement.GetProperty("ownerActivation").GetProperty("status").GetString()); Assert.False(payload.RootElement.TryGetProperty("activationToken", out _)); }); var conflictingRequest = new CreatePlatformTenantDto { Slug = slug, Name = "Different Tenant Name", PrimaryDomainHost = primaryHost, OwnerPhone = phone, OwnerName = request.OwnerName, DefaultPaymentProvider = request.DefaultPaymentProvider, CollectionMode = request.CollectionMode, RenewalLeadDays = request.RenewalLeadDays }; var conflict = await client.PostAsJsonAsync("/api/platform/tenants", conflictingRequest); Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode); Assert.Equal("idempotency_conflict", await ReadProblemCodeAsync(conflict)); var tenantId = tenantIds[0]; using (var pendingIssueRequest = new HttpRequestMessage( HttpMethod.Post, $"/api/platform/tenants/{tenantId}/owner-activation-links") { Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto { Reason = "Domain is not ready yet" }) }) { pendingIssueRequest.Headers.Add("Idempotency-Key", $"pending-{Guid.NewGuid():N}"); var pendingIssue = await client.SendAsync(pendingIssueRequest); Assert.Equal(HttpStatusCode.BadRequest, pendingIssue.StatusCode); Assert.Equal("primary_domain_not_active", await ReadProblemCodeAsync(pendingIssue)); } Guid domainId; using (var activationScope = factory.CreateSystemScope("Activate provisioned primary domain")) { var activationDb = activationScope.ServiceProvider.GetRequiredService(); var domain = await activationDb.TenantDomains.SingleAsync(value => value.TenantId == tenantId && value.IsPrimary); domainId = domain.Id; domain.Status = TenantDomainStatus.Active; domain.DnsVerifiedAt = DateTimeOffset.UtcNow; domain.TlsReadyAt = DateTimeOffset.UtcNow; domain.VerifiedAt = DateTimeOffset.UtcNow; await activationDb.SaveChangesAsync(); } using var browserClient = factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); using (var setupRuntimeRequest = new HttpRequestMessage( HttpMethod.Get, $"https://{primaryHost}/api/public/runtime/bootstrap")) { var setupRuntime = await browserClient.SendAsync(setupRuntimeRequest); using var setupPayload = await JsonDocument.ParseAsync(await setupRuntime.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, setupRuntime.StatusCode); Assert.Equal("setup_required", setupPayload.RootElement.GetProperty("siteState").GetString()); } var issueKeys = new[] { $"issue-{Guid.NewGuid():N}", $"issue-{Guid.NewGuid():N}" }; var issueRequests = issueKeys.Select(issueKey => { var issueRequest = new HttpRequestMessage( HttpMethod.Post, $"/api/platform/tenants/{tenantId}/owner-activation-links") { Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto { Reason = "Secure tenant handoff" }) }; issueRequest.Headers.Add("Idempotency-Key", issueKey); return issueRequest; }).ToArray(); var issueResponses = await Task.WhenAll(issueRequests.Select(client.SendAsync)); var issuedIndex = Array.FindIndex(issueResponses, response => response.StatusCode == HttpStatusCode.OK); Assert.True(issuedIndex >= 0); var issued = issueResponses[issuedIndex]; var concurrentConflict = issueResponses.Single(response => response.StatusCode != HttpStatusCode.OK); Assert.Equal(HttpStatusCode.Conflict, concurrentConflict.StatusCode); Assert.Equal("owner_activation_already_issued", await ReadProblemCodeAsync(concurrentConflict)); Assert.Equal(HttpStatusCode.OK, issued.StatusCode); using var issuedPayload = await JsonDocument.ParseAsync(await issued.Content.ReadAsStreamAsync()); var firstActivationId = issuedPayload.RootElement.GetProperty("activationId").GetGuid(); var activationUrl = issuedPayload.RootElement.GetProperty("activationUrl").GetString()!; Assert.StartsWith($"https://{primaryHost}/activate/{firstActivationId}#token=", activationUrl, StringComparison.Ordinal); var firstActivationToken = new Uri(activationUrl).Fragment["#token=".Length..]; using var replayIssueRequest = new HttpRequestMessage( HttpMethod.Post, $"/api/platform/tenants/{tenantId}/owner-activation-links") { Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto { Reason = "Secure tenant handoff" }) }; replayIssueRequest.Headers.Add("Idempotency-Key", issueKeys[issuedIndex]); var issueReplay = await client.SendAsync(replayIssueRequest); using var replayPayload = await JsonDocument.ParseAsync(await issueReplay.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, issueReplay.StatusCode); Assert.True(replayPayload.RootElement.GetProperty("isReplay").GetBoolean()); Assert.Equal(JsonValueKind.Null, replayPayload.RootElement.GetProperty("activationUrl").ValueKind); using var replaceIssueRequest = new HttpRequestMessage( HttpMethod.Post, $"/api/platform/tenants/{tenantId}/owner-activation-links") { Content = JsonContent.Create(new IssuePlatformOwnerActivationLinkDto { Reason = "Owner requested a replacement link", ReplaceExisting = true }) }; replaceIssueRequest.Headers.Add("Idempotency-Key", $"replace-{Guid.NewGuid():N}"); var replacement = await client.SendAsync(replaceIssueRequest); using var replacementPayload = await JsonDocument.ParseAsync(await replacement.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, replacement.StatusCode); var activationId = replacementPayload.RootElement.GetProperty("activationId").GetGuid(); var replacementUrl = replacementPayload.RootElement.GetProperty("activationUrl").GetString()!; var activationToken = new Uri(replacementUrl).Fragment["#token=".Length..]; using (var revokedRequest = new HttpRequestMessage( HttpMethod.Post, $"https://{primaryHost}/api/tenant/auth/browser/activation/complete") { Content = JsonContent.Create(new CompleteOwnerActivationDto { ActivationId = firstActivationId, Token = firstActivationToken, NewPassword = "ActivatedOwner2026" }) }) { revokedRequest.Headers.Add("Origin", $"https://{primaryHost}"); var revoked = await browserClient.SendAsync(revokedRequest); Assert.Equal(HttpStatusCode.BadRequest, revoked.StatusCode); Assert.Equal("owner_activation_invalid", await ReadProblemCodeAsync(revoked)); } using var invalidPasswordRequest = new HttpRequestMessage( HttpMethod.Post, $"https://{primaryHost}/api/tenant/auth/browser/activation/complete") { Content = JsonContent.Create(new CompleteOwnerActivationDto { ActivationId = activationId, Token = activationToken, NewPassword = "weak" }) }; invalidPasswordRequest.Headers.Add("Origin", $"https://{primaryHost}"); var invalidPassword = await browserClient.SendAsync(invalidPasswordRequest); Assert.Equal(HttpStatusCode.BadRequest, invalidPassword.StatusCode); using var activationRequest = new HttpRequestMessage( HttpMethod.Post, $"https://{primaryHost}/api/tenant/auth/browser/activation/complete") { Content = JsonContent.Create(new CompleteOwnerActivationDto { ActivationId = activationId, Token = activationToken, NewPassword = "ActivatedOwner2026" }) }; activationRequest.Headers.Add("Origin", $"https://{primaryHost}"); var activated = await browserClient.SendAsync(activationRequest); Assert.Equal(HttpStatusCode.OK, activated.StatusCode); var cookies = activated.Headers.GetValues("Set-Cookie").ToArray(); var accessCookie = cookies.Single(value => value.StartsWith("__Host-tiku-at=", StringComparison.Ordinal)); accessCookie = accessCookie[..accessCookie.IndexOf(';')]; using (var readyRuntimeRequest = new HttpRequestMessage( HttpMethod.Get, $"https://{primaryHost}/api/public/runtime/bootstrap")) { var readyRuntime = await browserClient.SendAsync(readyRuntimeRequest); using var readyPayload = await JsonDocument.ParseAsync(await readyRuntime.Content.ReadAsStreamAsync()); Assert.Equal(HttpStatusCode.OK, readyRuntime.StatusCode); Assert.Equal("ready_to_launch", readyPayload.RootElement.GetProperty("siteState").GetString()); } using var bootstrapRequest = new HttpRequestMessage( HttpMethod.Get, $"https://{primaryHost}/api/tenant/access/ui-bootstrap"); bootstrapRequest.Headers.Add("Cookie", accessCookie); bootstrapRequest.Headers.Add("Origin", $"https://{primaryHost}"); var bootstrap = await browserClient.SendAsync(bootstrapRequest); Assert.True( bootstrap.StatusCode == HttpStatusCode.OK, $"Expected UI bootstrap success, got {(int)bootstrap.StatusCode}: {await bootstrap.Content.ReadAsStringAsync()}"); using var consumedRequest = new HttpRequestMessage( HttpMethod.Post, $"https://{primaryHost}/api/tenant/auth/browser/activation/complete") { Content = JsonContent.Create(new CompleteOwnerActivationDto { ActivationId = activationId, Token = activationToken, NewPassword = "ActivatedOwner2026" }) }; consumedRequest.Headers.Add("Origin", $"https://{primaryHost}"); var consumed = await browserClient.SendAsync(consumedRequest); Assert.Equal(HttpStatusCode.Conflict, consumed.StatusCode); Assert.Equal("owner_activation_consumed", await ReadProblemCodeAsync(consumed)); var policy = await client.GetAsync($"/api/platform/tenants/{tenantIds[0]}/billing-policy"); Assert.Equal(HttpStatusCode.OK, policy.StatusCode); using var policyPayload = await JsonDocument.ParseAsync(await policy.Content.ReadAsStreamAsync()); Assert.Equal(21, policyPayload.RootElement.GetProperty("renewalLeadDays").GetInt32()); using var scope = factory.CreateSystemScope("Verify tenant provisioning and owner activation"); var dbContext = scope.ServiceProvider.GetRequiredService(); var grant = await dbContext.TenantOwnerActivationGrants.AsNoTracking() .SingleAsync(value => value.Id == activationId); var revokedGrant = await dbContext.TenantOwnerActivationGrants.AsNoTracking() .SingleAsync(value => value.Id == firstActivationId); var subscription = await dbContext.TenantSaasSubscriptions.AsNoTracking() .SingleAsync(value => value.TenantId == tenantId); Assert.NotNull(grant.ConsumedAt); Assert.NotNull(revokedGrant.RevokedAt); Assert.Equal(platform.UserId, revokedGrant.RevokedBy); Assert.Equal(domainId, grant.DomainId); Assert.DoesNotContain(activationToken, grant.TokenHash, StringComparison.Ordinal); Assert.InRange(subscription.CurrentPeriodEnd - subscription.StartsAt, TimeSpan.FromDays(16.9), TimeSpan.FromDays(17.1)); Assert.True(await dbContext.TenantFrontendConfigs.AnyAsync(value => value.TenantId == tenantId)); Assert.True(await dbContext.AuditLogs.AnyAsync(value => value.TenantId == tenantIds[0] && value.Action == "tenant.owner.activated")); foreach (var payload in payloads) payload.Dispose(); } private static async Task ReadProblemCodeAsync(HttpResponseMessage response) { using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); return payload.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null; } 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)> SeedAdditionalPlatformUserAsync(ApiTestFactory factory) { var userId = Guid.NewGuid(); var roleId = Guid.NewGuid(); var email = $"platform-target-{Guid.NewGuid():N}@example.test"; await factory.SeedAsync( new User { Id = userId, Email = email, NormalizedEmail = email.ToUpperInvariant(), UserName = email, NormalizedUserName = email.ToUpperInvariant(), Name = "Platform Reset Target", PrimaryRole = "platform_staff", RawProfile = JsonDefaults.Object() }.WithTestPassword(), new PlatformBackendRole { Id = roleId, Code = $"platform_reset_target_{roleId:N}", Name = "Platform Reset Target", Status = BackendRoleStatus.Active }, new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = BackendPermissions.PlatformDashboardView }, new PlatformBackendUserRole { UserId = userId, RoleId = roleId }); return (userId, email); } 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); } }