Skip to content

Commit

Permalink
Confirm Account Endpoint (leaderboardsgg#193)
Browse files Browse the repository at this point in the history
* Remove redundant response type.

* Implement ConfirmAccount.

* Implement PUT /account/confirm/{id} endpoint.

* Fix typo.

* Add account confirm route to Consts.cs.

* Include User navigation.

* Add account confirmation tests.

* Fix account confirmation tests.

* dotnet format

* Add XML comments.

* Don't confirm if it JUST expired this instant.

* Add results.

* Update openapi.json.

* Return 404 for malformed confirmation ID.

* formatting

* Be explicit about confirmation UsedAt time.

* formatting

* Split tests.

* formatting
  • Loading branch information
TheTedder authored Oct 7, 2023
1 parent c410930 commit 5b78f44
Show file tree
Hide file tree
Showing 8 changed files with 323 additions and 7 deletions.
3 changes: 3 additions & 0 deletions LeaderboardBackend.Test/Consts.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System;

namespace LeaderboardBackend.Test;

internal static class Routes
Expand All @@ -6,4 +8,5 @@ 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()}";
}
190 changes: 190 additions & 0 deletions LeaderboardBackend.Test/Features/Users/ConfirmAccountTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
using System;
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.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using NodaTime;
using NodaTime.Testing;
using NUnit.Framework;

namespace LeaderboardBackend.Test.Features.Users;

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

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

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

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

[Test]
public async Task ConfirmAccount_BadConfirmationId()
{
_clock.Reset(Instant.FromUnixTimeSeconds(1));
ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();
AccountConfirmation confirmation = new()
{
CreatedAt = Instant.FromUnixTimeSeconds(0),
ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)),
User = new()
{
Email = "[email protected]",
Password = "password",
Username = "username",
}
};

await context.AccountConfirmations.AddAsync(confirmation);
await context.SaveChangesAsync();
HttpResponseMessage res = await _client.PutAsync(Routes.ConfirmAccount(Guid.NewGuid()), null);
res.StatusCode.Should().Be(HttpStatusCode.NotFound);
context.ChangeTracker.Clear();
User? user = await context.Users.FindAsync(confirmation.UserId);
user!.Role.Should().Be(UserRole.Registered);
}

[Test]
public async Task ConfirmAccount_MalformedConfirmationId()
{
_clock.Reset(Instant.FromUnixTimeSeconds(1));
HttpResponseMessage res = await _client.PutAsync("/account/confirm/not_a_guid", null);
res.StatusCode.Should().Be(HttpStatusCode.NotFound);
}

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

AccountConfirmation confirmation = 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.AccountConfirmations.AddAsync(confirmation);
await context.SaveChangesAsync();

HttpResponseMessage res = await _client.PutAsync(Routes.ConfirmAccount(confirmation.Id), null);
res.StatusCode.Should().Be(HttpStatusCode.Conflict);
context.ChangeTracker.Clear();
AccountConfirmation? conf = await context.AccountConfirmations.FindAsync(confirmation.Id);
conf!.UsedAt.Should().BeNull();
}

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

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

await context.AccountConfirmations.AddAsync(confirmation);
await context.SaveChangesAsync();
HttpResponseMessage res = await _client.PutAsync(Routes.ConfirmAccount(confirmation.Id), null);
res.StatusCode.Should().Be(HttpStatusCode.NotFound);
context.ChangeTracker.Clear();
AccountConfirmation? conf = await context.AccountConfirmations.Include(c => c.User).SingleOrDefaultAsync(c => c.Id == confirmation.Id);
conf!.UsedAt.Should().BeNull();
conf!.User.Role.Should().Be(UserRole.Registered);
}

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

AccountConfirmation confirmation = 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",
}
};

await context.AccountConfirmations.AddAsync(confirmation);
await context.SaveChangesAsync();
HttpResponseMessage res = await _client.PutAsync(Routes.ConfirmAccount(confirmation.Id), null);
res.StatusCode.Should().Be(HttpStatusCode.NotFound);
context.ChangeTracker.Clear();
User? user = await context.Users.FindAsync(confirmation.UserId);
user!.Role.Should().Be(UserRole.Registered);
}

