mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
171 lines
6.9 KiB
C#
171 lines
6.9 KiB
C#
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"))
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|