using System.Security.Claims; using System.Linq.Expressions; using System.Data.Common; using JobTrackerApi.Controllers; using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Tests.TestSupport; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Data.Sqlite; using JobTrackerApi.Data; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class UsersControllerTests { [Fact] public async Task SetRoles_rejects_removing_the_final_administrator() { var admin = User("admin-1"); var users = TestHostFactory.CreateUserManager(admin); users.Setup(x => x.GetRolesAsync(admin)).ReturnsAsync(["Admin"]); users.Setup(x => x.GetUsersInRoleAsync("Admin")).ReturnsAsync([admin]); var controller = CreateController(users, admin.Id); var result = await controller.SetRoles(admin.Id, new UsersController.SetRolesRequest([]), CancellationToken.None); var conflict = Assert.IsType(result); Assert.Equal("Last administrator protected", Assert.IsType(conflict.Value).Title); users.Verify(x => x.RemoveFromRolesAsync(It.IsAny(), It.IsAny>()), Times.Never); } [Fact] public async Task SetRoles_allows_a_confirmed_self_demotion_when_another_admin_exists() { var admin = User("admin-1"); var otherAdmin = User("admin-2"); var users = TestHostFactory.CreateUserManager(admin); users.Setup(x => x.GetRolesAsync(admin)).ReturnsAsync(["Admin"]); users.Setup(x => x.GetUsersInRoleAsync("Admin")).ReturnsAsync([admin, otherAdmin]); users.Setup(x => x.RemoveFromRolesAsync(admin, It.Is>(roles => roles.Contains("Admin")))) .ReturnsAsync(IdentityResult.Success); var controller = CreateController(users, admin.Id); var result = await controller.SetRoles(admin.Id, new UsersController.SetRolesRequest([]), CancellationToken.None); Assert.IsType(result); users.Verify(x => x.RemoveFromRolesAsync(admin, It.IsAny>()), Times.Once); } [Fact] public async Task Delete_rejects_deleting_the_final_administrator() { var admin = User("admin-1"); var users = TestHostFactory.CreateUserManager(admin); users.Setup(x => x.IsInRoleAsync(admin, "Admin")).ReturnsAsync(true); users.Setup(x => x.GetUsersInRoleAsync("Admin")).ReturnsAsync([admin]); var controller = CreateController(users, admin.Id); var result = await controller.Delete(admin.Id, CancellationToken.None); Assert.IsType(result); users.Verify(x => x.DeleteAsync(It.IsAny()), Times.Never); } [Fact] public async Task Delete_never_falls_back_to_identity_only_removal() { var member = User("user-1"); var users = TestHostFactory.CreateUserManager(member); users.Setup(x => x.IsInRoleAsync(member, "Admin")).ReturnsAsync(false); var controller = CreateController(users, "admin-1"); var result = Assert.IsType(await controller.Delete(member.Id, CancellationToken.None)); Assert.Equal(StatusCodes.Status503ServiceUnavailable, result.StatusCode); users.Verify(x => x.DeleteAsync(It.IsAny()), Times.Never); } [Fact] public async Task List_marks_the_current_user_and_last_admin_safety_state() { var admin = User("admin-1"); var member = User("user-1"); var users = TestHostFactory.CreateUserManager(); users.SetupGet(x => x.Users).Returns(new TestAsyncEnumerable([admin, member])); users.Setup(x => x.GetUsersInRoleAsync("Admin")).ReturnsAsync([admin]); users.Setup(x => x.GetRolesAsync(admin)).ReturnsAsync(["Admin"]); users.Setup(x => x.GetRolesAsync(member)).ReturnsAsync([]); var controller = CreateController(users, admin.Id); var action = await controller.List(CancellationToken.None); var rows = Assert.IsType>(Assert.IsType(action.Result).Value); var adminRow = Assert.Single(rows, row => row.Id == admin.Id); Assert.True(adminRow.IsCurrentUser); Assert.False(adminRow.CanRemoveAdmin); Assert.True(Assert.Single(rows, row => row.Id == member.Id).CanRemoveAdmin); } [Fact] public async Task List_uses_a_fixed_number_of_role_queries_for_many_users() { var counter = new CommandCounter(); await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var currentUser = new Mock(); currentUser.SetupGet(service => service.UserId).Returns("admin-1"); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .AddInterceptors(counter) .Options; await using var db = new JobTrackerContext(options, currentUser.Object); await db.Database.EnsureCreatedAsync(); var adminRole = new IdentityRole("Admin") { Id = "role-admin", NormalizedName = "ADMIN" }; var memberRole = new IdentityRole("Member") { Id = "role-member", NormalizedName = "MEMBER" }; db.Roles.AddRange(adminRole, memberRole); var usersToAdd = Enumerable.Range(1, 75).Select(index => User(index == 1 ? "admin-1" : $"user-{index}")).ToList(); db.Users.AddRange(usersToAdd); db.UserRoles.Add(new IdentityUserRole { UserId = "admin-1", RoleId = adminRole.Id }); foreach (var user in usersToAdd.Skip(1)) db.UserRoles.Add(new IdentityUserRole { UserId = user.Id, RoleId = memberRole.Id }); await db.SaveChangesAsync(); counter.Reset(); var manager = TestHostFactory.CreateUserManager(); var controller = CreateController(manager, "admin-1", db); var action = await controller.List(CancellationToken.None); var rows = Assert.IsType>(Assert.IsType(action.Result).Value); Assert.Equal(75, rows.Count); Assert.Equal(2, counter.ReaderCount); manager.Verify(userManager => userManager.GetRolesAsync(It.IsAny()), Times.Never); manager.Verify(userManager => userManager.GetUsersInRoleAsync(It.IsAny()), Times.Never); } private static ApplicationUser User(string id) => new() { Id = id, Email = $"{id}@example.com", UserName = $"{id}@example.com" }; private static UsersController CreateController(Mock> users, string currentUserId, JobTrackerContext? db = null) { var roleStore = new Mock>(); var roles = new Mock>( roleStore.Object, Array.Empty>(), new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), new NullLogger>()); var controller = new UsersController( users.Object, roles.Object, Mock.Of(), new ConfigurationBuilder().Build(), new NullLogger(), ExternalOrigin.Parse("http://localhost:3000", production: false), db: db); var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, currentUserId)], "test"); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) } }; return controller; } private sealed class CommandCounter : DbCommandInterceptor { public int ReaderCount { get; private set; } public void Reset() => ReaderCount = 0; public override ValueTask> ReaderExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { ReaderCount++; return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); } } private sealed class TestAsyncQueryProvider(IQueryProvider inner) : IAsyncQueryProvider { public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); public object? Execute(Expression expression) => inner.Execute(expression); public TResult Execute(Expression expression) => inner.Execute(expression); public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) => (TResult)typeof(Task) .GetMethod(nameof(Task.FromResult))! .MakeGenericMethod(typeof(TResult).GetGenericArguments()[0]) .Invoke(null, [Execute(expression)])!; } private sealed class TestAsyncEnumerable : EnumerableQuery, IAsyncEnumerable, IQueryable { public TestAsyncEnumerable(IEnumerable enumerable) : base(enumerable) { } public TestAsyncEnumerable(Expression expression) : base(expression) { } public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => new TestAsyncEnumerator(this.AsEnumerable().GetEnumerator()); IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider(this); } private sealed class TestAsyncEnumerator(IEnumerator inner) : IAsyncEnumerator { public T Current => inner.Current; public ValueTask DisposeAsync() { inner.Dispose(); return ValueTask.CompletedTask; } public ValueTask MoveNextAsync() => ValueTask.FromResult(inner.MoveNext()); } }