diff --git a/SecurityService.UnitTests/Pages/LoginPageModelTests.cs b/SecurityService.UnitTests/Pages/LoginPageModelTests.cs index 371e48c..fc882dc 100644 --- a/SecurityService.UnitTests/Pages/LoginPageModelTests.cs +++ b/SecurityService.UnitTests/Pages/LoginPageModelTests.cs @@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Imposter.Abstractions; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SecurityService.BusinessLogic.Mfa; using SecurityService.BusinessLogic.Requests; using SecurityService.Models; using SecurityService.UnitTests.Infrastructure; @@ -13,13 +15,23 @@ namespace SecurityService.UnitTests.Pages; public class LoginPageModelTests { [Fact] - public async Task OnPost_WhenForgotPasswordSelected_RedirectsToForgotPasswordWithReturnUrlAndClientId() + public async Task OnPost_WhenCancelSelected_RedirectsToHome() { - var mediator = new IMediatorImposter(); - mediator.Send(Arg>>>.Any(), Arg.Any()) - .ReturnsAsync(Result.Success(new List())); + var mediator = CreateMediator(); + var model = CreateModel(mediator); + model.Input.Button = "cancel"; + + var result = await model.OnPostAsync(); - var model = CreateModel(mediator, new DefaultHttpContext()); + result.ShouldBeOfType().Url.ShouldBe("/"); + AssertLoginWasNotSent(mediator); + } + + [Fact] + public async Task OnPost_WhenForgotPasswordSelected_RedirectsWithReturnUrlAndClientId() + { + var mediator = CreateMediator(); + var model = CreateModel(mediator); model.Input = new SecurityService.Pages.Account.Login.IndexModel.InputModel { Button = "forgotpassword", @@ -31,20 +43,124 @@ public async Task OnPost_WhenForgotPasswordSelected_RedirectsToForgotPasswordWit var redirect = result.ShouldBeOfType(); redirect.PageName.ShouldBe("/Account/ForgotPassword/Index"); - redirect.RouteValues.ShouldNotBeNull(); - redirect.RouteValues["returnUrl"].ShouldBe("/connect/authorize?client_id=test-client-id"); + redirect.RouteValues!["returnUrl"].ShouldBe("/connect/authorize?client_id=test-client-id"); redirect.RouteValues["clientId"].ShouldBe("test-client-id"); - mediator.Send(Arg.Any(), Arg.Any()).Called(Count.Never()); + AssertLoginWasNotSent(mediator); + } + + [Fact] + public async Task OnPost_WhenLoginRejected_RedisplaysPageWithRejectionMessage() + { + var mediator = CreateMediator(Result.Success(new LoginRejected("The account is locked."))); + var model = CreateModel(mediator); + + var result = await model.OnPostAsync(); + + result.ShouldBeOfType(); + model.ModelState[string.Empty]!.Errors.Single().ErrorMessage.ShouldBe("The account is locked."); + } + + [Fact] + public async Task OnPost_WhenLoginFailsWithoutRejection_UsesResultErrorMessage() + { + var mediator = CreateMediator(Result.Failure("Login service unavailable.")); + var model = CreateModel(mediator); + + var result = await model.OnPostAsync(); + + result.ShouldBeOfType(); + model.ModelState[string.Empty]!.Errors.Single().ErrorMessage.ShouldBe("Login service unavailable."); + } + + [Fact] + public async Task OnPost_WhenLoginCompletes_RedirectsToReturnUrl() + { + var mediator = CreateMediator(Result.Success(new LoginCompleted("/connect/authorize"))); + var model = CreateModel(mediator); + + var result = await model.OnPostAsync(); + + result.ShouldBeOfType().Url.ShouldBe("/connect/authorize"); + } + + [Fact] + public async Task OnPost_WhenCompletedReturnUrlIsEmpty_RedirectsToHome() + { + var mediator = CreateMediator(Result.Success(new LoginCompleted(" "))); + var model = CreateModel(mediator); + + var result = await model.OnPostAsync(); + + result.ShouldBeOfType().Url.ShouldBe("/"); + } + + [Fact] + public async Task OnPost_WhenMfaIsRequired_RedirectsToChallengeWithTransaction() + { + var mediator = CreateMediator(Result.Success(new LoginRequiresMfa("protected-transaction"))); + var model = CreateModel(mediator); + + var result = await model.OnPostAsync(); + + var redirect = result.ShouldBeOfType(); + redirect.PageName.ShouldBe("/Account/LoginWithMfa/Index"); + redirect.RouteValues!["transaction"].ShouldBe("protected-transaction"); + } + + [Fact] + public async Task OnPost_WhenMfaEnrollmentIsRequired_RedirectsToEnrollmentWithTransaction() + { + var mediator = CreateMediator(Result.Success(new LoginRequiresMfaEnrollment("enrollment-transaction"))); + var model = CreateModel(mediator); + + var result = await model.OnPostAsync(); + + var redirect = result.ShouldBeOfType(); + redirect.PageName.ShouldBe("/Account/EnrollMfa/Index"); + redirect.RouteValues!["transaction"].ShouldBe("enrollment-transaction"); + } + + [Fact] + public async Task OnPost_WhenLoginOutcomeIsUnsupported_RedisplaysPageWithError() + { + var mediator = CreateMediator(Result.Success(new UnsupportedLoginOutcome())); + var model = CreateModel(mediator); + + var result = await model.OnPostAsync(); + + result.ShouldBeOfType(); + model.ModelState[string.Empty]!.Errors.Single().ErrorMessage.ShouldBe("Unsupported Login Success Type"); + } + + private static IMediatorImposter CreateMediator(Result? loginResult = null) + { + var mediator = new IMediatorImposter(); + mediator.Send(Arg>>>.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(new List())); + if (loginResult is not null) + { + mediator.Send(Arg>>.Any(), Arg.Any()) + .ReturnsAsync(loginResult); + } + + return mediator; } - private static SecurityService.Pages.Account.Login.IndexModel CreateModel(IMediatorImposter mediator, HttpContext httpContext) + private static SecurityService.Pages.Account.Login.IndexModel CreateModel(IMediatorImposter mediator) { return new SecurityService.Pages.Account.Login.IndexModel(mediator.Instance()) { PageContext = new Microsoft.AspNetCore.Mvc.RazorPages.PageContext { - HttpContext = httpContext + HttpContext = new DefaultHttpContext() } }; } + + private static void AssertLoginWasNotSent(IMediatorImposter mediator) + { + mediator.Send(Arg>>.Any(), Arg.Any()).Called(Count.Never()); + } + + private sealed record UnsupportedLoginOutcome : LoginOutcome; } diff --git a/SecurityService/Pages/Account/Login/Index.cshtml.cs b/SecurityService/Pages/Account/Login/Index.cshtml.cs index e391914..5a3b72c 100644 --- a/SecurityService/Pages/Account/Login/Index.cshtml.cs +++ b/SecurityService/Pages/Account/Login/Index.cshtml.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.WebUtilities; using SecurityService.BusinessLogic.Mfa; using SecurityService.BusinessLogic.Requests; +using SimpleResults; namespace SecurityService.Pages.Account.Login; @@ -38,39 +39,37 @@ public async Task OnPostAsync() { await this.LoadProvidersAsync(); - if (this.Input.Button == "cancel") - { + if (this.Input.Button == "cancel") { return this.LocalRedirect("/"); } // the user clicked the "cancel" button - if (Input.Button == "forgotpassword") - { - return this.RedirectToPage("/Account/ForgotPassword/Index", new - { - returnUrl = this.Input.ReturnUrl, - clientId = this.Input.ClientId - }); + if (Input.Button == "forgotpassword") { + return this.RedirectToPage("/Account/ForgotPassword/Index", new { returnUrl = this.Input.ReturnUrl, clientId = this.Input.ClientId }); } - var result = await this._mediator.Send(new SecurityServiceCommands.BeginLoginCommand(this.Input.Username, this.Input.Password, this.Input.RememberLogin, this.Input.ReturnUrl)); - if (result.IsSuccess && result.Data is LoginCompleted completed) - { - return this.LocalRedirect(string.IsNullOrWhiteSpace(completed.ReturnUrl) ? "/" : completed.ReturnUrl); - } + Result result = await this._mediator.Send(new SecurityServiceCommands.BeginLoginCommand(this.Input.Username, this.Input.Password, this.Input.RememberLogin, this.Input.ReturnUrl)); - if (result.IsSuccess && result.Data is LoginRequiresMfa requiresMfa) - { - return this.RedirectToPage("/Account/LoginWithMfa/Index", new { transaction = requiresMfa.Transaction }); + if (result.Data is LoginRejected rejection) { + this.ModelState.AddModelError(string.Empty, rejection.Message); + return this.Page(); } - if (result.IsSuccess && result.Data is LoginRequiresMfaEnrollment requiresMfaEnrollment) - { - return this.RedirectToPage("/Account/EnrollMfa/Index", new { transaction = requiresMfaEnrollment.Transaction }); + if (result.IsFailed) { + this.ModelState.AddModelError(string.Empty, result.Errors.FirstOrDefault() ?? result.Message ?? "Invalid username or password."); + return this.Page(); } - var rejection = result.Data as LoginRejected; - this.ModelState.AddModelError(string.Empty, rejection?.Message ?? result.Errors.FirstOrDefault() ?? result.Message ?? "Invalid username or password."); + return result.Data switch { + LoginCompleted completed => this.LocalRedirect(string.IsNullOrWhiteSpace(completed.ReturnUrl) ? "/" : completed.ReturnUrl), + LoginRequiresMfa requiresMfa => this.RedirectToPage("/Account/LoginWithMfa/Index", new { transaction = requiresMfa.Transaction }), + LoginRequiresMfaEnrollment requiresMfaEnrollment => this.RedirectToPage("/Account/EnrollMfa/Index", new { transaction = requiresMfaEnrollment.Transaction }), + _ => this.Error("Unsupported Login Success Type") + }; + } + + private PageResult Error(String message) { + this.ModelState.AddModelError(string.Empty, message); return this.Page(); }