Skip to content

Commit

Permalink
Account Recovery Endpoint (leaderboardsgg#195)
Browse files Browse the repository at this point in the history
* Remove required attribute on User navigation.

* Move results into a separate file.

* Create AccountRecoveryService.

* Add GetUserByNameAndEmail method.

* Create RecoverAccountRequest record.

* Add POST /account/recover endpoint.

* Update openapi.json.

* Add missing attributes to the request model.

* Add 400 bad request response type attribute.

* Update openapi.json.

* dotnet format

* Add the AccountRecoveryService.

* Don't use case-insensitive Equals().

* Don't validate account recovery requests.

* Add account recovery route.

* Add account recovery tests.

* Fix Typo

Co-authored-by: zysim <[email protected]>

* Fix test method names.

* Explicitly accept application/json.
  • Loading branch information
TheTedder authored Sep 17, 2023
1 parent c0fbccb commit c410930
Show file tree
Hide file tree
Showing 15 changed files with 468 additions and 58 deletions.
1 change: 1 addition & 0 deletions LeaderboardBackend.Test/Consts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ internal static class Routes
public const string LOGIN = "/login";
public const string REGISTER = "/account/register";
public const string RESEND_CONFIRMATION = "/account/confirm";
public const string RECOVER_ACCOUNT = "/account/recover";
}
263 changes: 263 additions & 0 deletions LeaderboardBackend.Test/Features/Users/AccountRecoveryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Models.Requests;
using LeaderboardBackend.Services;
using LeaderboardBackend.Test.Fixtures;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using NodaTime;
using NodaTime.Testing;
using NUnit.Framework;

namespace LeaderboardBackend.Test.Features.Users;

[TestFixture]
public class AccountRecoveryTests : IntegrationTestsBase
{
private IServiceScope _scope = null!;

[SetUp]
public void Init()
{
_scope = _factory.Services.CreateScope();
}

[TearDown]
public void TearDown()
{
_factory.ResetDatabase();
_scope.Dispose();
}

public async Task SendRecoveryEmail_MalformedMissingUsername()
{
Mock<IEmailSender> emailSenderMock = new();

HttpClient client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddScoped(_ => emailSenderMock.Object);
});
}).CreateClient();

ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();

User user = new()
{
Email = "[email protected]",
Username = "username",
Password = "password",
Role = UserRole.Confirmed
};

await context.Users.AddAsync(user);
await context.SaveChangesAsync();

HttpResponseMessage res = await Client.PostAsJsonAsync(
Routes.RECOVER_ACCOUNT,
new
{
Email = user.Email
}
);

res.Should().HaveStatusCode(HttpStatusCode.BadRequest);
context.ChangeTracker.Clear();

AccountRecovery? recovery = await context.AccountRecoveries.FirstOrDefaultAsync(
ar => ar.UserId == user.Id
);

recovery.Should().BeNull();
emailSenderMock.Verify(
m => m.EnqueueEmailAsync(user.Email, It.IsAny<string>(), It.IsAny<string>()),
Times.Never()
);
}

public async Task SendRecoveryEmail_MalformedMissingEmail()
{
Mock<IEmailSender> emailSenderMock = new();

HttpClient client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddScoped(_ => emailSenderMock.Object);
});
}).CreateClient();

ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();

User user = new()
{
Email = "[email protected]",
Username = "username",
Password = "password",
Role = UserRole.Confirmed
};

await context.Users.AddAsync(user);
await context.SaveChangesAsync();

HttpResponseMessage res = await Client.PostAsJsonAsync(
Routes.RECOVER_ACCOUNT,
new
{
Username = "username"
}
);

res.Should().HaveStatusCode(HttpStatusCode.BadRequest);
context.ChangeTracker.Clear();

AccountRecovery? recovery = await context.AccountRecoveries.FirstOrDefaultAsync(
ar => ar.UserId == user.Id
);

recovery.Should().BeNull();
emailSenderMock.Verify(
m => m.EnqueueEmailAsync(user.Email, It.IsAny<string>(), It.IsAny<string>()),
Times.Never()
);
}

[TestCase(UserRole.Banned)]
[TestCase(UserRole.Registered)]
public async Task SendRecoveryEmail_BadRole(UserRole role)
{
Mock<IEmailSender> emailSenderMock = new();

HttpClient client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddScoped(_ => emailSenderMock.Object);
});
}).CreateClient();

ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();

User user = new()
{
Email = "[email protected]",
Password = "password",
Username = "username",
Role = role
};

await context.Users.AddAsync(user);
await context.SaveChangesAsync();

HttpResponseMessage res = await Client.PostAsJsonAsync(
Routes.RECOVER_ACCOUNT,
new RecoverAccountRequest
{
Email = "[email protected]",
Username = "username"
}
);

res.Should().HaveStatusCode(HttpStatusCode.OK);
context.ChangeTracker.Clear();

AccountRecovery? recovery = await context.AccountRecoveries.SingleOrDefaultAsync(
ar => ar.UserId == user.Id
);

recovery.Should().BeNull();
emailSenderMock.Verify(
m => m.EnqueueEmailAsync(user.Email, It.IsAny<string>(), It.IsAny<string>()),
Times.Never()
);
}

[Test]
public async Task SendRecoveryEmail_UserNotPresent()
{
Mock<IEmailSender> emailSenderMock = new();

HttpClient client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddScoped(_ => emailSenderMock.Object);
});
})
.CreateClient();

HttpResponseMessage res = await client.PostAsJsonAsync(
Routes.RECOVER_ACCOUNT,
new RecoverAccountRequest
{
Email = "[email protected]",
Username = "username"
}
);

