backend phase 2: identity — phone-OTP auth, sessions & roles (REST)
- six REST endpoints (auth/request_otp, verify_otp, refresh, logout, me, me/select_role) wrapping the existing JWE/TOTP/RBAC engine - usr.UserSessions with refresh-token rotation + stolen-token (replay) detection → revoke-all + 401; logout rotates the security stamp - users extended: gender, national_id (enc, NULL until KYC), shahkar_verified_at (auto-reset on phone change), phone_hash UNIQUE, is_active, deleted_at + soft-delete filter; phone/email/national_id encrypted at rest via IFieldEncryptor value converter - user_roles grant/revoke audit trail + global revoked filter; 7 roles seeded; admin sub-roles never self-assignable (403) - ISmsSender seam (mock logs the OTP code) replaces the TODO log lines - OperationResult/BaseController learned enveloped 401/403 - auth knobs as platform_configs rows (resend/attempts/session TTL) - migration IdentitySessionsAndUserExtensions applied to the dev DB - 24 new tests incl. Baya.Test.Api (WebApplicationFactory over SQLite); 47 total green, zero new build warnings; swagger snapshot + contract (identity-auth.md), handoff, report, mocks-registry updated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
using Baya.Application.Contracts;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Identity;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Identity;
|
||||
using Baya.Application.Features.Identity.Commands.RefreshToken;
|
||||
using Baya.Application.Models.Jwt;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
|
||||
namespace Baya.Test.Foundation.Identity;
|
||||
|
||||
public class RefreshTokenCommandHandlerTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly IUserSessionRepository _sessions = Substitute.For<IUserSessionRepository>();
|
||||
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
|
||||
private readonly IAppUserManager _userManager = Substitute.For<IAppUserManager>();
|
||||
private readonly IPlatformConfig _platformConfig = Substitute.For<IPlatformConfig>();
|
||||
private readonly IFieldEncryptor _fieldEncryptor = Substitute.For<IFieldEncryptor>();
|
||||
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
|
||||
private RefreshTokenCommandHandler CreateHandler()
|
||||
{
|
||||
_clock.UtcNow.Returns(Now);
|
||||
_unitOfWork.UserSessionRepository.Returns(_sessions);
|
||||
_fieldEncryptor.Hash(Arg.Any<string>()).Returns("TOKEN_HASH");
|
||||
_platformConfig.GetConfig<int>(IdentityDefaults.SessionTtlDaysKey, Arg.Any<CancellationToken>()).Returns(30);
|
||||
_jwtService.GenerateAccessTokenAsync(Arg.Any<User>())
|
||||
.Returns(new JweAccessToken("new-access-token", Now.AddMinutes(15)));
|
||||
|
||||
return new RefreshTokenCommandHandler(
|
||||
_unitOfWork, _jwtService, _userManager, _platformConfig, _fieldEncryptor, _clock, _currentUser,
|
||||
NullLogger<RefreshTokenCommandHandler>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ActiveSession_RotatesAndReturnsNewPair()
|
||||
{
|
||||
// Arrange
|
||||
var user = new User { Id = 7, IsActive = true };
|
||||
var session = new UserSession
|
||||
{
|
||||
UserId = 7,
|
||||
User = user,
|
||||
RefreshTokenHash = "TOKEN_HASH",
|
||||
ExpiresAt = Now.AddDays(10)
|
||||
};
|
||||
_sessions.GetByTokenHashAsync("TOKEN_HASH", Arg.Any<CancellationToken>()).Returns(session);
|
||||
_userManager.GetRoleAsync(user).Returns(["customer"]);
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new RefreshTokenCommand("raw-refresh-token"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(session.IsRevoked);
|
||||
Assert.Equal(Now, session.RevokedAt);
|
||||
Assert.Equal("new-access-token", result.Result.AccessToken);
|
||||
Assert.Contains("customer", result.Result.Roles);
|
||||
await _sessions.Received(1).AddAsync(Arg.Is<UserSession>(s => !s.IsRevoked && s.UserId == 7), Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RevokedSessionReplay_RevokesEverythingAndReturns401()
|
||||
{
|
||||
// Arrange — a token presented against an already-rotated session is a stolen-token signal.
|
||||
var session = new UserSession { UserId = 7, RefreshTokenHash = "TOKEN_HASH", IsRevoked = true, ExpiresAt = Now.AddDays(10) };
|
||||
_sessions.GetByTokenHashAsync("TOKEN_HASH", Arg.Any<CancellationToken>()).Returns(session);
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new RefreshTokenCommand("raw-refresh-token"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.True(result.IsUnauthorized);
|
||||
await _sessions.Received(1).RevokeAllActiveForUserAsync(7, Now, Arg.Any<CancellationToken>());
|
||||
await _sessions.DidNotReceive().AddAsync(Arg.Any<UserSession>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ExpiredSession_Returns401AndRevokesIt()
|
||||
{
|
||||
// Arrange
|
||||
var session = new UserSession { UserId = 7, RefreshTokenHash = "TOKEN_HASH", ExpiresAt = Now.AddMinutes(-1) };
|
||||
_sessions.GetByTokenHashAsync("TOKEN_HASH", Arg.Any<CancellationToken>()).Returns(session);
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new RefreshTokenCommand("raw-refresh-token"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsUnauthorized);
|
||||
Assert.True(session.IsRevoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UnknownToken_Returns401()
|
||||
{
|
||||
// Arrange
|
||||
_sessions.GetByTokenHashAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new RefreshTokenCommand("bogus"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsUnauthorized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Identity;
|
||||
using Baya.Application.Features.Identity;
|
||||
using Baya.Application.Features.Identity.Commands.RequestOtp;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
|
||||
namespace Baya.Test.Foundation.Identity;
|
||||
|
||||
public class RequestOtpCommandHandlerTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly IAppUserManager _userManager = Substitute.For<IAppUserManager>();
|
||||
private readonly ISmsSender _smsSender = Substitute.For<ISmsSender>();
|
||||
private readonly IPlatformConfig _platformConfig = Substitute.For<IPlatformConfig>();
|
||||
private readonly ICacheService _cache = Substitute.For<ICacheService>();
|
||||
private readonly IFieldEncryptor _fieldEncryptor = Substitute.For<IFieldEncryptor>();
|
||||
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
|
||||
|
||||
private RequestOtpCommandHandler CreateHandler()
|
||||
{
|
||||
_clock.UtcNow.Returns(Now);
|
||||
_fieldEncryptor.Hash(Arg.Any<string>()).Returns("PHONE_HASH");
|
||||
_platformConfig.GetConfig<int>(IdentityDefaults.OtpResendSecondsKey, Arg.Any<CancellationToken>())
|
||||
.Returns(120);
|
||||
|
||||
return new RequestOtpCommandHandler(_userManager, _smsSender, _platformConfig, _cache, _fieldEncryptor, _clock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NewPhone_CreatesInactiveUserAndSendsOtp()
|
||||
{
|
||||
// Arrange
|
||||
_userManager.GetUserByPhoneNumber("09123456789").ReturnsNull();
|
||||
_userManager.CreateUser(Arg.Any<User>()).Returns(IdentityResult.Success);
|
||||
_userManager.GeneratePhoneNumberConfirmationToken(Arg.Any<User>(), "09123456789").Returns("123456");
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new RequestOtpCommand("09123456789"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Result.OtpSent);
|
||||
Assert.Equal(120, result.Result.ResendAvailableInSeconds);
|
||||
await _userManager.Received(1).CreateUser(Arg.Is<User>(u => !u.IsActive && u.PhoneNumber == "09123456789"));
|
||||
await _smsSender.Received(1).SendOtpAsync("09123456789", "123456", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ResendWindowOpen_DoesNotSendAndReportsRemainingSeconds()
|
||||
{
|
||||
// Arrange
|
||||
_cache.GetAsync<DateTimeOffset>(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Now.AddSeconds(60));
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new RequestOtpCommand("09123456789"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.Result.OtpSent);
|
||||
Assert.Equal(60, result.Result.ResendAvailableInSeconds);
|
||||
await _smsSender.DidNotReceive().SendOtpAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ExistingConfirmedUser_UsesPasswordlessOtpAndSameShape()
|
||||
{
|
||||
// Arrange
|
||||
var user = new User { PhoneNumber = "09123456789", PhoneNumberConfirmed = true };
|
||||
_userManager.GetUserByPhoneNumber("09123456789").Returns(user);
|
||||
_userManager.GenerateOtpCode(user).Returns("654321");
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new RequestOtpCommand("+989123456789"), CancellationToken.None);
|
||||
|
||||
// Assert — normalized phone, no enumeration (same shape as the new-user path).
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Result.OtpSent);
|
||||
await _smsSender.Received(1).SendOtpAsync("09123456789", "654321", Arg.Any<CancellationToken>());
|
||||
await _userManager.DidNotReceive().CreateUser(Arg.Any<User>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Identity.Commands.SelectRole;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
|
||||
namespace Baya.Test.Foundation.Identity;
|
||||
|
||||
public class SelectRoleCommandHandlerTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly IUserAccountRepository _accounts = Substitute.For<IUserAccountRepository>();
|
||||
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
|
||||
|
||||
private SelectRoleCommandHandler CreateHandler()
|
||||
{
|
||||
_clock.UtcNow.Returns(Now);
|
||||
_currentUser.UserId.Returns(7);
|
||||
_unitOfWork.UserAccountRepository.Returns(_accounts);
|
||||
_accounts.GetAccountSnapshotAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new UserAccountSnapshot(7, "09123456789", null, null, null, true, ["customer"]));
|
||||
|
||||
return new SelectRoleCommandHandler(_currentUser, _unitOfWork, _clock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AdminSubRole_IsForbidden()
|
||||
{
|
||||
// Arrange
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new SelectRoleCommand("super_admin"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.True(result.IsForbidden);
|
||||
await _accounts.DidNotReceive().AddUserRoleAsync(Arg.Any<UserRole>(), Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NewCustomerRole_GrantsWithSelfAudit()
|
||||
{
|
||||
// Arrange
|
||||
_accounts.GetRoleByNameAsync("customer", Arg.Any<CancellationToken>()).Returns(new Role { Id = 3, Name = "customer" });
|
||||
_accounts.GetUserRoleIncludingRevokedAsync(7, 3, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new SelectRoleCommand("Customer"), CancellationToken.None);
|
||||
|
||||
// Assert — case-insensitive input, granted_by = self, masked phone in the payload.
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Contains("customer", result.Result.Roles);
|
||||
Assert.DoesNotContain("09123456789", result.Result.Phone);
|
||||
await _accounts.Received(1).AddUserRoleAsync(
|
||||
Arg.Is<UserRole>(ur => ur.UserId == 7 && ur.RoleId == 3 && ur.GrantedById == 7 && ur.GrantedAt == Now),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RoleAlreadyHeld_IsIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
_accounts.GetRoleByNameAsync("customer", Arg.Any<CancellationToken>()).Returns(new Role { Id = 3, Name = "customer" });
|
||||
_accounts.GetUserRoleIncludingRevokedAsync(7, 3, Arg.Any<CancellationToken>())
|
||||
.Returns(new UserRole { UserId = 7, RoleId = 3, GrantedAt = Now.AddDays(-1) });
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new SelectRoleCommand("customer"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
await _accounts.DidNotReceive().AddUserRoleAsync(Arg.Any<UserRole>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_RevokedGrant_IsReactivatedNotDuplicated()
|
||||
{
|
||||
// Arrange
|
||||
var revoked = new UserRole { UserId = 7, RoleId = 3, GrantedAt = Now.AddDays(-10), RevokedAt = Now.AddDays(-5) };
|
||||
_accounts.GetRoleByNameAsync("nurse", Arg.Any<CancellationToken>()).Returns(new Role { Id = 3, Name = "nurse" });
|
||||
_accounts.GetUserRoleIncludingRevokedAsync(7, 3, Arg.Any<CancellationToken>()).Returns(revoked);
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new SelectRoleCommand("nurse"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null(revoked.RevokedAt);
|
||||
Assert.Equal(Now, revoked.GrantedAt);
|
||||
Assert.Equal(7, revoked.GrantedById);
|
||||
await _accounts.DidNotReceive().AddUserRoleAsync(Arg.Any<UserRole>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Baya.Application.Contracts;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Identity;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Identity;
|
||||
using Baya.Application.Features.Identity.Commands.VerifyOtp;
|
||||
using Baya.Application.Models.Jwt;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
|
||||
namespace Baya.Test.Foundation.Identity;
|
||||
|
||||
public class VerifyOtpCommandHandlerTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly IAppUserManager _userManager = Substitute.For<IAppUserManager>();
|
||||
private readonly IJwtService _jwtService = Substitute.For<IJwtService>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly IUserSessionRepository _sessions = Substitute.For<IUserSessionRepository>();
|
||||
private readonly IPlatformConfig _platformConfig = Substitute.For<IPlatformConfig>();
|
||||
private readonly IFieldEncryptor _fieldEncryptor = Substitute.For<IFieldEncryptor>();
|
||||
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
|
||||
private VerifyOtpCommandHandler CreateHandler()
|
||||
{
|
||||
_clock.UtcNow.Returns(Now);
|
||||
_unitOfWork.UserSessionRepository.Returns(_sessions);
|
||||
_fieldEncryptor.Hash(Arg.Any<string>()).Returns("TOKEN_HASH");
|
||||
_platformConfig.GetConfig<int>(IdentityDefaults.OtpMaxAttemptsKey, Arg.Any<CancellationToken>()).Returns(5);
|
||||
_platformConfig.GetConfig<int>(IdentityDefaults.SessionTtlDaysKey, Arg.Any<CancellationToken>()).Returns(30);
|
||||
_jwtService.GenerateAccessTokenAsync(Arg.Any<User>())
|
||||
.Returns(new JweAccessToken("jwe-access-token", Now.AddMinutes(15)));
|
||||
|
||||
return new VerifyOtpCommandHandler(
|
||||
_userManager, _jwtService, _unitOfWork, _platformConfig, _fieldEncryptor, _clock, _currentUser);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ValidCodeForNewUser_ActivatesUserMintsTokensAndSession()
|
||||
{
|
||||
// Arrange
|
||||
var user = new User { PhoneNumber = "09123456789", PhoneNumberConfirmed = false };
|
||||
_userManager.GetUserByPhoneNumber("09123456789").Returns(user);
|
||||
_userManager.ChangePhoneNumber(user, "09123456789", "123456").Returns(IdentityResult.Success);
|
||||
_userManager.GetRoleAsync(user).Returns([]);
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new VerifyOtpCommand("09123456789", "123456", "test-device"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Result.IsNewUser);
|
||||
Assert.Empty(result.Result.Roles);
|
||||
Assert.Equal("jwe-access-token", result.Result.AccessToken);
|
||||
Assert.False(string.IsNullOrEmpty(result.Result.RefreshToken));
|
||||
Assert.Equal(Now.AddDays(30), result.Result.RefreshExpiresAt);
|
||||
Assert.True(user.IsActive);
|
||||
Assert.Equal(Now, user.PhoneVerifiedAt);
|
||||
await _sessions.Received(1).AddAsync(
|
||||
Arg.Is<UserSession>(s => s.RefreshTokenHash == "TOKEN_HASH" && !s.IsRevoked && s.DeviceInfo == "test-device"),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WrongCode_IncrementsAttemptsAndFailsSafely()
|
||||
{
|
||||
// Arrange
|
||||
var user = new User { PhoneNumber = "09123456789", PhoneNumberConfirmed = true };
|
||||
_userManager.GetUserByPhoneNumber("09123456789").Returns(user);
|
||||
_userManager.VerifyUserCode(user, "999999")
|
||||
.Returns(IdentityResult.Failed(new IdentityError { Description = "Incorrect Code" }));
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new VerifyOtpCommand("09123456789", "999999"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuccess);
|
||||
await _userManager.Received(1).IncrementAccessFailedCountAsync(user);
|
||||
await _sessions.DidNotReceive().AddAsync(Arg.Any<UserSession>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TooManyFailedAttempts_RefusesWithoutCheckingTheCode()
|
||||
{
|
||||
// Arrange
|
||||
var user = new User { PhoneNumber = "09123456789", PhoneNumberConfirmed = true, AccessFailedCount = 5 };
|
||||
_userManager.GetUserByPhoneNumber("09123456789").Returns(user);
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new VerifyOtpCommand("09123456789", "123456"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuccess);
|
||||
await _userManager.DidNotReceive().VerifyUserCode(Arg.Any<User>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UnknownPhone_FailsWithTheSameSafeMessageAsAWrongCode()
|
||||
{
|
||||
// Arrange — no enumeration: unknown phone and wrong code are indistinguishable.
|
||||
_userManager.GetUserByPhoneNumber(Arg.Any<string>()).ReturnsNull();
|
||||
var handler = CreateHandler();
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new VerifyOtpCommand("09123456789", "123456"), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Contains(result.ErrorMessages, e => e.Value.Contains("invalid or expired", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.CrossCutting.Seams;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Infrastructure.Persistence.Interceptors;
|
||||
using Baya.Tests.Setup.Setups;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -36,7 +37,7 @@ internal sealed class OpsTestHost : IDisposable
|
||||
.AddInterceptors(interceptor)
|
||||
.Options;
|
||||
|
||||
Db = new ApplicationDbContext(options);
|
||||
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
|
||||
Db.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
@@ -66,4 +67,5 @@ internal sealed class TestCurrentUser : ICurrentUser
|
||||
public int? UserId { get; set; }
|
||||
public bool IsAuthenticated => UserId is not null;
|
||||
public IReadOnlyList<string> Roles { get; set; } = [];
|
||||
public string? IpAddress { get; set; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user