1044 lines
50 KiB
C#
1044 lines
50 KiB
C#
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<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/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<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/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<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/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<string, long>(),
|
|
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<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/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/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<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/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<string, string?>
|
|
{
|
|
["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<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.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<string, string?>
|
|
{
|
|
["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<TikuDbContext>();
|
|
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<TikuDbContext>();
|
|
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<string?> 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<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);
|
|
}
|
|
} |