using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Moq; using ROLAC.API.Data; using ROLAC.API.DTOs.Auth; using ROLAC.API.DTOs.Permissions; using ROLAC.API.Entities; using ROLAC.API.Services; using Xunit; namespace ROLAC.API.Tests.Services; public class AuthServiceTests { // ----------------------------------------------------------------------- // Factory helpers // ----------------------------------------------------------------------- private static AppDbContext BuildDb() => new(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static IConfiguration BuildConfig() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "Jwt:RefreshTokenExpiryDays", "30" }, }) .Build(); /// Creates a mock with sensible defaults. private static Mock> BuildUserManager( AppUser? findResult = null, bool passwordOk = true, IList? roles = null, IdentityResult? changePasswordResult = null) { var store = new Mock>(); // Remaining ctor params are all optional; Moq passes them via reflection. #pragma warning disable CS8625 var mgr = new Mock>( store.Object, null, null, null, null, null, null, null, null); #pragma warning restore CS8625 mgr.Setup(m => m.FindByEmailAsync(It.IsAny())) .ReturnsAsync(findResult); mgr.Setup(m => m.FindByIdAsync(It.IsAny())) .ReturnsAsync(findResult); mgr.Setup(m => m.CheckPasswordAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(passwordOk); mgr.Setup(m => m.GetRolesAsync(It.IsAny())) .ReturnsAsync(roles ?? new List { "member" }); mgr.Setup(m => m.UpdateAsync(It.IsAny())) .ReturnsAsync(IdentityResult.Success); mgr.Setup(m => m.ChangePasswordAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(changePasswordResult ?? IdentityResult.Success); return mgr; } /// /// ITokenService mock: GenerateAccessToken → "access-token", /// GenerateRefreshToken → "raw-refresh", HashToken(x) → "hash:{x}". /// private static Mock BuildTokenService() { var svc = new Mock(); svc.Setup(t => t.GenerateAccessToken(It.IsAny(), It.IsAny>())) .Returns("access-token"); svc.Setup(t => t.GenerateRefreshToken()) .Returns("raw-refresh"); svc.Setup(t => t.HashToken(It.IsAny())) .Returns(s => $"hash:{s}"); return svc; } /// IPermissionService mock: returns an empty effective-permission map. private static Mock BuildPermissionService() { var svc = new Mock(); svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny>())) .ReturnsAsync(new Dictionary()); return svc; } private static AuthService BuildSut( Mock> umMock, Mock tsMock, AppDbContext db) => new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance, BuildConfig()); // ----------------------------------------------------------------------- // Login tests // ----------------------------------------------------------------------- [Fact] public async Task Login_ValidCredentials_ReturnsAccessTokenAndRefreshToken() { var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true }; var um = BuildUserManager(findResult: user, roles: new[] { "member" }); var ts = BuildTokenService(); var db = BuildDb(); var sut = BuildSut(um, ts, db); var (response, raw) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" }); Assert.Equal("access-token", response.AccessToken); Assert.Equal(900, response.ExpiresIn); Assert.Equal("u1", response.User.Id); Assert.Equal("a@b.com", response.User.Email); Assert.Contains("member", response.User.Roles); Assert.Equal("raw-refresh", raw); // Persisted in DB var stored = db.RefreshTokens.Single(); Assert.Equal("hash:raw-refresh", stored.TokenHash); Assert.Equal("u1", stored.UserId); } [Fact] public async Task Login_UnknownEmail_ThrowsUnauthorized() { var um = BuildUserManager(findResult: null); var ts = BuildTokenService(); var sut = BuildSut(um, ts, BuildDb()); await Assert.ThrowsAsync( () => sut.LoginAsync(new LoginRequest { Email = "nope@b.com", Password = "P@ssw0rd!" })); } [Fact] public async Task Login_WrongPassword_ThrowsUnauthorized() { var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true }; var um = BuildUserManager(findResult: user, passwordOk: false); var ts = BuildTokenService(); var sut = BuildSut(um, ts, BuildDb()); await Assert.ThrowsAsync( () => sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "wrong" })); } [Fact] public async Task Login_InactiveAccount_ThrowsUnauthorized() { var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = false }; var um = BuildUserManager(findResult: user, passwordOk: true); var ts = BuildTokenService(); var sut = BuildSut(um, ts, BuildDb()); await Assert.ThrowsAsync( () => sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" })); } [Fact] public async Task Login_Success_UpdatesLastLoginAt() { var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true }; var um = BuildUserManager(findResult: user); var ts = BuildTokenService(); var sut = BuildSut(um, ts, BuildDb()); await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" }); um.Verify(m => m.UpdateAsync(It.Is(u => u.LastLoginAt != null)), Times.Once); } [Fact] public async Task Login_LinkedMember_ReturnsMemberInfo() { var db = BuildDb(); db.Members.Add(new Member { Id = 7, NickName = "Johnny", FirstName_en = "John", LastName_en = "Chen", LastName_zh = "陳", CreatedBy = "seed", UpdatedBy = "seed", }); await db.SaveChangesAsync(); var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = 7 }; var um = BuildUserManager(findResult: user); var ts = BuildTokenService(); var sut = BuildSut(um, ts, db); var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" }); Assert.NotNull(response.User.MemberInfo); Assert.Equal(7, response.User.MemberInfo!.Id); Assert.Equal("Johnny", response.User.MemberInfo.NickName); Assert.Equal("Chen", response.User.MemberInfo.LastName_en); } [Fact] public async Task Login_AdminOnlyAccount_ReturnsNullMemberInfo() { var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = null }; var um = BuildUserManager(findResult: user); var ts = BuildTokenService(); var sut = BuildSut(um, ts, BuildDb()); var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" }); Assert.Null(response.User.MemberInfo); } // ----------------------------------------------------------------------- // Refresh tests // ----------------------------------------------------------------------- [Fact] public async Task Refresh_ValidToken_RotatesRefreshToken() { const string rawOld = "old-raw"; const string rawNew = "raw-refresh"; // what BuildTokenService returns var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true }; var um = BuildUserManager(findResult: user); var ts = BuildTokenService(); var db = BuildDb(); // Seed an active token db.RefreshTokens.Add(new RefreshToken { UserId = "u1", TokenHash = "hash:old-raw", ExpiresAt = DateTime.UtcNow.AddDays(30), CreatedAt = DateTime.UtcNow.AddHours(-1), }); await db.SaveChangesAsync(); var sut = BuildSut(um, ts, db); var (response, newRaw) = await sut.RefreshAsync(rawOld); Assert.Equal("access-token", response.AccessToken); Assert.Equal(rawNew, newRaw); // Old token revoked var old = db.RefreshTokens.First(rt => rt.TokenHash == "hash:old-raw"); Assert.NotNull(old.RevokedAt); Assert.Equal("hash:raw-refresh", old.ReplacedByHash); // New token stored var newTok = db.RefreshTokens.First(rt => rt.TokenHash == "hash:raw-refresh"); Assert.Null(newTok.RevokedAt); } [Fact] public async Task Refresh_UnknownToken_ThrowsUnauthorized() { var um = BuildUserManager(); var ts = BuildTokenService(); var sut = BuildSut(um, ts, BuildDb()); await Assert.ThrowsAsync( () => sut.RefreshAsync("no-such-token")); } [Fact] public async Task Refresh_ExpiredToken_ThrowsUnauthorized() { var db = BuildDb(); db.RefreshTokens.Add(new RefreshToken { UserId = "u1", TokenHash = "hash:expired-raw", ExpiresAt = DateTime.UtcNow.AddDays(-1), // already expired CreatedAt = DateTime.UtcNow.AddDays(-31), }); await db.SaveChangesAsync(); var um = BuildUserManager(); var ts = BuildTokenService(); var sut = BuildSut(um, ts, db); await Assert.ThrowsAsync( () => sut.RefreshAsync("expired-raw")); } // ----------------------------------------------------------------------- // Logout tests // ----------------------------------------------------------------------- [Fact] public async Task Logout_ValidToken_RevokesRefreshToken() { const string raw = "my-raw-token"; var db = BuildDb(); db.RefreshTokens.Add(new RefreshToken { UserId = "u1", TokenHash = "hash:my-raw-token", ExpiresAt = DateTime.UtcNow.AddDays(30), CreatedAt = DateTime.UtcNow.AddHours(-1), }); await db.SaveChangesAsync(); var um = BuildUserManager(); var ts = BuildTokenService(); var sut = BuildSut(um, ts, db); await sut.LogoutAsync(raw); var token = db.RefreshTokens.Single(); Assert.NotNull(token.RevokedAt); } // ----------------------------------------------------------------------- // Change password tests // ----------------------------------------------------------------------- [Fact] public async Task ChangePassword_ValidRequest_Succeeds() { var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true }; var um = BuildUserManager(findResult: user); var ts = BuildTokenService(); var sut = BuildSut(um, ts, BuildDb()); var result = await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", null); Assert.True(result.Succeeded); um.Verify(m => m.ChangePasswordAsync(user, "Old1234!", "New1234!"), Times.Once); } [Fact] public async Task ChangePassword_UnknownUser_Fails() { var um = BuildUserManager(findResult: null); var ts = BuildTokenService(); var sut = BuildSut(um, ts, BuildDb()); var result = await sut.ChangePasswordAsync("missing", "Old1234!", "New1234!", null); Assert.False(result.Succeeded); um.Verify(m => m.ChangePasswordAsync( It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task ChangePassword_WrongCurrentPassword_ReturnsFailure() { var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true }; var failed = IdentityResult.Failed(new IdentityError { Description = "Incorrect password." }); var um = BuildUserManager(findResult: user, changePasswordResult: failed); var ts = BuildTokenService(); var sut = BuildSut(um, ts, BuildDb()); var result = await sut.ChangePasswordAsync("u1", "WrongOld!", "New1234!", null); Assert.False(result.Succeeded); } [Fact] public async Task ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent() { var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true }; var um = BuildUserManager(findResult: user); var ts = BuildTokenService(); // HashToken(x) => "hash:{x}" var db = BuildDb(); // Current session token (raw "current-raw" => "hash:current-raw") db.RefreshTokens.Add(new RefreshToken { UserId = "u1", TokenHash = "hash:current-raw", ExpiresAt = DateTime.UtcNow.AddDays(30), CreatedAt = DateTime.UtcNow.AddHours(-1), }); // Another active session on a different device db.RefreshTokens.Add(new RefreshToken { UserId = "u1", TokenHash = "hash:other-device", ExpiresAt = DateTime.UtcNow.AddDays(30), CreatedAt = DateTime.UtcNow.AddHours(-2), }); await db.SaveChangesAsync(); var sut = BuildSut(um, ts, db); await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", "current-raw"); var current = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:current-raw"); var other = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:other-device"); Assert.Null(current.RevokedAt); // current session preserved Assert.NotNull(other.RevokedAt); // other session revoked } }