forked from xiongyuxing/tiku-backend.net
311 lines
13 KiB
C#
311 lines
13 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.Identity;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Platform;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Auth;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.IntegrationTests.Api;
|
|
|
|
public sealed class PlatformAdminEndpointTests
|
|
{
|
|
[Fact]
|
|
public async Task Platform_admin_can_manage_tenant_subscription_domain_recheck_and_audit()
|
|
{
|
|
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<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
|
|
},
|
|
new PlatformSaasPlan
|
|
{
|
|
Code = "standard",
|
|
Name = "Standard",
|
|
BaseAmountCents = 99900,
|
|
Status = PlatformSaasPlanStatus.Active
|
|
});
|
|
|
|
using var client = factory.CreateClient();
|
|
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
|
|
|
|
var overview = await client.GetAsync("/api/platform-admin/overview");
|
|
var tenants = await client.GetAsync("/api/platform-admin/tenants?search=six-a");
|
|
var subscription = await client.PostAsJsonAsync(
|
|
"/api/platform-admin/subscriptions",
|
|
new UpsertPlatformSubscriptionDto
|
|
{
|
|
TenantId = tenantId,
|
|
PlanCode = "standard",
|
|
Status = TenantSubscriptionStatus.Active,
|
|
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
|
AmountCents = 99900
|
|
});
|
|
var 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, subscription.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"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tenant_token_cannot_access_platform_admin_endpoints()
|
|
{
|
|
await using var factory = new ApiTestFactory();
|
|
var tenantId = Guid.NewGuid();
|
|
var userId = Guid.NewGuid();
|
|
var phone = "13866660000";
|
|
await factory.SeedAsync(
|
|
new Tenant
|
|
{
|
|
Id = tenantId,
|
|
Slug = tenantId.ToString("N"),
|
|
Name = "Tenant Realm"
|
|
},
|
|
new User
|
|
{
|
|
Id = userId,
|
|
Phone = phone,
|
|
Name = "Tenant Admin"
|
|
}.WithTestPassword(),
|
|
new TenantMembership
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
Role = TenantRole.TenantAdmin,
|
|
Status = MembershipStatus.Active
|
|
});
|
|
using var client = factory.CreateClient();
|
|
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
|
|
|
|
var response = await client.GetAsync("/api/platform-admin/overview");
|
|
|
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Platform_admin_can_manage_dunning_channels_and_retry_events()
|
|
{
|
|
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<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 TenantInvoice
|
|
{
|
|
Id = invoiceId,
|
|
TenantId = tenantId,
|
|
InvoiceNo = "INV-DUNNING-1",
|
|
Status = TenantInvoiceStatus.Overdue,
|
|
TotalCents = 10_000,
|
|
BalanceCents = 10_000
|
|
},
|
|
new TenantInvoiceReminder
|
|
{
|
|
Id = reminderId,
|
|
TenantId = tenantId,
|
|
InvoiceId = invoiceId,
|
|
ReminderType = TenantInvoiceReminderType.Overdue,
|
|
Channel = TenantInvoiceReminderChannel.Wechat,
|
|
Status = TenantInvoiceReminderStatus.Failed,
|
|
ReminderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
|
|
BalanceCentsSnapshot = 10_000
|
|
});
|
|
using var client = factory.CreateClient();
|
|
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
|
|
|
|
var upsertResponse = await client.PutAsJsonAsync(
|
|
"/api/platform-admin/dunning-notification-channels",
|
|
new UpsertPlatformDunningChannelDto
|
|
{
|
|
ChannelCode = "wecom-overdue",
|
|
Name = "企业微信逾期提醒",
|
|
Provider = PlatformDunningProvider.Wecom,
|
|
WebhookUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret-key",
|
|
SecretRef = "platform_secrets:dunning:wecom:default",
|
|
ReminderTypes = ["overdue"],
|
|
ReminderChannels = ["wechat"],
|
|
MinReminderLevel = 2,
|
|
TenantIds = [tenantId]
|
|
});
|
|
var upsertBody = await upsertResponse.Content.ReadAsStringAsync();
|
|
var channelJson = JsonDocument.Parse(upsertBody);
|
|
var channelId = channelJson.RootElement.GetProperty("id").GetGuid();
|
|
await factory.SeedAsync(new PlatformDunningNotificationEvent
|
|
{
|
|
TenantId = tenantId,
|
|
ChannelId = channelId,
|
|
ReminderId = reminderId,
|
|
InvoiceId = invoiceId,
|
|
Provider = PlatformDunningProvider.Wecom,
|
|
Status = PlatformDunningNotificationStatus.Failed,
|
|
Attempts = 2,
|
|
LastError = "timeout",
|
|
LastHttpCode = 500,
|
|
LastResponseSummary = "server error",
|
|
RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 })
|
|
});
|
|
|
|
var channelsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-channels?search=wecom");
|
|
var eventsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-events?status=failed");
|
|
var eventsJson = await JsonDocument.ParseAsync(await eventsResponse.Content.ReadAsStreamAsync());
|
|
var eventId = eventsJson.RootElement.GetProperty("items")[0].GetProperty("id").GetGuid();
|
|
var detailResponse = await client.GetAsync($"/api/platform-admin/dunning-notification-events/detail?eventId={eventId}");
|
|
var retryResponse = await client.PostAsJsonAsync(
|
|
"/api/platform-admin/dunning-notification-events/retry",
|
|
new RetryPlatformDunningEventDto
|
|
{
|
|
EventId = eventId,
|
|
Reason = "manual retry"
|
|
});
|
|
var disableResponse = await client.PostAsJsonAsync(
|
|
"/api/platform-admin/dunning-notification-channels/disable",
|
|
new DisablePlatformDunningChannelDto
|
|
{
|
|
ChannelId = channelId,
|
|
Reason = "disable test"
|
|
});
|
|
|
|
Assert.Equal(HttpStatusCode.OK, upsertResponse.StatusCode);
|
|
Assert.DoesNotContain("secret-key", upsertBody, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("https://qyapi.weixin.qq.com/****", upsertBody, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Equal(HttpStatusCode.OK, channelsResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, retryResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode);
|
|
|
|
using var scope = factory.CreateSystemScope("Verify platform dunning side effects");
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var storedEvent = await dbContext.PlatformDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId);
|
|
var storedChannel = await dbContext.PlatformDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId);
|
|
Assert.Equal(PlatformDunningNotificationStatus.Pending, storedEvent.Status);
|
|
Assert.Null(storedEvent.LastError);
|
|
Assert.False(storedChannel.Enabled);
|
|
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
|
log.ActorUserId == platform.UserId &&
|
|
log.Action == "platform.dunning_event.retry_requested"));
|
|
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
|
log.ActorUserId == platform.UserId &&
|
|
log.Action == "platform.dunning_channel.disabled"));
|
|
}
|
|
|
|
private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory)
|
|
{
|
|
var userId = Guid.NewGuid();
|
|
var roleId = Guid.NewGuid();
|
|
var email = $"platform-{Guid.NewGuid():N}@example.test";
|
|
var permissions = BackendPermissions.Platform.Select(code => new BackendPermission
|
|
{
|
|
Code = code,
|
|
Name = code,
|
|
Area = BackendPermissionArea.Platform,
|
|
Module = code.Split(':')[1],
|
|
IsSystem = true
|
|
}).Cast<object>().ToList();
|
|
await factory.SeedAsync(
|
|
[
|
|
..permissions,
|
|
new User
|
|
{
|
|
Id = userId,
|
|
Email = email,
|
|
NormalizedEmail = email.ToUpperInvariant(),
|
|
UserName = email,
|
|
NormalizedUserName = email.ToUpperInvariant(),
|
|
Name = "Platform Admin",
|
|
PrimaryRole = "platform_admin",
|
|
RawProfile = JsonDefaults.Object()
|
|
}.WithTestPassword(),
|
|
new PlatformBackendRole
|
|
{
|
|
Id = roleId,
|
|
Code = "platform_super_admin",
|
|
Name = "Platform Super Admin",
|
|
Status = BackendRoleStatus.Active,
|
|
IsSystem = true
|
|
},
|
|
..BackendPermissions.Platform.Select(code => new PlatformBackendRolePermission
|
|
{
|
|
RoleId = roleId,
|
|
PermissionCode = code
|
|
}),
|
|
new PlatformBackendUserRole
|
|
{
|
|
UserId = userId,
|
|
RoleId = roleId
|
|
}
|
|
]);
|
|
return (userId, email);
|
|
}
|
|
}
|