[Test]
public async Task ConfirmAccount_Success()
{
_clock.Reset(Instant.FromUnixTimeSeconds(1));

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

ApplicationContext context = _scope.ServiceProvider.GetRequiredService<ApplicationContext>();
await context.AccountConfirmations.AddAsync(confirmation);
await context.SaveChangesAsync();
HttpResponseMessage res = await _client.PutAsync(Routes.ConfirmAccount(confirmation.Id), null);
res.Should().HaveStatusCode(HttpStatusCode.OK);
context.ChangeTracker.Clear();
AccountConfirmation? conf = await context.AccountConfirmations.Include(c => c.User).SingleOrDefaultAsync(c => c.Id == confirmation.Id);
conf!.UsedAt.Should().Be(Instant.FromUnixTimeSeconds(1));
conf!.User.Role.Should().Be(UserRole.Confirmed);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
Expand All @@ -15,8 +16,7 @@

namespace LeaderboardBackend.Test.Features.Users;

[TestFixture]
public class AccountConfirmationTests : IntegrationTestsBase
public class SendConfirmationTests : IntegrationTestsBase
{
private IServiceScope _scope = null!;
private IAuthService _authService = null!;
Expand Down Expand Up @@ -70,7 +70,7 @@ public async Task ResendConfirmation_Conflict()
Username = "username",
Role = UserRole.Confirmed,
};
context.Add<User>(user);
context.Add(user);
context.SaveChanges();
string token = _authService.GenerateJSONWebToken(user);

Expand All @@ -86,12 +86,12 @@ public async Task ResendConfirmation_EmailFailedToSend()
Mock<IEmailSender> emailSenderMock = new();
emailSenderMock.Setup(e =>
e.EnqueueEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())
).Throws(new System.Exception());
).Throws(new Exception());
HttpClient client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddScoped<IEmailSender>(_ => emailSenderMock.Object);
services.AddScoped(_ => emailSenderMock.Object);
});
})
.CreateClient();
Expand All @@ -118,7 +118,7 @@ public async Task ResendConfirmation_Success()
{
builder.ConfigureTestServices(services =>
{
services.AddScoped<IEmailSender>(_ => emailSenderMock.Object);
services.AddScoped(_ => emailSenderMock.Object);
services.AddSingleton<IClock, FakeClock>(_ => new(Instant.FromUnixTimeSeconds(1)));
});
})
Expand Down
28 changes: 27 additions & 1 deletion LeaderboardBackend/Controllers/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@ public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest req
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult> ResendConfirmation(
[FromServices] IAccountConfirmationService confirmationService
Expand Down Expand Up @@ -196,4 +195,31 @@ [FromBody] RecoverAccountRequest request

return Ok();
}

/// <summary>
/// Confirms a user account.
/// </summary>
/// <param name="id">The confirmation token.</param>
/// <param name="confirmationService">IAccountConfirmationService dependency.</param>
/// <response code="200">The account was confirmed successfully.</response>
/// <response code="404">The token provided was invalid or expired.</response>
/// <response code="409">The user's account was either already confirmed or banned.</response>
[AllowAnonymous]
[HttpPut("confirm/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult> ConfirmAccount(Guid id, [FromServices] IAccountConfirmationService confirmationService)
{
ConfirmAccountResult result = await confirmationService.ConfirmAccount(id);

return result.Match<ActionResult>(
confirmed => Ok(),
alreadyUsed => NotFound(),
badRole => Conflict(),
notFound => NotFound(),
expired => NotFound()
);
}
}
4 changes: 4 additions & 0 deletions LeaderboardBackend/Results.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
namespace LeaderboardBackend.Result;

public readonly record struct AccountConfirmed();
public readonly record struct AlreadyUsed();
public readonly record struct BadCredentials();
public readonly record struct BadRole();
public readonly record struct ConfirmationNotFound();
public readonly record struct EmailFailed();
public readonly record struct Expired();
public readonly record struct UserNotFound();
public readonly record struct UserBanned();
5 changes: 5 additions & 0 deletions LeaderboardBackend/Services/IAccountConfirmationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ public interface IAccountConfirmationService
{
Task<AccountConfirmation?> GetConfirmationById(Guid id);
Task<CreateConfirmationResult> CreateConfirmationAndSendEmail(User user);
Task<ConfirmAccountResult> ConfirmAccount(Guid id);
}

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

[GenerateOneOf]
public partial class ConfirmAccountResult : OneOfBase<AccountConfirmed, AlreadyUsed, BadRole, ConfirmationNotFound, Expired> { };

33 changes: 33 additions & 0 deletions LeaderboardBackend/Services/Impl/AccountConfirmationService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using LeaderboardBackend.Models.Entities;
using LeaderboardBackend.Result;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NodaTime;

Expand Down Expand Up @@ -68,6 +69,38 @@ await _emailSender.EnqueueEmailAsync(
return newConfirmation;
}

public async Task<ConfirmAccountResult> ConfirmAccount(Guid id)
{
AccountConfirmation? confirmation = await _applicationContext.AccountConfirmations.Include(c => c.User).SingleOrDefaultAsync(c => c.Id == id);

if (confirmation is null)
{
return new ConfirmationNotFound();
}

if (confirmation.User.Role is not UserRole.Registered)
{
return new BadRole();
}

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

Instant now = _clock.GetCurrentInstant();

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

confirmation.User.Role = UserRole.Confirmed;
confirmation.UsedAt = now;
await _applicationContext.SaveChangesAsync();
return new AccountConfirmed();
}

private string GenerateAccountConfirmationEmailBody(User user, AccountConfirmation confirmation)
{
// Copy of https://datatracker.ietf.org/doc/html/rfc7515#page-55
Expand Down
Loading

0 comments on commit 5b78f44

Please sign in to comment.