using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using System.Security.Claims; using System.Text.Json; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class AuthenticationControllerTests { private static string Json(object? value) => JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object)); private static AuthenticationController NewController(Mock service, string? userId = null) { var controller = new AuthenticationController(service.Object, Mock.Of>()); if (userId != null) { controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "Test")) } }; } return controller; } [Fact] public async Task Login_WithSuccessfulResult_MapsLowercaseTokenPayload() { var service = new Mock(); service.Setup(s => s.LoginAsync("alice", "pw", It.IsAny())) .ReturnsAsync(new LoginResultDTO { Token = "tok", Expiration = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), Email = "alice@example.com", UserRole = "Admin", PhoneNumber = "555", Fullname = "Alice Q", Id = "user-1" }); var controller = NewController(service); var result = await controller.Login(new LoginModel { Username = "alice", Password = "pw" }, CancellationToken.None); var ok = result.Should().BeOfType().Subject; var json = Json(ok.Value); json.Should().Contain("\"token\":\"tok\""); json.Should().Contain("\"expiration\":\""); json.Should().Contain("\"email\":\"alice@example.com\""); json.Should().Contain("\"userRoles\":\"Admin\""); json.Should().Contain("\"phoneNumber\":\"555\""); json.Should().Contain("\"fullname\":\"Alice Q\""); json.Should().Contain("\"id\":\"user-1\""); } [Fact] public async Task Login_WithNullResult_ReturnsUnauthorized() { var service = new Mock(); service.Setup(s => s.LoginAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((LoginResultDTO?)null); var controller = NewController(service); var result = await controller.Login(new LoginModel(), CancellationToken.None); result.Should().BeOfType(); } [Fact] public async Task Login_WhenServiceThrows_ReturnsSanitized500AndLogsInternally() { var service = new Mock(); service.Setup(s => s.LoginAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("SECRET-internal-stack-detail")); var logger = new Mock>(); logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); var controller = new AuthenticationController(service.Object, logger.Object); var result = await controller.Login(new LoginModel { Username = "x", Password = "y" }, CancellationToken.None); var status = result.Should().BeOfType().Subject; status.StatusCode.Should().Be(500); var message = (string)status.Value!; message.Should().NotContain("SECRET"); message.Should().NotContain("internal-stack-detail"); message.Should().Contain("reference"); logger.Verify( x => x.Log( LogLevel.Error, It.IsAny(), It.IsAny(), It.Is(ex => ex.Message.Contains("SECRET")), It.IsAny>()), Times.Once); } [Fact] public async Task ChangePassword_Success_ReturnsSuccessMessageWithTrailingSpace() { var service = new Mock(); service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny())) .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Succeeded }); var controller = NewController(service, "42"); var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Newpassword = "new", Confirmpassword = "new" }, CancellationToken.None); var ok = result.Should().BeOfType().Subject; var response = ok.Value.Should().BeOfType().Subject; response.Status.Should().Be("Success "); response.Message.Should().Be("Password successfully changed"); } [Fact] public async Task ChangePassword_WrongCurrentPassword_ReturnsOldPasswordIncorrectStatus() { var service = new Mock(); service.Setup(s => s.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.CurrentPasswordIncorrect }); var controller = NewController(service, "42"); var result = await controller.ChangePassword(new ChangePasswords(), CancellationToken.None); var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; response.Status.Should().Be("Old Password is incorrect"); response.Message.Should().Be("Current password is incorrect"); } [Fact] public async Task ChangePassword_PolicyRejection_ReturnsPasswordRequirementsMessage() { var service = new Mock(); service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "weak", It.IsAny())) .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.PasswordRejected }); var controller = NewController(service, "42"); var result = await controller.ChangePassword( new ChangePasswords { Currentpassword = "Current1!", Newpassword = "weak", Confirmpassword = "weak" }, CancellationToken.None); var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; response.Status.Should().Be("Password does not meet requirements"); response.Message.Should().Be( "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."); } [Fact] public async Task ChangePassword_ConfirmationMismatch_IsRejectedWithoutChangingThePassword() { var service = new Mock(); var controller = NewController(service, "42"); var result = await controller.ChangePassword( new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@y" }, CancellationToken.None); var bad = result.Should().BeOfType().Subject; bad.Value.Should().BeOfType().Subject.Message.Should().Be("Passwords don't match"); service.Verify( s => s.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task ChangePassword_NonPolicyFailure_ReturnsTheGenericMessage() { var service = new Mock(); service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "Next2@x", It.IsAny())) .ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Failed }); var controller = NewController(service, "42"); var result = await controller.ChangePassword( new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@x" }, CancellationToken.None); var response = result.Should().BeOfType().Subject.Value.Should().BeOfType().Subject; response.Message.Should().Be("Your password could not be changed. Try again."); response.Message.Should().NotContain("at least 6 characters"); } [Fact] public void ChangePassword_RequiresAuthenticatedCaller() { var method = typeof(AuthenticationController).GetMethod(nameof(AuthenticationController.ChangePassword))!; method.GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: true) .Should().NotBeEmpty(); } [Fact] public async Task UpdateProfile_MapsProfileDataIntoDataResponse() { var service = new Mock(); service.Setup(s => s.UpdateProfileAsync("42", It.IsAny(), It.IsAny())) .ReturnsAsync(new UserProfileDTO { FirstName = "Bob", Email = "bob@x.com", Contact = "111" }); var controller = NewController(service, "42"); var form = new User_DTO { Name = "Bob", Email = "bob@x.com", Contact = "111" }; var result = await controller.UserProfileUpdate(form, CancellationToken.None); var ok = result.Should().BeOfType().Subject; var response = ok.Value.Should().BeOfType().Subject; response.Status.Should().Be("200"); response.Message.Should().Be("Introduction Updated Successfully"); var json = Json((object?)response.Data); json.Should().Contain("\"FirstName\":\"Bob\""); json.Should().Contain("\"Email\":\"bob@x.com\""); json.Should().Contain("\"Contact\":\"111\""); } [Fact] public async Task ForgetPassword_ReturnsTheSameSuccessWhetherOrNotTheEmailIsRegistered() { var service = new Mock(); var controller = NewController(service); var registered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, CancellationToken.None); var unregistered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "x@y.com" }, CancellationToken.None); var ok = registered.Should().BeOfType().Subject; var response = ok.Value.Should().BeOfType().Subject; response.Status.Should().Be("Success "); response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage); Json(unregistered.Should().BeOfType().Subject.Value).Should().Be(Json(ok.Value)); service.Verify(s => s.ForgetPasswordAsync("a@b.com", It.IsAny()), Times.Once); service.Verify(s => s.ForgetPasswordAsync("x@y.com", It.IsAny()), Times.Once); } [Fact] public async Task ForgetPassword_WhenServiceThrows_StillAnswersTheSameSuccessAndLogsNoDetail() { var service = new Mock(); service.Setup(s => s.ForgetPasswordAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("SECRET a@b.com")); var logger = new Mock>(); logger.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); var controller = new AuthenticationController(service.Object, logger.Object); var result = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, CancellationToken.None); var response = result.Should().BeOfType().Subject.Value.Should().BeOfType().Subject; response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage); logger.Verify( x => x.Log( LogLevel.Error, It.IsAny(), It.Is((state, _) => !state.ToString()!.Contains("a@b.com") && !state.ToString()!.Contains("SECRET")), null, It.IsAny>()), Times.Once); } [Theory] [InlineData(true, "Success ", "Code Matched")] [InlineData(false, "Error", "Code Not Matched")] public async Task VerificationCode_MapsServiceResult(bool matched, string expectedStatus, string expectedMessage) { var service = new Mock(); service.Setup(s => s.VerifyCodeAsync("a@b.com", "123456", It.IsAny())).ReturnsAsync(matched); var controller = NewController(service); var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "123456" }, CancellationToken.None); if (matched) { var ok = result.Should().BeOfType().Subject; var r = ok.Value.Should().BeOfType().Subject; r.Status.Should().Be(expectedStatus); r.Message.Should().Be(expectedMessage); } else { var bad = result.Should().BeOfType().Subject; var r = bad.Value.Should().BeOfType().Subject; r.Status.Should().Be(expectedStatus); r.Message.Should().Be(expectedMessage); } } [Fact] public async Task VerificationCode_WithoutABody_PassesNoEmailOrCodeToTheService() { var service = new Mock(); var controller = NewController(service); var result = await controller.VerificationCode(null, CancellationToken.None); result.Should().BeOfType().Subject.Value.Should().BeOfType() .Which.Message.Should().Be("Code Not Matched"); service.Verify(s => s.VerifyCodeAsync(null, null, It.IsAny()), Times.Once); } [Fact] public async Task VerificationCode_WhenServiceThrows_AnswersTheGenericWrongCodeError() { var service = new Mock(); service.Setup(s => s.VerifyCodeAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("SECRET-internal-stack-detail")); var controller = NewController(service); var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "1" }, CancellationToken.None); Json(result.Should().BeOfType().Subject.Value) .Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" })); } [Fact] public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage() { var service = new Mock(); service.Setup(s => s.ResetPasswordAsync("a@b.com", "123", "new", It.IsAny())).ReturnsAsync(true); var controller = NewController(service); var result = await controller.ResetPassword(new ForgetPassword_Dto { Email = "a@b.com", Code = "123", Password = "new" }, CancellationToken.None); var ok = result.Should().BeOfType().Subject; var response = ok.Value.Should().BeOfType().Subject; response.Status.Should().Be("Success "); response.Message.Should().Be("password changed"); } [Fact] public async Task ResetPassword_NotMatched_ReturnsEmailOrCodeNotFoundMessage() { var service = new Mock(); service.Setup(s => s.ResetPasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(false); var controller = NewController(service); var result = await controller.ResetPassword(new ForgetPassword_Dto { Email = "a@b.com" }, CancellationToken.None); var bad = result.Should().BeOfType().Subject; var response = bad.Value.Should().BeOfType().Subject; response.Message.Should().Be("Your email or code not found please check"); } }