174 lines
7.5 KiB
C#
174 lines
7.5 KiB
C#
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<ConflictObjectResult>(result);
|
|
Assert.Equal("Last administrator protected", Assert.IsType<ProblemDetails>(conflict.Value).Title);
|
|
users.Verify(x => x.RemoveFromRolesAsync(It.IsAny<ApplicationUser>(), It.IsAny<IEnumerable<string>>()), 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<IEnumerable<string>>(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<NoContentResult>(result);
|
|
users.Verify(x => x.RemoveFromRolesAsync(admin, It.IsAny<IEnumerable<string>>()), 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<ConflictObjectResult>(result);
|
|
users.Verify(x => x.DeleteAsync(It.IsAny<ApplicationUser>()), 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<ObjectResult>(await controller.Delete(member.Id, CancellationToken.None));
|
|
|
|
Assert.Equal(StatusCodes.Status503ServiceUnavailable, result.StatusCode);
|
|
users.Verify(x => x.DeleteAsync(It.IsAny<ApplicationUser>()), 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<ApplicationUser>([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<List<UsersController.UserDto>>(Assert.IsType<OkObjectResult>(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<UserManager<ApplicationUser>> users, string currentUserId)
|
|
{
|
|
var roleStore = new Mock<IRoleStore<IdentityRole>>();
|
|
var roles = new Mock<RoleManager<IdentityRole>>(
|
|
roleStore.Object,
|
|
Array.Empty<IRoleValidator<IdentityRole>>(),
|
|
new UpperInvariantLookupNormalizer(),
|
|
new IdentityErrorDescriber(),
|
|
new NullLogger<RoleManager<IdentityRole>>());
|
|
|
|
var controller = new UsersController(
|
|
users.Object,
|
|
roles.Object,
|
|
Mock.Of<IAppEmailSender>(),
|
|
new ConfigurationBuilder().Build(),
|
|
new NullLogger<UsersController>(),
|
|
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<TEntity>(IQueryProvider inner) : IAsyncQueryProvider
|
|
{
|
|
public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable<TEntity>(expression);
|
|
public IQueryable<TElement> CreateQuery<TElement>(Expression expression) => new TestAsyncEnumerable<TElement>(expression);
|
|
public object? Execute(Expression expression) => inner.Execute(expression);
|
|
public TResult Execute<TResult>(Expression expression) => inner.Execute<TResult>(expression);
|
|
public TResult ExecuteAsync<TResult>(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<T> : EnumerableQuery<T>, IAsyncEnumerable<T>, IQueryable<T>
|
|
{
|
|
public TestAsyncEnumerable(IEnumerable<T> enumerable) : base(enumerable) { }
|
|
public TestAsyncEnumerable(Expression expression) : base(expression) { }
|
|
|
|
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
|
|
=> new TestAsyncEnumerator<T>(this.AsEnumerable().GetEnumerator());
|
|
|
|
IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider<T>(this);
|
|
}
|
|
|
|
private sealed class TestAsyncEnumerator<T>(IEnumerator<T> inner) : IAsyncEnumerator<T>
|
|
{
|
|
public T Current => inner.Current;
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
inner.Dispose();
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public ValueTask<bool> MoveNextAsync() => ValueTask.FromResult(inner.MoveNext());
|
|
}
|
|
}
|