feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
@@ -158,6 +158,7 @@ public sealed class AuthServiceTests
|
||||
services.AddScoped<ISmsVerificationService, RejectingSmsVerificationService>();
|
||||
services.AddScoped<IWechatOAuthClient, RejectingWechatClient>();
|
||||
services.AddScoped<ITenantExternalProviderConfigService, RejectingProviderConfigService>();
|
||||
services.AddScoped<IFeatureAccessService, UnlimitedFeatureAccessService>();
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
@@ -167,6 +168,46 @@ public sealed class AuthServiceTests
|
||||
return fixture;
|
||||
}
|
||||
|
||||
private sealed class UnlimitedFeatureAccessService : IFeatureAccessService
|
||||
{
|
||||
public Task<FeatureAccessDecision> EvaluateAsync(
|
||||
Guid tenantId,
|
||||
string featureCode,
|
||||
FeatureAccessOperation operation,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new FeatureAccessDecision(true, null, featureCode, operation));
|
||||
|
||||
public Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(
|
||||
Guid tenantId,
|
||||
FeatureAccessOperation operation = FeatureAccessOperation.Read,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(SaasFeatureCatalog.All, StringComparer.Ordinal));
|
||||
|
||||
public Task<IReadOnlySet<string>> FilterPermissionCodesAsync(
|
||||
Guid tenantId,
|
||||
IEnumerable<string> permissionCodes,
|
||||
FeatureAccessOperation operation = FeatureAccessOperation.Read,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlySet<string>>(permissionCodes.ToHashSet(StringComparer.Ordinal));
|
||||
|
||||
public Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyCollection<FeatureQuotaSnapshot>>([]);
|
||||
|
||||
public Task<bool> TryConsumeQuotaAsync(
|
||||
Guid tenantId,
|
||||
string metricCode,
|
||||
long amount,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task ReleaseQuotaAsync(
|
||||
Guid tenantId,
|
||||
string metricCode,
|
||||
long amount,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SeedAsync(bool includeBackendPermission)
|
||||
{
|
||||
var tenant = new Tenant { Id = Guid.NewGuid(), Slug = Guid.NewGuid().ToString("N"), Name = "Test" };
|
||||
@@ -198,7 +239,7 @@ public sealed class AuthServiceTests
|
||||
Code = BackendPermissions.TenantDashboardView,
|
||||
Name = "Dashboard",
|
||||
Area = BackendPermissionArea.Tenant,
|
||||
Module = "dashboard"
|
||||
PermissionModuleCode = "tenant_dashboard"
|
||||
});
|
||||
DbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -147,14 +148,76 @@ public sealed class SmsVerificationServiceTests
|
||||
"device-two")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_sms_consumes_quota_once_without_compensation()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var quota = new RecordingFeatureAccessService();
|
||||
var service = CreateService(context, featureAccessService: quota);
|
||||
|
||||
await service.CreateCodeAsync(new SendSmsCodeRequest(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
"127.0.0.1",
|
||||
"quota-success"));
|
||||
|
||||
Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Consumptions);
|
||||
Assert.Empty(quota.Releases);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_sms_send_releases_reserved_quota()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var quota = new RecordingFeatureAccessService();
|
||||
var service = CreateService(context, new FailingSmsProvider(), featureAccessService: quota);
|
||||
|
||||
await Assert.ThrowsAsync<SmsProviderException>(() => service.CreateCodeAsync(new SendSmsCodeRequest(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
"127.0.0.1",
|
||||
"quota-failure")));
|
||||
|
||||
Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Consumptions);
|
||||
Assert.Equal([(tenantId, SaasQuotaMetricCatalog.SmsCount, 1L)], quota.Releases);
|
||||
Assert.Equal(SmsVerificationStatus.Failed, Assert.Single(context.SmsVerificationCodes).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exhausted_sms_quota_rejects_before_provider_send()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var tenantId = await SeedTenantAsync(context);
|
||||
var provider = new CapturingSmsProvider();
|
||||
var quota = new RecordingFeatureAccessService { AllowConsumption = false };
|
||||
var service = CreateService(context, provider, featureAccessService: quota);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<FeatureAccessException>(() => service.CreateCodeAsync(new SendSmsCodeRequest(
|
||||
tenantId,
|
||||
"13800000000",
|
||||
SmsPurpose.Login,
|
||||
"127.0.0.1",
|
||||
"quota-exhausted")));
|
||||
|
||||
Assert.Equal("feature_quota_exhausted", exception.Code);
|
||||
Assert.Empty(provider.Requests);
|
||||
Assert.Empty(quota.Releases);
|
||||
}
|
||||
|
||||
private static SmsVerificationService CreateService(
|
||||
TikuDbContext context,
|
||||
ISmsProvider? provider = null,
|
||||
SmsSecurityOptions? options = null)
|
||||
SmsSecurityOptions? options = null,
|
||||
IFeatureAccessService? featureAccessService = null)
|
||||
{
|
||||
return new SmsVerificationService(
|
||||
context,
|
||||
provider ?? new CapturingSmsProvider(),
|
||||
featureAccessService ?? new RecordingFeatureAccessService(),
|
||||
Options.Create(options ?? ValidOptions()));
|
||||
}
|
||||
|
||||
@@ -228,4 +291,53 @@ public sealed class SmsVerificationServiceTests
|
||||
return Task.FromResult(new SmsProviderSendResult("test", "sent", "message-id"));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FailingSmsProvider : ISmsProvider
|
||||
{
|
||||
public Task<SmsProviderSendResult> SendAsync(
|
||||
SmsProviderSendRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new SmsProviderException("provider failed", "sms_provider_send_failed");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingFeatureAccessService : IFeatureAccessService
|
||||
{
|
||||
public bool AllowConsumption { get; init; } = true;
|
||||
public List<(Guid TenantId, string MetricCode, long Amount)> Consumptions { get; } = [];
|
||||
public List<(Guid TenantId, string MetricCode, long Amount)> Releases { get; } = [];
|
||||
|
||||
public Task<bool> TryConsumeQuotaAsync(
|
||||
Guid tenantId,
|
||||
string metricCode,
|
||||
long amount,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Consumptions.Add((tenantId, metricCode, amount));
|
||||
return Task.FromResult(AllowConsumption);
|
||||
}
|
||||
|
||||
public Task ReleaseQuotaAsync(
|
||||
Guid tenantId,
|
||||
string metricCode,
|
||||
long amount,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Releases.Add((tenantId, metricCode, amount));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<FeatureAccessDecision> EvaluateAsync(Guid tenantId, string featureCode, FeatureAccessOperation operation, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(Guid tenantId, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlySet<string>> FilterPermissionCodesAsync(Guid tenantId, IEnumerable<string> permissionCodes, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user