shoc-backend/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs

172 lines
6.9 KiB
C#
Raw Normal View History

using System.Reflection;
using System.Text.Json;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Filters;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging.Abstractions;
using FluentAssertions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class TeamMemberInviteControllerTests
{
[Fact]
public void RegistrationRequests_CarryNoUserIdentifier()
{
var requestTypes = new[]
{
typeof(TeamMemberInviteTokenRequestDTO),
typeof(VerifyTeamMemberInviteCodeRequestDTO),
typeof(CompleteTeamMemberRegistrationRequestDTO)
};
var properties = requestTypes
.SelectMany(type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
.Select(property => property.Name)
.Distinct()
.OrderBy(name => name);
properties.Should().Equal("Code", "Password", "Phone", "Token");
}
[Fact]
public void RegistrationEndpoints_AreAnonymousAndNeverCached()
{
var type = typeof(TeamMemberInviteController);
type.GetCustomAttribute<AllowAnonymousAttribute>().Should().NotBeNull();
var cache = type.GetCustomAttribute<ResponseCacheAttribute>();
cache.Should().NotBeNull();
cache!.NoStore.Should().BeTrue();
}
[Fact]
public void RegistrationEndpoints_UseTheControllerScopedExceptionFilter()
{
var filter = typeof(TeamMemberInviteController).GetCustomAttribute<TypeFilterAttribute>();
filter.Should().NotBeNull();
filter!.ImplementationType.Should().Be(typeof(InviteRegistrationExceptionFilter));
}
[Fact]
public void ExceptionFilter_ReturnsAFixedBodyWithoutExceptionDetail()
{
var context = ExceptionContextFor(new ArgumentException(
"SELECT [Id] FROM [TeamMemberInvites] WHERE [TokenHash] = 'leak-me'"));
new InviteRegistrationExceptionFilter(NullLogger<InviteRegistrationExceptionFilter>.Instance)
.OnException(context);
context.ExceptionHandled.Should().BeTrue();
var result = context.Result.Should().BeOfType<ObjectResult>().Subject;
result.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
var body = JsonSerializer.Serialize(result.Value);
body.Should().Be(
"{\"code\":\"server_error\",\"message\":\"Something went wrong. Try again in a minute.\",\"retryAfterSeconds\":null}");
body.Should().NotContain("SELECT").And.NotContain("leak-me");
}
[Fact]
public void ExceptionFilter_LeavesCancellationToTheHost()
{
var context = ExceptionContextFor(new OperationCanceledException());
new InviteRegistrationExceptionFilter(NullLogger<InviteRegistrationExceptionFilter>.Instance)
.OnException(context);
context.ExceptionHandled.Should().BeFalse();
context.Result.Should().BeNull();
}
private static ExceptionContext ExceptionContextFor(Exception exception)
{
var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor());
return new ExceptionContext(actionContext, new List<IFilterMetadata>()) { Exception = exception };
}
[Theory]
[InlineData(TeamMemberRegistrationStatus.InvalidInvite)]
[InlineData(TeamMemberRegistrationStatus.Ok)]
public async Task Complete_WithoutASession_ReturnsTheGenericInviteError(TeamMemberRegistrationStatus status)
{
var service = new Mock<ITeamMemberRegistrationService>();
service.Setup(x => x.CompleteAsync(It.IsAny<CompleteTeamMemberRegistrationRequestDTO>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberRegistrationOutcomeDTO { Status = status });
var controller = new TeamMemberInviteController(service.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Complete(new CompleteTeamMemberRegistrationRequestDTO(), CancellationToken.None);
var failure = result.Should().BeOfType<ObjectResult>().Subject;
failure.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
failure.Value.Should().BeEquivalentTo(new
{
code = "invalid_invite",
message = TeamMemberInviteController.InvalidInviteMessage
});
}
[Fact]
public void ResendInvite_RequiresAuthenticatedCaller()
{
typeof(TeamMemberController).GetCustomAttribute<AuthorizeAttribute>().Should().NotBeNull();
typeof(TeamMemberController).GetMethod(nameof(TeamMemberController.ResendInvite))!
.GetCustomAttribute<AllowAnonymousAttribute>().Should().BeNull();
}
[Fact]
public async Task ResendInvite_Success_ReturnsInviteSent()
{
var invites = new Mock<ITeamMemberInviteService>();
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = true });
var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None);
result.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeEquivalentTo(new { message = "Invite sent" });
}
[Theory]
[InlineData("Forbidden", typeof(ForbidResult))]
[InlineData("Team member not found.", typeof(NotFoundObjectResult))]
[InlineData("Only pending team members can be re-invited.", typeof(BadRequestObjectResult))]
[InlineData("The invite could not be emailed. Try again.", typeof(BadRequestObjectResult))]
public async Task ResendInvite_Failure_MapsToHttpResult(string error, Type expected)
{
var invites = new Mock<ITeamMemberInviteService>();
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = false, Error = error });
var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None);
result.Should().BeOfType(expected);
}
private static TeamMemberController NewTeamMemberController(Mock<ITeamMemberInviteService> invites)
{
return new TeamMemberController(Mock.Of<ITeamMemberService>(), Mock.Of<ITeamPermissionService>(), invites.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test"))
}
}
};
}
}