Skip to content

Commit

Permalink
Test Recovery Code Endpoint (#196)
Browse files Browse the repository at this point in the history
* Remove unused import.

* Rename AccountRecoveryTests.

* Add RecoverAccount route.

* Create test acount recovery tests.

* Add new result.

* Implement TestRecovery.

* Add GET /account/recover/{id} endpoint.

* Update openapi.json.

* Fix test file naming.

* Fix missing Test attributes.

* Don't recreate the client for each test.

* formatting

* Fix incorrect role checking.

* Allow admins to recover their accounts.

* Generalize role-based test.

* Delete Old result.

* Only banned users cannot recover their account.

* Use OneTimeSetUp instead of constructor.
  • Loading branch information
TheTedder authored Oct 16, 2023
1 parent 5b78f44 commit 1d54fa1
Show file tree
Hide file tree
Showing 8 changed files with 285 additions and 3 deletions.
5 changes: 4 additions & 1 deletion LeaderboardBackend.Test/Consts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@ internal static class Routes
public const string REGISTER = "/account/register";
public const string RESEND_CONFIRMATION = "/account/confirm";
public const string RECOVER_ACCOUNT = "/account/recover";
public static string ConfirmAccount(Guid id) => $"/account/confirm/{id.ToUrlSafeBase64String()}";
public static string ConfirmAccount(string id) => $"/account/confirm/{id}";
public static string ConfirmAccount(Guid id) => ConfirmAccount(id.ToUrlSafeBase64String());
public static string RecoverAccount(string id) => $"/account/recover/{id}";
public static string RecoverAccount(Guid id) => RecoverAccount(id.ToUrlSafeBase64String());
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
namespace LeaderboardBackend.Test.Features.Users;

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

Expand Down
161 changes: 161 additions & 0 deletions LeaderboardBackend.Test/Features/Users/TestRecoveryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Test.Fixtures;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using NodaTime;
using NodaTime.Testing;
using NUnit.Framework;

namespace LeaderboardBackend.Test.Features.Users;

public class TestRecoveryTests : IntegrationTestsBase
{
private IServiceScope _scope = null!;
private HttpClient _client = null!;
private readonly FakeClock _clock = new(Instant.FromUnixTimeSeconds(1));

[OneTimeSetUp]
public void OneTimeSetUp()
{
_client = _factory.WithWebHostBuilder(builder =>
builder.ConfigureTestServices(services =>
services.AddSingleton<IClock, FakeClock>(_ => _clock)
)
).CreateClient();
}

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

[TearDown]
public void TearDown() => _scope.Dispose();

[TestCase("not_a_guid")]
[TestCase("L8msfy9wd0qWbDJMZwwgQg")]
public async Task TestRecovery_BadRecoveryId(string id)
{
HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(id));
res.Should().HaveStatusCode(HttpStatusCode.NotFound);
}

[Test]
public async Task TestRecovery_Expired()
{
_clock.Reset(Instant.FromUnixTimeSeconds(1) + Duration.FromHours(2));
ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();

AccountRecovery recovery = new()
{
CreatedAt = Instant.FromUnixTimeSeconds(0),
ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)),
User = new()
{
Email = "[email protected]",
Password = "password",
Username = "username",
Role = UserRole.Confirmed
}
};

await context.AccountRecoveries.AddAsync(recovery);
await context.SaveChangesAsync();
HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(recovery.Id));
res.StatusCode.Should().Be(HttpStatusCode.NotFound);
}

[Test]
public async Task TestRecovery_Old()
{
_clock.Reset(Instant.FromUnixTimeSeconds(10));
ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();

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

AccountRecovery recovery1 = new()
{
CreatedAt = Instant.FromUnixTimeSeconds(0),
ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)),
User = user
};

AccountRecovery recovery2 = new()
{
CreatedAt = Instant.FromUnixTimeSeconds(5),
ExpiresAt = Instant.FromUnixTimeSeconds(5).Plus(Duration.FromHours(1)),
User = user
};

await context.AccountRecoveries.AddRangeAsync(recovery1, recovery2);
await context.SaveChangesAsync();
HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(recovery1.Id));
res.Should().HaveStatusCode(HttpStatusCode.NotFound);
}

[Test]
public async Task TestRecovery_Used()
{
_clock.Reset(Instant.FromUnixTimeSeconds(10));
ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();

AccountRecovery recovery = new()
{
CreatedAt = Instant.FromUnixTimeSeconds(0),
ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)),
UsedAt = Instant.FromUnixTimeSeconds(5),
User = new()
{
Email = "[email protected]",
Password = "password",
Username = "username",
Role = UserRole.Confirmed
}
};

