Files
tiku-backend.net/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs

658 lines
30 KiB
C#

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_staff_password_reset_revokes_sessions_and_requires_change()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["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-admin/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/me")).StatusCode);
targetClient.DefaultRequestHeaders.Authorization = null;
var login = await targetClient.PostAsJsonAsync(
"/api/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<TikuDbContext>();
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<string, string?>
{
["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/backoffice/platform/bootstrap",
"/api/platform-admin/overview",
"/api/platform-admin/domains?limit=200",
"/api/platform-admin/saas/catalog",
"/api/platform-admin/tenants?limit=200",
"/api/platform-admin/saas/subscriptions?limit=200",
"/api/platform-admin/saas/orders?limit=200",
"/api/platform-admin/saas/refunds?limit=200",
"/api/platform-admin/saas/invoices?limit=200",
"/api/platform-admin/saas/payments?limit=200",
"/api/platform-admin/saas/usage?limit=200",
"/api/platform-admin/saas/invoices/reminders?limit=100",
"/api/platform-admin/question-banks?status=all",
"/api/platform-admin/staff?limit=200",
"/api/platform-admin/saas/dunning/channels?limit=100",
"/api/platform-admin/saas/dunning/events?limit=100",
"/api/platform-admin/audit-logs?limit=200",
"/api/platform-admin/audit-alerts?limit=100",
"/api/platform-admin/tenant-capabilities/crm/configs?limit=200",
"/api/platform-admin/tenant-capabilities/crm/leads?limit=200",
"/api/platform-admin/tenant-capabilities/crm/logs?limit=200",
"/api/platform-admin/tenant-capabilities/sms/channels?limit=200",
"/api/platform-admin/tenant-capabilities/sms/templates?limit=200",
"/api/platform-admin/tenant-capabilities/sms/logs?limit=200",
"/api/platform-admin/payment-settings/apps?limit=200",
"/api/platform-admin/payment-settings/channels?limit=200",
"/api/platform-admin/payment-settings/rebates/summary",
"/api/platform-admin/tenant-capabilities/payments/apps?limit=200",
"/api/platform-admin/payment-settings/events?limit=100",
"/api/platform-admin/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<string, string?>
{
["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<string, long>(),
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<TikuDbContext>();
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<string, string?>
{
["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<TikuDbContext>();
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<string, string?>
{
["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<string, string?>
{
["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<TikuDbContext>();
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)> 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<string> 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<object>()
.ToList();
var permissions = permissionCodes.Select(code => new BackendPermission
{
Code = code,
Name = code,
Area = BackendPermissionArea.Platform,
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code),
IsSystem = true
}).Cast<object>().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);
}
}