feat: add phase five operations foundation

This commit is contained in:
2026-07-28 09:57:50 +08:00
parent 71793a2de0
commit f22f329d33
39 changed files with 4810 additions and 160 deletions

View File

@@ -135,6 +135,60 @@ public sealed class AuthServiceTests
entity.FailureCode == "invalid_sms_code");
}
[Fact]
public async Task Sms_send_failure_records_failed_code_and_does_not_leave_usable_verification()
{
await using var context = CreateContext();
var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!"));
var smsService = new SmsVerificationService(context, new FailingSmsProvider());
var exception = await Assert.ThrowsAsync<SmsProviderException>(() =>
smsService.CreateCodeAsync(new SendSmsCodeRequest(
seed.TenantId,
seed.Phone,
SmsPurpose.Login,
null,
null)));
Assert.Equal("sms_provider_send_failed", exception.Code);
var verification = Assert.Single(context.SmsVerificationCodes);
Assert.Equal(SmsVerificationStatus.Failed, verification.Status);
Assert.Equal("failed", verification.Provider);
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
smsService.VerifyCodeAsync(
seed.TenantId,
seed.Phone,
SmsPurpose.Login,
"123456"));
}
[Fact]
public async Task Failed_sms_code_is_never_accepted_even_when_hash_matches()
{
await using var context = CreateContext();
var seed = await SeedUserAsync(context, new PasswordHasher().Hash("passw0rd!"));
context.SmsVerificationCodes.Add(new SmsVerificationCode
{
TenantId = seed.TenantId,
Phone = seed.Phone,
Purpose = SmsPurpose.Login,
CodeHash = SmsCodeHashing.Hash(seed.TenantId, seed.Phone, SmsPurpose.Login, "123456"),
Status = SmsVerificationStatus.Failed,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
});
await context.SaveChangesAsync();
var smsService = new SmsVerificationService(context, new FakeSmsProvider());
await Assert.ThrowsAsync<InvalidCredentialsException>(() =>
smsService.VerifyCodeAsync(
seed.TenantId,
seed.Phone,
SmsPurpose.Login,
"123456"));
}
[Fact]
public async Task Revoked_refresh_token_cannot_be_refreshed()
{
@@ -410,6 +464,14 @@ public sealed class AuthServiceTests
Task.FromResult(new SmsProviderSendResult("fake", "accepted"));
}
private sealed class FailingSmsProvider : ISmsProvider
{
public Task<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("provider unavailable");
}
private sealed class FakeWechatOAuthClient(
WechatIdentity? WebIdentity = null,
WechatIdentity? MiniAppIdentity = null) : IWechatOAuthClient