Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 126 additions & 10 deletions SecurityService.UnitTests/Pages/LoginPageModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<IRequest<Result<List<ExternalProviderDetails>>>>.Any(), Arg<CancellationToken>.Any())
.ReturnsAsync(Result.Success(new List<ExternalProviderDetails>()));
var mediator = CreateMediator();
var model = CreateModel(mediator);
model.Input.Button = "cancel";

var result = await model.OnPostAsync();

var model = CreateModel(mediator, new DefaultHttpContext());
result.ShouldBeOfType<LocalRedirectResult>().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",
Expand All @@ -31,20 +43,124 @@ public async Task OnPost_WhenForgotPasswordSelected_RedirectsToForgotPasswordWit

var redirect = result.ShouldBeOfType<RedirectToPageResult>();
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<SecurityServiceCommands.LoginCommand>.Any(), Arg<CancellationToken>.Any()).Called(Count.Never());
AssertLoginWasNotSent(mediator);
}

[Fact]
public async Task OnPost_WhenLoginRejected_RedisplaysPageWithRejectionMessage()
{
var mediator = CreateMediator(Result.Success<LoginOutcome>(new LoginRejected("The account is locked.")));
var model = CreateModel(mediator);

var result = await model.OnPostAsync();

result.ShouldBeOfType<PageResult>();
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<PageResult>();
model.ModelState[string.Empty]!.Errors.Single().ErrorMessage.ShouldBe("Login service unavailable.");
}

[Fact]
public async Task OnPost_WhenLoginCompletes_RedirectsToReturnUrl()
{
var mediator = CreateMediator(Result.Success<LoginOutcome>(new LoginCompleted("/connect/authorize")));
var model = CreateModel(mediator);

var result = await model.OnPostAsync();

result.ShouldBeOfType<LocalRedirectResult>().Url.ShouldBe("/connect/authorize");
}

[Fact]
public async Task OnPost_WhenCompletedReturnUrlIsEmpty_RedirectsToHome()
{
var mediator = CreateMediator(Result.Success<LoginOutcome>(new LoginCompleted(" ")));
var model = CreateModel(mediator);

var result = await model.OnPostAsync();

result.ShouldBeOfType<LocalRedirectResult>().Url.ShouldBe("/");
}

[Fact]
public async Task OnPost_WhenMfaIsRequired_RedirectsToChallengeWithTransaction()
{
var mediator = CreateMediator(Result.Success<LoginOutcome>(new LoginRequiresMfa("protected-transaction")));
var model = CreateModel(mediator);

var result = await model.OnPostAsync();

var redirect = result.ShouldBeOfType<RedirectToPageResult>();
redirect.PageName.ShouldBe("/Account/LoginWithMfa/Index");
redirect.RouteValues!["transaction"].ShouldBe("protected-transaction");
}

[Fact]
public async Task OnPost_WhenMfaEnrollmentIsRequired_RedirectsToEnrollmentWithTransaction()
{
var mediator = CreateMediator(Result.Success<LoginOutcome>(new LoginRequiresMfaEnrollment("enrollment-transaction")));
var model = CreateModel(mediator);

var result = await model.OnPostAsync();

var redirect = result.ShouldBeOfType<RedirectToPageResult>();
redirect.PageName.ShouldBe("/Account/EnrollMfa/Index");
redirect.RouteValues!["transaction"].ShouldBe("enrollment-transaction");
}

[Fact]
public async Task OnPost_WhenLoginOutcomeIsUnsupported_RedisplaysPageWithError()
{
var mediator = CreateMediator(Result.Success<LoginOutcome>(new UnsupportedLoginOutcome()));
var model = CreateModel(mediator);

var result = await model.OnPostAsync();

result.ShouldBeOfType<PageResult>();
model.ModelState[string.Empty]!.Errors.Single().ErrorMessage.ShouldBe("Unsupported Login Success Type");
}

private static IMediatorImposter CreateMediator(Result<LoginOutcome>? loginResult = null)
{
var mediator = new IMediatorImposter();
mediator.Send(Arg<IRequest<Result<List<ExternalProviderDetails>>>>.Any(), Arg<CancellationToken>.Any())
.ReturnsAsync(Result.Success(new List<ExternalProviderDetails>()));
if (loginResult is not null)
{
mediator.Send(Arg<IRequest<Result<LoginOutcome>>>.Any(), Arg<CancellationToken>.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<IRequest<Result<LoginOutcome>>>.Any(), Arg<CancellationToken>.Any()).Called(Count.Never());
}

private sealed record UnsupportedLoginOutcome : LoginOutcome;
}
43 changes: 21 additions & 22 deletions SecurityService/Pages/Account/Login/Index.cshtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.AspNetCore.WebUtilities;
using SecurityService.BusinessLogic.Mfa;
using SecurityService.BusinessLogic.Requests;
using SimpleResults;

namespace SecurityService.Pages.Account.Login;

Expand Down Expand Up @@ -38,39 +39,37 @@ public async Task<IActionResult> 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<LoginOutcome> 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();
}

Expand Down
Loading