res.Should().HaveStatusCode(HttpStatusCode.OK);

emailSenderMock.Verify(m => m.EnqueueEmailAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
Times.Never()
);
}

[Test]
public async Task SendRecoveryEmail_Success()
{
Mock<IEmailSender> emailSenderMock = new();

HttpClient client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddScoped(_ => emailSenderMock.Object);
services.AddSingleton<IClock, FakeClock>(_ => new(Instant.FromUnixTimeSeconds(0)));
});
}).CreateClient();

ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();
User user = new()
{
Email = "[email protected]",
Username = "username",
Password = "password",
Role = UserRole.Confirmed
};

await context.Users.AddAsync(user);
await context.SaveChangesAsync();

HttpResponseMessage res = await client.PostAsJsonAsync(
Routes.RECOVER_ACCOUNT,
new RecoverAccountRequest
{
Email = "[email protected]",
Username = "username"
}
);

res.Should().HaveStatusCode(HttpStatusCode.OK);
context.ChangeTracker.Clear();

AccountRecovery? recovery = await context.AccountRecoveries.FirstOrDefaultAsync(
ar => ar.UserId == user.Id
);

recovery.Should().NotBeNull();
recovery!.CreatedAt.Should().Be(Instant.FromUnixTimeSeconds(0));
recovery!.ExpiresAt.Should().Be(Instant.FromUnixTimeSeconds(0) + Duration.FromHours(1));

emailSenderMock.Verify(
m => m.EnqueueEmailAsync(user.Email, It.IsAny<string>(), It.IsAny<string>()),
Times.Once()
);
}
}
27 changes: 27 additions & 0 deletions LeaderboardBackend/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Models.Requests;
using LeaderboardBackend.Models.ViewModels;
using LeaderboardBackend.Result;
using LeaderboardBackend.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
Expand Down Expand Up @@ -169,4 +170,30 @@ [FromServices] IAccountConfirmationService confirmationService
notFound => Unauthorized()
);
}

/// <summary>
/// Sends an account recovery email.
/// </summary>
/// <param name="recoveryService">IAccountRecoveryService dependency.</param>
/// <param name="request">The account recovery request.</param>
/// <response code="200">This endpoint returns 200 OK regardless of whether the email was sent successfully or not.</response>
/// <response code="400">The request object was malformed.</response>
[AllowAnonymous]
[HttpPost("recover")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> RecoverAccount(
[FromServices] IAccountRecoveryService recoveryService,
[FromBody] RecoverAccountRequest request
)
{
User? user = await _userService.GetUserByNameAndEmail(request.Username, request.Email);

if (user is not null)
{
await recoveryService.CreateRecoveryAndSendEmail(user);
}

return Ok();
}
}
1 change: 1 addition & 0 deletions LeaderboardBackend/Controllers/ApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace LeaderboardBackend.Controllers;

[ApiController]
[Consumes("application/json")]
[Produces("application/json")]
[Route("api/[controller]")]
public abstract class ApiController : ControllerBase { }
1 change: 0 additions & 1 deletion LeaderboardBackend/Models/Entities/AccountRecovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ public class AccountRecovery
/// <summary>
/// The `User` relationship model.
/// </summary>
[Required]
public User User { get; set; } = null!;

/// <summary>
Expand Down
16 changes: 16 additions & 0 deletions LeaderboardBackend/Models/Requests/UserRequests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ public record RegisterRequest
public required string Password { get; set; }
}

public record RecoverAccountRequest
{
/// <summary>
/// The user's name.
/// </summary>
[Required]
public required string Username { get; set; }

/// <summary>
/// The user's email address.
/// </summary>
[EmailAddress]
[Required]
public required string Email { get; set; }
}

public class LoginRequestValidator : AbstractValidator<LoginRequest>
{
public LoginRequestValidator()
Expand Down
1 change: 1 addition & 0 deletions LeaderboardBackend/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IAccountConfirmationService, AccountConfirmationService>();
builder.Services.AddScoped<IAccountRecoveryService, AccountRecoveryService>();
builder.Services.AddScoped<IRunService, RunService>();
builder.Services.AddSingleton<IEmailSender, EmailSender>();
builder.Services.AddSingleton<ISmtpClient>(_ => new SmtpClient() { Timeout = 3000 });
Expand Down
7 changes: 7 additions & 0 deletions LeaderboardBackend/Results.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace LeaderboardBackend.Result;

public readonly record struct BadCredentials();
public readonly record struct BadRole();
public readonly record struct EmailFailed();
public readonly record struct UserNotFound();
public readonly record struct UserBanned();
4 changes: 1 addition & 3 deletions LeaderboardBackend/Services/IAccountConfirmationService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Result;
using OneOf;

namespace LeaderboardBackend.Services;
Expand All @@ -11,6 +12,3 @@ public interface IAccountConfirmationService

[GenerateOneOf]
public partial class CreateConfirmationResult : OneOfBase<AccountConfirmation, BadRole, EmailFailed> { };

public readonly record struct BadRole();
public readonly record struct EmailFailed();
13 changes: 13 additions & 0 deletions LeaderboardBackend/Services/IAccountRecoveryService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Result;
using OneOf;

namespace LeaderboardBackend.Services;

public interface IAccountRecoveryService
{
Task<CreateRecoveryResult> CreateRecoveryAndSendEmail(User user);
}

[GenerateOneOf]
public partial class CreateRecoveryResult : OneOfBase<AccountRecovery, BadRole, EmailFailed> { };
Loading

0 comments on commit c410930

Please sign in to comment.