feat: add UserManagementService with temp-password creation and deactivation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Users;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class UserManagementServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext BuildDb() =>
|
||||||
|
new(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options);
|
||||||
|
|
||||||
|
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||||
|
AppUser? findResult = null,
|
||||||
|
bool createOk = true,
|
||||||
|
IList<string>? roles = null)
|
||||||
|
{
|
||||||
|
var store = new Mock<IUserStore<AppUser>>();
|
||||||
|
#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.FindByIdAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(findResult);
|
||||||
|
mgr.Setup(m => m.FindByEmailAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync((AppUser?)null);
|
||||||
|
mgr.Setup(m => m.CreateAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(createOk ? IdentityResult.Success
|
||||||
|
: IdentityResult.Failed(new IdentityError { Description = "fail" }));
|
||||||
|
mgr.Setup(m => m.AddToRolesAsync(It.IsAny<AppUser>(), It.IsAny<IEnumerable<string>>()))
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
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.RemoveFromRolesAsync(It.IsAny<AppUser>(), It.IsAny<IEnumerable<string>>()))
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
mgr.Setup(m => m.GeneratePasswordResetTokenAsync(It.IsAny<AppUser>()))
|
||||||
|
.ReturnsAsync("reset-token");
|
||||||
|
mgr.Setup(m => m.ResetPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
return mgr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CreateAsync ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_ReturnsTempPassword()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
// Seed a Member so MemberId validation passes
|
||||||
|
// Note: InMemory DB requires audit fields — we set them directly
|
||||||
|
var member = new Member
|
||||||
|
{
|
||||||
|
FirstName_en = "A", LastName_en = "B",
|
||||||
|
CreatedBy = "system", UpdatedBy = "system",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.Members.Add(member);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var mgr = BuildUserManager();
|
||||||
|
// Capture the AppUser passed to CreateAsync
|
||||||
|
AppUser? created = null;
|
||||||
|
mgr.Setup(m => m.CreateAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
|
||||||
|
.Callback<AppUser, string>((u, _) => { created = u; u.Id = Guid.NewGuid().ToString(); })
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
|
// Mock Users queryable to return empty (no existing user for this member)
|
||||||
|
mgr.Setup(m => m.Users)
|
||||||
|
.Returns(new List<AppUser>().AsQueryable());
|
||||||
|
|
||||||
|
var svc = new UserManagementService(mgr.Object, db);
|
||||||
|
var result = await svc.CreateAsync(new CreateUserRequest
|
||||||
|
{
|
||||||
|
MemberId = member.Id,
|
||||||
|
Email = "test@rolac.org",
|
||||||
|
Roles = ["member"],
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrEmpty(result.TempPassword));
|
||||||
|
Assert.Equal(12, result.TempPassword.Length);
|
||||||
|
Assert.NotNull(created);
|
||||||
|
Assert.Equal(member.Id, created!.MemberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_Throws_WhenMemberNotFound()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var mgr = BuildUserManager();
|
||||||
|
mgr.Setup(m => m.Users)
|
||||||
|
.Returns(new List<AppUser>().AsQueryable());
|
||||||
|
var svc = new UserManagementService(mgr.Object, db);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
svc.CreateAsync(new CreateUserRequest
|
||||||
|
{ MemberId = 9999, Email = "x@y.com", Roles = ["member"] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_Throws_WhenMemberAlreadyHasUser()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var member = new Member
|
||||||
|
{
|
||||||
|
FirstName_en = "A", LastName_en = "B",
|
||||||
|
CreatedBy = "system", UpdatedBy = "system",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.Members.Add(member);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var existingUser = new AppUser
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
UserName = "existing@test.com",
|
||||||
|
Email = "existing@test.com",
|
||||||
|
MemberId = member.Id,
|
||||||
|
};
|
||||||
|
db.Users.Add(existingUser);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var mgr = BuildUserManager();
|
||||||
|
// The service checks _userManager.Users — we need to return the existing user
|
||||||
|
mgr.Setup(m => m.Users)
|
||||||
|
.Returns(new List<AppUser> { existingUser }.AsQueryable());
|
||||||
|
var svc = new UserManagementService(mgr.Object, db);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
svc.CreateAsync(new CreateUserRequest
|
||||||
|
{ MemberId = member.Id, Email = "new@test.com", Roles = ["member"] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DeactivateAsync ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeactivateAsync_SetsIsActiveFalse()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var user = new AppUser
|
||||||
|
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
|
||||||
|
var mgr = BuildUserManager(findResult: user);
|
||||||
|
var svc = new UserManagementService(mgr.Object, db);
|
||||||
|
|
||||||
|
await svc.DeactivateAsync("u1");
|
||||||
|
|
||||||
|
Assert.False(user.IsActive);
|
||||||
|
Assert.Equal(DateTimeOffset.MaxValue, user.LockoutEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeactivateAsync_ThrowsKeyNotFound_WhenUserMissing()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var mgr = BuildUserManager(findResult: null);
|
||||||
|
var svc = new UserManagementService(mgr.Object, db);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ResetPasswordAsync ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResetPasswordAsync_ReturnsNewTempPassword()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
|
||||||
|
var mgr = BuildUserManager(findResult: user);
|
||||||
|
var svc = new UserManagementService(mgr.Object, db);
|
||||||
|
|
||||||
|
var pwd = await svc.ResetPasswordAsync("u1");
|
||||||
|
|
||||||
|
Assert.Equal(12, pwd.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using ROLAC.API.DTOs.Shared;
|
||||||
|
using ROLAC.API.DTOs.Users;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IUserManagementService
|
||||||
|
{
|
||||||
|
Task<PagedResult<UserListItemDto>> GetPagedAsync(int page, int pageSize, string? search);
|
||||||
|
Task<UserDto?> GetByIdAsync(string id);
|
||||||
|
Task<CreateUserResult> CreateAsync(CreateUserRequest request);
|
||||||
|
Task UpdateAsync(string id, UpdateUserRequest request);
|
||||||
|
Task DeactivateAsync(string id);
|
||||||
|
Task<string> ResetPasswordAsync(string id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Shared;
|
||||||
|
using ROLAC.API.DTOs.Users;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public class UserManagementService : IUserManagementService
|
||||||
|
{
|
||||||
|
private readonly UserManager<AppUser> _userManager;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public UserManagementService(UserManager<AppUser> userManager, AppDbContext db)
|
||||||
|
{
|
||||||
|
_userManager = userManager;
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetPaged ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<PagedResult<UserListItemDto>> GetPagedAsync(
|
||||||
|
int page, int pageSize, string? search)
|
||||||
|
{
|
||||||
|
var query = from u in _userManager.Users
|
||||||
|
join m in _db.Members.IgnoreQueryFilters()
|
||||||
|
on u.MemberId equals (int?)m.Id into mg
|
||||||
|
from m in mg.DefaultIfEmpty()
|
||||||
|
select new { u, m };
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var s = search.Trim().ToLower();
|
||||||
|
query = query.Where(x =>
|
||||||
|
x.u.Email!.ToLower().Contains(s) ||
|
||||||
|
(x.m != null && (
|
||||||
|
x.m.FirstName_en.ToLower().Contains(s) ||
|
||||||
|
x.m.LastName_en.ToLower().Contains(s) ||
|
||||||
|
(x.m.NickName != null && x.m.NickName.ToLower().Contains(s)))));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
var rows = await query
|
||||||
|
.OrderBy(x => x.u.Email)
|
||||||
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.u.Id, x.u.Email, x.u.MemberId, x.u.IsActive,
|
||||||
|
x.u.LanguagePreference, x.u.LastLoginAt, x.u.CreatedAt,
|
||||||
|
MemberDisplayName = x.m != null
|
||||||
|
? (x.m.NickName ?? x.m.FirstName_en) + " " + x.m.LastName_en
|
||||||
|
: (string?)null,
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Batch-load roles
|
||||||
|
var userIds = rows.Select(r => r.Id).ToList();
|
||||||
|
var roleMap = await (
|
||||||
|
from ur in _db.UserRoles
|
||||||
|
join r in _db.Roles on ur.RoleId equals r.Id
|
||||||
|
where userIds.Contains(ur.UserId)
|
||||||
|
select new { ur.UserId, r.Name }
|
||||||
|
).ToListAsync();
|
||||||
|
|
||||||
|
var rolesByUser = roleMap
|
||||||
|
.GroupBy(x => x.UserId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(x => x.Name!).ToList());
|
||||||
|
|
||||||
|
var items = rows.Select(r => new UserListItemDto
|
||||||
|
{
|
||||||
|
Id = r.Id,
|
||||||
|
Email = r.Email ?? "",
|
||||||
|
MemberId = r.MemberId,
|
||||||
|
MemberDisplayName = r.MemberDisplayName,
|
||||||
|
IsActive = r.IsActive,
|
||||||
|
LanguagePreference = r.LanguagePreference,
|
||||||
|
LastLoginAt = r.LastLoginAt,
|
||||||
|
CreatedAt = r.CreatedAt,
|
||||||
|
Roles = rolesByUser.GetValueOrDefault(r.Id, []),
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<UserListItemDto>
|
||||||
|
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetById ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<UserDto?> GetByIdAsync(string id)
|
||||||
|
{
|
||||||
|
var user = await _userManager.FindByIdAsync(id);
|
||||||
|
if (user is null) return null;
|
||||||
|
|
||||||
|
var roles = await _userManager.GetRolesAsync(user);
|
||||||
|
var memberName = user.MemberId.HasValue
|
||||||
|
? await _db.Members.IgnoreQueryFilters()
|
||||||
|
.Where(m => m.Id == user.MemberId)
|
||||||
|
.Select(m => (m.NickName ?? m.FirstName_en) + " " + m.LastName_en)
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new UserDto
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
Email = user.Email ?? "",
|
||||||
|
MemberId = user.MemberId,
|
||||||
|
MemberDisplayName = memberName,
|
||||||
|
Roles = roles.ToList(),
|
||||||
|
IsActive = user.IsActive,
|
||||||
|
LanguagePreference = user.LanguagePreference,
|
||||||
|
LastLoginAt = user.LastLoginAt,
|
||||||
|
CreatedAt = user.CreatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<CreateUserResult> CreateAsync(CreateUserRequest request)
|
||||||
|
{
|
||||||
|
// Validate Member exists (FindAsync bypasses query filter — intentional)
|
||||||
|
var member = await _db.Members.FindAsync(request.MemberId)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Member {request.MemberId} does not exist.");
|
||||||
|
|
||||||
|
// One user per member
|
||||||
|
if (_userManager.Users.Any(u => u.MemberId == request.MemberId))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"This member already has a user account.");
|
||||||
|
|
||||||
|
// Unique email
|
||||||
|
if (await _userManager.FindByEmailAsync(request.Email) is not null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Email '{request.Email}' is already in use.");
|
||||||
|
|
||||||
|
var tempPassword = GenerateTempPassword();
|
||||||
|
var user = new AppUser
|
||||||
|
{
|
||||||
|
UserName = request.Email,
|
||||||
|
Email = request.Email,
|
||||||
|
EmailConfirmed = true,
|
||||||
|
MemberId = request.MemberId,
|
||||||
|
LanguagePreference = request.LanguagePreference,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _userManager.CreateAsync(user, tempPassword);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||||
|
|
||||||
|
await _userManager.AddToRolesAsync(user, request.Roles);
|
||||||
|
|
||||||
|
return new CreateUserResult { UserId = user.Id, TempPassword = tempPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task UpdateAsync(string id, UpdateUserRequest request)
|
||||||
|
{
|
||||||
|
var user = await _userManager.FindByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException($"User {id} not found.");
|
||||||
|
|
||||||
|
user.Email = request.Email;
|
||||||
|
user.UserName = request.Email;
|
||||||
|
user.NormalizedEmail = request.Email.ToUpperInvariant();
|
||||||
|
user.NormalizedUserName = request.Email.ToUpperInvariant();
|
||||||
|
user.LanguagePreference = request.LanguagePreference;
|
||||||
|
user.IsActive = request.IsActive;
|
||||||
|
user.LockoutEnd = request.IsActive ? null : DateTimeOffset.MaxValue;
|
||||||
|
|
||||||
|
var updateResult = await _userManager.UpdateAsync(user);
|
||||||
|
if (!updateResult.Succeeded)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
string.Join("; ", updateResult.Errors.Select(e => e.Description)));
|
||||||
|
|
||||||
|
var currentRoles = await _userManager.GetRolesAsync(user);
|
||||||
|
var toRemove = currentRoles.Except(request.Roles).ToList();
|
||||||
|
var toAdd = request.Roles.Except(currentRoles).ToList();
|
||||||
|
if (toRemove.Count > 0) await _userManager.RemoveFromRolesAsync(user, toRemove);
|
||||||
|
if (toAdd.Count > 0) await _userManager.AddToRolesAsync(user, toAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deactivate ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task DeactivateAsync(string id)
|
||||||
|
{
|
||||||
|
var user = await _userManager.FindByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException($"User {id} not found.");
|
||||||
|
user.IsActive = false;
|
||||||
|
user.LockoutEnd = DateTimeOffset.MaxValue;
|
||||||
|
await _userManager.UpdateAsync(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ResetPassword ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<string> ResetPasswordAsync(string id)
|
||||||
|
{
|
||||||
|
var user = await _userManager.FindByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException($"User {id} not found.");
|
||||||
|
|
||||||
|
var tempPassword = GenerateTempPassword();
|
||||||
|
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||||
|
var result = await _userManager.ResetPasswordAsync(user, token, tempPassword);
|
||||||
|
|
||||||
|
if (!result.Succeeded)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||||
|
|
||||||
|
return tempPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string GenerateTempPassword()
|
||||||
|
{
|
||||||
|
const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||||
|
const string lower = "abcdefghjkmnpqrstuvwxyz";
|
||||||
|
const string digits = "23456789";
|
||||||
|
const string special = "!@#$%^";
|
||||||
|
const string all = upper + lower + digits + special;
|
||||||
|
|
||||||
|
var rng = new Random();
|
||||||
|
var pw = new char[12];
|
||||||
|
pw[0] = upper[rng.Next(upper.Length)];
|
||||||
|
pw[1] = lower[rng.Next(lower.Length)];
|
||||||
|
pw[2] = digits[rng.Next(digits.Length)];
|
||||||
|
pw[3] = special[rng.Next(special.Length)];
|
||||||
|
for (var i = 4; i < 12; i++) pw[i] = all[rng.Next(all.Length)];
|
||||||
|
return new string(pw.OrderBy(_ => rng.Next()).ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user