355 lines
13 KiB
C#
355 lines
13 KiB
C#
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<AppDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options);
|
|
|
|
private static IConfiguration BuildConfig() =>
|
|
new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
{ "Jwt:RefreshTokenExpiryDays", "30" },
|
|
})
|
|
.Build();
|
|
|
|
/// <summary>Creates a <see cref="UserManager{TUser}"/> mock with sensible defaults.</summary>
|
|
private static Mock<UserManager<AppUser>> BuildUserManager(
|
|
AppUser? findResult = null,
|
|
bool passwordOk = true,
|
|
IList<string>? roles = null,
|
|
IdentityResult? changePasswordResult = null)
|
|
{
|
|
var store = new Mock<IUserStore<AppUser>>();
|
|
// Remaining ctor params are all optional; Moq passes them via reflection.
|
|
#pragma warning disable CS8625
|
|
var mgr = new Mock<UserManager<AppUser>>(
|
|
store.Object, null, null, null, null, null, null, null, null);
|
|
#pragma warning restore CS8625
|
|
|
|
mgr.Setup(m => m.FindByEmailAsync(It.IsAny<string>()))
|
|
.ReturnsAsync(findResult);
|
|
mgr.Setup(m => m.FindByIdAsync(It.IsAny<string>()))
|
|
.ReturnsAsync(findResult);
|
|
mgr.Setup(m => m.CheckPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
|
|
.ReturnsAsync(passwordOk);
|
|
mgr.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>()))
|
|
.ReturnsAsync(roles ?? new List<string> { "member" });
|
|
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
|
.ReturnsAsync(IdentityResult.Success);
|
|
mgr.Setup(m => m.ChangePasswordAsync(
|
|
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
|
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
|
|
|
|
return mgr;
|
|
}
|
|
|
|
/// <summary>
|
|
/// ITokenService mock: GenerateAccessToken → "access-token",
|
|
/// GenerateRefreshToken → "raw-refresh", HashToken(x) → "hash:{x}".
|
|
/// </summary>
|
|
private static Mock<ITokenService> BuildTokenService()
|
|
{
|
|
var svc = new Mock<ITokenService>();
|
|
svc.Setup(t => t.GenerateAccessToken(It.IsAny<AppUser>(), It.IsAny<IList<string>>()))
|
|
.Returns("access-token");
|
|
svc.Setup(t => t.GenerateRefreshToken())
|
|
.Returns("raw-refresh");
|
|
svc.Setup(t => t.HashToken(It.IsAny<string>()))
|
|
.Returns<string>(s => $"hash:{s}");
|
|
return svc;
|
|
}
|
|
|
|
/// <summary>IPermissionService mock: returns an empty effective-permission map.</summary>
|
|
private static Mock<IPermissionService> BuildPermissionService()
|
|
{
|
|
var svc = new Mock<IPermissionService>();
|
|
svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny<IEnumerable<string>>()))
|
|
.ReturnsAsync(new Dictionary<string, ModuleActions>());
|
|
return svc;
|
|
}
|
|
|
|
private static AuthService BuildSut(
|
|
Mock<UserManager<AppUser>> umMock,
|
|
Mock<ITokenService> 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<UnauthorizedAccessException>(
|
|
() => 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<UnauthorizedAccessException>(
|
|
() => 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<UnauthorizedAccessException>(
|
|
() => 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<AppUser>(u => u.LastLoginAt != null)), Times.Once);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 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<UnauthorizedAccessException>(
|
|
() => 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<UnauthorizedAccessException>(
|
|
() => 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<AppUser>(), It.IsAny<string>(), It.IsAny<string>()), 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
|
|
}
|
|
}
|