Files
tiku-backend.net/Tiku.UnitTests/Auth/SmsVerificationServiceTests.cs

232 lines
8.0 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
namespace Tiku.UnitTests.Auth;
public sealed class SmsVerificationServiceTests
{
private const string Pepper = "unit-test-sms-code-pepper-32-characters";
[Fact]
public void Hash_uses_the_server_pepper()
{
var tenantId = Guid.NewGuid();
var first = SmsCodeHashing.Hash(tenantId, "13800000000", SmsPurpose.Login, "123456", Pepper);
var second = SmsCodeHashing.Hash(
tenantId,
"13800000000",
SmsPurpose.Login,
"123456",
"another-unit-test-pepper-32-characters");
Assert.NotEqual(first, second);
Assert.Equal(64, first.Length);
}
[Fact]
public async Task Create_code_generates_six_digits_and_consumes_all_available_rate_limit_dimensions()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var provider = new CapturingSmsProvider();
var service = CreateService(context, provider);
await service.CreateCodeAsync(new SendSmsCodeRequest(
tenantId,
"13800000000",
SmsPurpose.Login,
"127.0.0.1",
"test-device"));
Assert.Matches("^[0-9]{6}$", Assert.Single(provider.Requests).Code);
Assert.Equal(4, context.SmsSendRateLimits.Count());
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Tenant);
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Phone);
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Ip);
Assert.Contains(context.SmsSendRateLimits, item => item.Dimension == SmsRateLimitDimension.Device);
var sendEvent = Assert.Single(context.AuthLoginEvents);
Assert.Equal(AuthLoginResult.Sent, sendEvent.Result);
Assert.Equal("sms", sendEvent.Provider);
}
[Fact]
public async Task Fifth_invalid_attempt_blocks_code_and_correct_code_is_then_rejected()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var verification = await SeedCodeAsync(context, tenantId, "123456");
var service = CreateService(context);
for (var attempt = 0; attempt < 5; attempt++)
{
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "999999"));
}
context.ChangeTracker.Clear();
var blocked = await context.SmsVerificationCodes.FindAsync(verification.Id);
Assert.NotNull(blocked);
Assert.Equal(5, blocked.Attempts);
Assert.Equal(SmsVerificationStatus.Blocked, blocked.Status);
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
}
[Fact]
public async Task Expired_code_is_transitioned_to_expired()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var verification = await SeedCodeAsync(
context,
tenantId,
"123456",
DateTimeOffset.UtcNow.AddSeconds(-1));
var service = CreateService(context);
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
context.ChangeTracker.Clear();
Assert.Equal(
SmsVerificationStatus.Expired,
(await context.SmsVerificationCodes.FindAsync(verification.Id))!.Status);
}
[Fact]
public async Task Successful_code_can_only_be_consumed_once()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var verification = await SeedCodeAsync(context, tenantId, "123456");
var service = CreateService(context);
await service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456");
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
service.VerifyCodeAsync(tenantId, "13800000000", SmsPurpose.Login, "123456"));
context.ChangeTracker.Clear();
var consumed = await context.SmsVerificationCodes.FindAsync(verification.Id);
Assert.Equal(SmsVerificationStatus.Verified, consumed!.Status);
Assert.NotNull(consumed.ConsumedAt);
}
[Fact]
public async Task Phone_limit_applies_across_ip_and_device_changes()
{
await using var context = CreateContext();
var tenantId = await SeedTenantAsync(context);
var options = new SmsSecurityOptions
{
CodePepper = Pepper,
TenantRequestsPerHour = 100,
PhoneRequestsPerHour = 1,
IpRequestsPerHour = 20,
DeviceRequestsPerHour = 10
};
var service = CreateService(context, options: options);
await service.CreateCodeAsync(new SendSmsCodeRequest(
tenantId,
"13800000000",
SmsPurpose.Login,
"127.0.0.1",
"device-one"));
await Assert.ThrowsAsync<SmsRateLimitedException>(() =>
service.CreateCodeAsync(new SendSmsCodeRequest(
tenantId,
"13800000000",
SmsPurpose.Login,
"127.0.0.2",
"device-two")));
}
private static SmsVerificationService CreateService(
TikuDbContext context,
ISmsProvider? provider = null,
SmsSecurityOptions? options = null)
{
return new SmsVerificationService(
context,
provider ?? new CapturingSmsProvider(),
Options.Create(options ?? ValidOptions()));
}
private static SmsSecurityOptions ValidOptions()
{
return new SmsSecurityOptions
{
CodePepper = Pepper,
TenantRequestsPerHour = 100,
PhoneRequestsPerHour = 5,
IpRequestsPerHour = 20,
DeviceRequestsPerHour = 10
};
}
private static TikuDbContext CreateContext()
{
return new TikuDbContext(
new DbContextOptionsBuilder<TikuDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
}
private static async Task<Guid> SeedTenantAsync(TikuDbContext context)
{
var tenant = new Tenant
{
Id = Guid.NewGuid(),
Slug = Guid.NewGuid().ToString("N"),
Name = "SMS Test Tenant"
};
context.Tenants.Add(tenant);
await context.SaveChangesAsync();
return tenant.Id;
}
private static async Task<SmsVerificationCode> SeedCodeAsync(
TikuDbContext context,
Guid tenantId,
string code,
DateTimeOffset? expiresAt = null)
{
var verification = new SmsVerificationCode
{
TenantId = tenantId,
Phone = "13800000000",
Purpose = SmsPurpose.Login,
CodeHash = SmsCodeHashing.Hash(
tenantId,
"13800000000",
SmsPurpose.Login,
code,
Pepper),
Status = SmsVerificationStatus.Sent,
ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddMinutes(5)
};
context.SmsVerificationCodes.Add(verification);
await context.SaveChangesAsync();
return verification;
}
private sealed class CapturingSmsProvider : ISmsProvider
{
public List<SmsProviderSendRequest> Requests { get; } = [];
public Task<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default)
{
Requests.Add(request);
return Task.FromResult(new SmsProviderSendResult("test", "sent", "message-id"));
}
}
}