forked from leaderboardsgg/leaderboard-backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Account Recovery Endpoint (leaderboardsgg#195)
* 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
Showing
15 changed files
with
468 additions
and
58 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
263 changes: 263 additions & 0 deletions
263
LeaderboardBackend.Test/Features/Users/AccountRecoveryTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> { }; |
Oops, something went wrong.