mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
Forgot Password answers every address the same way and emails a code only to an active account. Codes are stored as salted SHA-256 hashes, expire 15 minutes after issue, are replaced by a newer request, and are checked only against the email they were issued to. Five failed checks delete the code; attempts are reserved with one conditional UPDATE so concurrent guesses cannot exceed the budget. VerificationCode requires the email, and email and code are accepted in the JSON body so they stay out of URLs. The three anonymous endpoints are rate limited to 10 requests per 15 minutes per client IP. Forwarded headers are trusted only through loopback and private hops, since the API sits behind the EB load balancer and nginx. The migration adds hash, salt, expiry and attempt columns and deletes the old plaintext rows.
298 lines
13 KiB
C#
298 lines
13 KiB
C#
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<IAuthenticationService> service, string? userId = null)
|
|
{
|
|
var controller = new AuthenticationController(service.Object, Mock.Of<ILogger<AuthenticationController>>());
|
|
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<IAuthenticationService>();
|
|
service.Setup(s => s.LoginAsync("alice", "pw", It.IsAny<CancellationToken>()))
|
|
.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<OkObjectResult>().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<IAuthenticationService>();
|
|
service.Setup(s => s.LoginAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((LoginResultDTO?)null);
|
|
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.Login(new LoginModel(), CancellationToken.None);
|
|
|
|
result.Should().BeOfType<UnauthorizedResult>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Login_WhenServiceThrows_ReturnsSanitized500AndLogsInternally()
|
|
{
|
|
var service = new Mock<IAuthenticationService>();
|
|
service.Setup(s => s.LoginAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("SECRET-internal-stack-detail"));
|
|
|
|
var logger = new Mock<ILogger<AuthenticationController>>();
|
|
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).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<ObjectResult>().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<EventId>(),
|
|
It.IsAny<It.IsAnyType>(),
|
|
It.Is<Exception>(ex => ex.Message.Contains("SECRET")),
|
|
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
|
Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChangePassword_Success_ReturnsSuccessMessageWithTrailingSpace()
|
|
{
|
|
var service = new Mock<IAuthenticationService>();
|
|
service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(true);
|
|
|
|
var controller = NewController(service, "42");
|
|
|
|
var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None);
|
|
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
var response = ok.Value.Should().BeOfType<Response>().Subject;
|
|
response.Status.Should().Be("Success ");
|
|
response.Message.Should().Be("Password successfully changed");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus()
|
|
{
|
|
var service = new Mock<IAuthenticationService>();
|
|
service.Setup(s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(false);
|
|
|
|
var controller = NewController(service, "42");
|
|
|
|
var result = await controller.ChangePassword(new ChangePasswords(), CancellationToken.None);
|
|
|
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
|
response.Status.Should().Be("Old Password is incorrect");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateProfile_MapsProfileDataIntoDataResponse()
|
|
{
|
|
var service = new Mock<IAuthenticationService>();
|
|
service.Setup(s => s.UpdateProfileAsync("42", It.IsAny<UpdateProfileRequestDTO>(), It.IsAny<CancellationToken>()))
|
|
.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<OkObjectResult>().Subject;
|
|
var response = ok.Value.Should().BeOfType<DataResponse>().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<IAuthenticationService>();
|
|
var controller = NewController(service);
|
|
|
|
var registered = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None);
|
|
var unregistered = await controller.ForgetPassword(null, "x@y.com", CancellationToken.None);
|
|
|
|
var ok = registered.Should().BeOfType<OkObjectResult>().Subject;
|
|
var response = ok.Value.Should().BeOfType<Response>().Subject;
|
|
response.Status.Should().Be("Success ");
|
|
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
|
|
Json(unregistered.Should().BeOfType<OkObjectResult>().Subject.Value).Should().Be(Json(ok.Value));
|
|
service.Verify(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>()), Times.Once);
|
|
service.Verify(s => s.ForgetPasswordAsync("x@y.com", It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ForgetPassword_WhenServiceThrows_StillAnswersTheSameSuccessAndLogsNoDetail()
|
|
{
|
|
var service = new Mock<IAuthenticationService>();
|
|
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("SECRET a@b.com"));
|
|
var logger = new Mock<ILogger<AuthenticationController>>();
|
|
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
|
|
var controller = new AuthenticationController(service.Object, logger.Object);
|
|
|
|
var result = await controller.ForgetPassword(new ForgetPasswordRequest_Dto { Email = "a@b.com" }, null, CancellationToken.None);
|
|
|
|
var response = result.Should().BeOfType<OkObjectResult>().Subject.Value.Should().BeOfType<Response>().Subject;
|
|
response.Message.Should().Be(AuthenticationController.ForgetPasswordMessage);
|
|
logger.Verify(
|
|
x => x.Log(
|
|
LogLevel.Error,
|
|
It.IsAny<EventId>(),
|
|
It.Is<It.IsAnyType>((state, _) => !state.ToString()!.Contains("a@b.com") && !state.ToString()!.Contains("SECRET")),
|
|
null,
|
|
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
|
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<IAuthenticationService>();
|
|
service.Setup(s => s.VerifyCodeAsync("a@b.com", "123456", It.IsAny<CancellationToken>())).ReturnsAsync(matched);
|
|
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.VerificationCode(new VerificationCode_Dto { Email = "a@b.com", Code = "123456" }, null, null, CancellationToken.None);
|
|
|
|
if (matched)
|
|
{
|
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
|
var r = ok.Value.Should().BeOfType<Response>().Subject;
|
|
r.Status.Should().Be(expectedStatus);
|
|
r.Message.Should().Be(expectedMessage);
|
|
}
|
|
else
|
|
{
|
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
|
var r = bad.Value.Should().BeOfType<Response>().Subject;
|
|
r.Status.Should().Be(expectedStatus);
|
|
r.Message.Should().Be(expectedMessage);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerificationCode_QueryOnlyCode_PassesNoEmailToTheService()
|
|
{
|
|
var service = new Mock<IAuthenticationService>();
|
|
var controller = NewController(service);
|
|
|
|
var result = await controller.VerificationCode(null, null, "123456", CancellationToken.None);
|
|
|
|
result.Should().BeOfType<BadRequestObjectResult>().Subject.Value.Should().BeOfType<Response>()
|
|
.Which.Message.Should().Be("Code Not Matched");
|
|
service.Verify(s => s.VerifyCodeAsync(null, "123456", It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerificationCode_WhenServiceThrows_AnswersTheGenericWrongCodeError()
|
|
{
|
|
var service = new Mock<IAuthenticationService>();
|
|
service.Setup(s => s.VerifyCodeAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
|
.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" }, null, null, CancellationToken.None);
|
|
|
|
Json(result.Should().BeOfType<BadRequestObjectResult>().Subject.Value)
|
|
.Should().Be(Json(new Response { Status = "Error", Message = "Code Not Matched" }));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage()
|
|
{
|
|
var service = new Mock<IAuthenticationService>();
|
|
service.Setup(s => s.ResetPasswordAsync("a@b.com", "123", "new", It.IsAny<CancellationToken>())).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<OkObjectResult>().Subject;
|
|
var response = ok.Value.Should().BeOfType<Response>().Subject;
|
|
response.Status.Should().Be("Success ");
|
|
response.Message.Should().Be("password changed");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ResetPassword_NotMatched_ReturnsEmailOrCodeNotFoundMessage()
|
|
{
|
|
var service = new Mock<IAuthenticationService>();
|
|
service.Setup(s => s.ResetPasswordAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
|
.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<BadRequestObjectResult>().Subject;
|
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
|
response.Message.Should().Be("Your email or code not found please check");
|
|
}
|
|
}
|