await context.AccountRecoveries.AddAsync(recovery);
await context.SaveChangesAsync();
HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(recovery.Id));
res.Should().HaveStatusCode(HttpStatusCode.NotFound);
}

[TestCase(UserRole.Administrator, HttpStatusCode.OK)]
[TestCase(UserRole.Banned, HttpStatusCode.NotFound)]
[TestCase(UserRole.Confirmed, HttpStatusCode.OK)]
[TestCase(UserRole.Registered, HttpStatusCode.OK)]
public async Task TestRecovery_Roles(UserRole role, HttpStatusCode expected)
{
_clock.Reset(Instant.FromUnixTimeSeconds(1));
ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();

AccountRecovery recovery = new()
{
CreatedAt = Instant.FromUnixTimeSeconds(0),
ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)),
User = new()
{
Email = "[email protected]",
Password = "password",
Username = "username",
Role = role
}
};

await context.AccountRecoveries.AddAsync(recovery);
await context.SaveChangesAsync();
HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(recovery.Id));
res.Should().HaveStatusCode(expected);
}
}
25 changes: 25 additions & 0 deletions LeaderboardBackend/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,29 @@ public async Task<ActionResult> ConfirmAccount(Guid id, [FromServices] IAccountC
expired => NotFound()
);
}

/// <summary>
/// Tests an account recovery token for validity.
/// </summary>
/// <param name="id">The recovery token.</param>
/// <param name="recoveryService">IAccountRecoveryService dependency.</param>
/// <response code="200">The token provided is valid.</response>
/// <response code="404">The token provided is invalid or expired, or the user is banned.</response>
[AllowAnonymous]
[HttpGet("recover/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> TestRecovery(Guid id, [FromServices] IAccountRecoveryService recoveryService)
{
RecoverAccountResult result = await recoveryService.TestRecovery(id);

return result.Match<ActionResult>(
alreadyUsed => NotFound(),
badRole => NotFound(),
expired => NotFound(),
notFound => NotFound(),
success => Ok()
);
}
}
1 change: 0 additions & 1 deletion LeaderboardBackend/Models/Entities/AccountRecovery.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using NodaTime;

namespace LeaderboardBackend.Models.Entities;
Expand Down
5 changes: 5 additions & 0 deletions LeaderboardBackend/Services/IAccountRecoveryService.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Result;
using OneOf;
using OneOf.Types;

namespace LeaderboardBackend.Services;

public interface IAccountRecoveryService
{
Task<CreateRecoveryResult> CreateRecoveryAndSendEmail(User user);
Task<RecoverAccountResult> TestRecovery(Guid id);
}

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

[GenerateOneOf]
public partial class RecoverAccountResult : OneOfBase<AlreadyUsed, BadRole, Expired, NotFound, Success> { };
44 changes: 44 additions & 0 deletions LeaderboardBackend/Services/Impl/AccountRecoveryService.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Result;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NodaTime;
using OneOf.Types;

namespace LeaderboardBackend.Services;

Expand Down Expand Up @@ -70,4 +72,46 @@ private string GenerateAccountRecoveryEmailBody(User user, AccountRecovery recov

return $@"Hi {user.Username},<br/><br/>Click <a href=""{builder.Uri}"">here</a> to reset your password.";
}

public async Task<RecoverAccountResult> TestRecovery(Guid id)
{
AccountRecovery? recovery = await _applicationContext.AccountRecoveries.Include(ar => ar.User).SingleOrDefaultAsync(ar => ar.Id == id);

if (recovery is null)
{
return new NotFound();
}

if (recovery.User.Role is UserRole.Banned)
{
return new BadRole();
}

if (recovery.UsedAt is not null)
{
return new AlreadyUsed();
}

Instant now = _clock.GetCurrentInstant();

if (recovery.ExpiresAt <= now)
{
return new Expired();
}

IQueryable<Guid> latest =
from rec in _applicationContext.AccountRecoveries
where rec.UserId == recovery.UserId
orderby rec.CreatedAt descending
select rec.Id;

Guid latestId = await latest.FirstAsync();

if (latestId != id)
{
return new Expired();
}

return new Success();
}
}
45 changes: 45 additions & 0 deletions LeaderboardBackend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,51 @@
}
}
},
"/Account/recover/{id}": {
"get": {
"tags": [
"Account"
],
"summary": "Tests an account recovery token for validity.",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The recovery token.",
"required": true,
"schema": {
"pattern": "^[a-zA-Z0-9-_]{22}$",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The token provided is valid."
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "The token provided is invalid or expired, or the user is banned.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/Categories/{id}": {
"get": {
"tags": [
Expand Down

0 comments on commit 1d54fa1

Please sign in to comment.