using System.Security.Claims; using System.Linq.Expressions; 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 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); } 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) { 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)); 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 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()); } }