Skip to content

Commit

Permalink
Account Activation (#55)
Browse files Browse the repository at this point in the history
* fix error response when activating account
* fix activate account endpoint tests
* add generating client urls from appsettings (class and options)
* refactored sending emails to use client urls
* add complete registration endpoint, command, command handler and domain logic
* add complete registration endpoint tests
* additional refactoring
  • Loading branch information
skrasekmichael authored May 15, 2024
1 parent 62a653a commit 9e68bdf
Show file tree
Hide file tree
Showing 21 changed files with 306 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public void MapEndpoints(RouteGroupBuilder group)
{
group.MapPost("/{userId:guid}/activate", ActivateAccountAsync)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status404NotFound)
.WithName(nameof(ActivateAccountEndpoint))
.MapToApiVersion(1);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using MediatR;

using Microsoft.AspNetCore.Mvc;

using TeamUp.Api.Extensions;
using TeamUp.Application.Users.Activation;
using TeamUp.Application.Users.CompleteRegistration;
using TeamUp.Contracts.Users;

namespace TeamUp.Api.Endpoints.UserAccess;

public sealed class CompleteRegistrationEndpoint : IEndpointGroup
{
public void MapEndpoints(RouteGroupBuilder group)
{
group.MapPost("/{userId:guid}/generated/complete", ActivateAccountAsync)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status404NotFound)
.WithName(nameof(CompleteRegistrationEndpoint))
.MapToApiVersion(1);
}

private async Task<IResult> ActivateAccountAsync(
[FromRoute] Guid userId,
[FromHeader(Name = UserConstants.HTTP_HEADER_PASSWORD)] string password,
[FromServices] ISender sender,
CancellationToken ct)
{
var command = new CompleteRegistrationCommand(UserId.FromGuid(userId), password);
var result = await sender.Send(command, ct);
return result.ToResponse(TypedResults.Ok);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public void MapEndpoints(RouteGroupBuilder group)

private async Task<IResult> DeleteUserAsync(
[FromServices] ISender sender,
[FromHeader(Name = UserConstants.HTTP_HEADER_CONFIRM_PASSWORD)] string password,
[FromHeader(Name = UserConstants.HTTP_HEADER_PASSWORD)] string password,
HttpContext httpContext,
CancellationToken ct)
{
Expand Down
1 change: 1 addition & 0 deletions src/TeamUp.Api/Endpoints/UserAccessEndpointGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public void MapEndpoints(RouteGroupBuilder group)
{
group.MapEndpoint<RegisterUserEndpoint>()
.MapEndpoint<ActivateAccountEndpoint>()
.MapEndpoint<CompleteRegistrationEndpoint>()
.MapEndpoint<LoginUserEndpoint>()
.MapEndpoint<GetMyAccountDetailsEndpoint>()
.MapEndpoint<DeleteAccountEndpoint>();
Expand Down
4 changes: 3 additions & 1 deletion src/TeamUp.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"Pbkdf2Iterations": 100
},
"Client": {
"Url": "https://localhost:7229"
"Url": "https://localhost:7229",
"ActivateAccountUrl": "{0}/activate/{1}",
"CompleteAccountRegistrationUrl": "{0}/complete-registration/{1}"
},
"MailSettings": {
"Server": "localhost",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using TeamUp.Application.Abstractions;
using TeamUp.Domain.Abstractions;
using TeamUp.Domain.Aggregates.Users;

namespace TeamUp.Application.Users.CompleteRegistration;

internal sealed class CompleteRegistrationCommandHandler : ICommandHandler<CompleteRegistrationCommand, Result>
{
private readonly IUserRepository _userRepository;
private readonly IPasswordService _passwordService;
private readonly IUnitOfWork _unitOfWork;

public CompleteRegistrationCommandHandler(IUserRepository userRepository, IPasswordService passwordService, IUnitOfWork unitOfWork)
{
_userRepository = userRepository;
_passwordService = passwordService;
_unitOfWork = unitOfWork;
}

public async Task<Result> Handle(CompleteRegistrationCommand command, CancellationToken ct)
{
var user = await _userRepository.GetUserByIdAsync(command.UserId, ct);
return await user
.EnsureNotNull(UserErrors.UserNotFound)
.Tap(user => user.CompleteGeneratedRegistration(_passwordService.HashPassword(command.Password)))
.TapAsync(_ => _unitOfWork.SaveChangesAsync(ct))
.ToResultAsync();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using TeamUp.Application.Abstractions;
using TeamUp.Contracts.Users;

namespace TeamUp.Application.Users.CompleteRegistration;

public sealed record CompleteRegistrationCommand(UserId UserId, string Password) : ICommand<Result>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using TeamUp.Common.Abstractions;
using TeamUp.Domain.Abstractions;
using TeamUp.Domain.Aggregates.Users.IntegrationEvents;

namespace TeamUp.Application.Users.Register;

internal sealed class UserGeneratedEventHandler : IIntegrationEventHandler<UserGeneratedEvent>
{
private readonly IEmailSender _emailSender;
private readonly IClientUrlGenerator _urlGenerator;

public UserGeneratedEventHandler(IEmailSender emailSender, IClientUrlGenerator urlGenerator)
{
_emailSender = emailSender;
_urlGenerator = urlGenerator;
}

public Task Handle(UserGeneratedEvent integrationEvent, CancellationToken ct)
{
return _emailSender.SendEmailAsync(
email: integrationEvent.Email,
subject: "Account has been created",
message: $"You need to finalize your registration at {_urlGenerator.GetCompleteAccountRegistrationUrl(integrationEvent.UserId)}.", ct);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,19 @@ namespace TeamUp.Application.Users.Register;
internal sealed class UserRegisteredEventHandler : IIntegrationEventHandler<UserRegisteredEvent>
{
private readonly IEmailSender _emailSender;
private readonly IClientUrlGenerator _urlGenerator;

public UserRegisteredEventHandler(IEmailSender emailSender)
public UserRegisteredEventHandler(IEmailSender emailSender, IClientUrlGenerator urlGenerator)
{
_emailSender = emailSender;
_urlGenerator = urlGenerator;
}

public Task Handle(UserRegisteredEvent integrationEvent, CancellationToken ct)
{
return _emailSender.SendEmailAsync(
email: integrationEvent.Email,
subject: "Successful registration",
message: $"You need to activate at /users/{integrationEvent.UserId.Value}/activate your account to finalize your registration.", ct);
subject: "Successful Registration",
message: $"You need to activate at your account at {_urlGenerator.GetActivationUrl(integrationEvent.UserId)} to finalize your registration.", ct);
}
}
2 changes: 1 addition & 1 deletion src/TeamUp.Contracts/Users/UserConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ public static class UserConstants
public const int USERNAME_MIN_SIZE = 3;
public const int USERNAME_MAX_SIZE = 30;

public const string HTTP_HEADER_CONFIRM_PASSWORD = "HTTP_HEADER_CONFIRM_PASSWORD";
public const string HTTP_HEADER_PASSWORD = "HTTP_HEADER_PASSWORD";
}
9 changes: 9 additions & 0 deletions src/TeamUp.Domain/Abstractions/IClientUrlGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using TeamUp.Contracts.Users;

namespace TeamUp.Domain.Abstractions;

public interface IClientUrlGenerator
{
public string GetActivationUrl(UserId userId);
public string GetCompleteAccountRegistrationUrl(UserId userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using TeamUp.Contracts.Users;
using TeamUp.Domain.Abstractions;

namespace TeamUp.Domain.Aggregates.Users.IntegrationEvents;

public sealed record UserGeneratedEvent(UserId UserId, string Email, string UserName) : IIntegrationEvent;
25 changes: 24 additions & 1 deletion src/TeamUp.Domain/Aggregates/Users/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,40 @@ internal User(UserId id, string name, string email, Password password, UserStatu

public override string ToString() => $"{Name} ({Status})";

public void Activate()
public Result Activate()
{
if (Status == UserStatus.Generated)
{
return UserErrors.CannotActivateGeneratedAccount;
}
else if (Status == UserStatus.Activated)
{
return UserErrors.AccountAlreadyActivated;
}

Status = UserStatus.Activated;
AddDomainEvent(new UserActivatedDomainEvent(this));
return Result.Success;
}

public void Delete()
{
AddDomainEvent(new UserDeletedDomainEvent(this));
}

public Result CompleteGeneratedRegistration(Password password)
{
if (Status != UserStatus.Generated)
{
return UserErrors.CannotCompleteRegistrationOfNonGeneratedAccount;
}

Password = password;
Status = UserStatus.Activated;

return Result.Success;
}

internal void IncreaseNumberOfOwningTeams() => NumberOfOwnedTeams += 1;

internal void DecreaseNumberOfOwningTeams() => NumberOfOwnedTeams -= 1;
Expand Down
4 changes: 4 additions & 0 deletions src/TeamUp.Domain/Aggregates/Users/UserErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ public static class UserErrors
public static readonly NotFoundError AccountNotFound = new("Users.NotFound.Account", "Account not found.");

public static readonly ConflictError ConflictingEmail = new("Users.Conflict.Email", "User with this email is already registered.");

public static readonly DomainError CannotActivateGeneratedAccount = new("Users.Domain.ActivateGeneratedAccount", "Cannot activate generated account.");
public static readonly DomainError AccountAlreadyActivated = new("Users.Domain.AccountAlreadyActivated", "Account is already activated.");
public static readonly DomainError CannotCompleteRegistrationOfNonGeneratedAccount = new("Users.Domain.CompleteNonGeneratedAccount", "Cannot complete registration of non-generated account.");
}
5 changes: 5 additions & 0 deletions src/TeamUp.Domain/EventHandlers/UserCreatedEventHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ public Task Handle(UserCreatedDomainEvent domainEvent, CancellationToken ct)
var integrationEvent = new UserRegisteredEvent(domainEvent.User.Id, domainEvent.User.Email, domainEvent.User.Name);
_integrationEventManager.AddIntegrationEvent(integrationEvent);
}
else if (domainEvent.User.Status == UserStatus.Generated)
{
var integrationEvent = new UserGeneratedEvent(domainEvent.User.Id, domainEvent.User.Email, domainEvent.User.Name);
_integrationEventManager.AddIntegrationEvent(integrationEvent);
}

return Task.CompletedTask;
}
Expand Down
23 changes: 23 additions & 0 deletions src/TeamUp.Infrastructure/Core/ClientUrlGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.Extensions.Options;

using TeamUp.Contracts.Users;
using TeamUp.Domain.Abstractions;
using TeamUp.Infrastructure.Options;

namespace TeamUp.Infrastructure.Core;

internal sealed class ClientUrlGenerator : IClientUrlGenerator
{
private readonly ClientOptions _options;

public ClientUrlGenerator(IOptions<ClientOptions> options)
{
_options = options.Value;
}

public string GetActivationUrl(UserId userId) =>
string.Format(_options.ActivateAccountUrl, _options.Url, userId.Value);

public string GetCompleteAccountRegistrationUrl(UserId userId) =>
string.Format(_options.CompleteAccountRegistrationUrl, _options.Url, userId.Value);
}
11 changes: 10 additions & 1 deletion src/TeamUp.Infrastructure/Options/ClientOptions.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
namespace TeamUp.Infrastructure.Options;
using System.ComponentModel.DataAnnotations;

namespace TeamUp.Infrastructure.Options;

internal sealed class ClientOptions : IApplicationOptions
{
public static string SectionName => "Client";

[Required]
public required string Url { get; init; }

[Required]
public required string ActivateAccountUrl { get; init; }

[Required]
public required string CompleteAccountRegistrationUrl { get; init; }
}
1 change: 1 addition & 0 deletions src/TeamUp.Infrastructure/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
services
.AddSingleton<IDateTimeProvider, DateTimeProvider>()
.AddSingleton<IEmailSender, EmailSender>()
.AddSingleton<IClientUrlGenerator, ClientUrlGenerator>()
.AddScoped<IIntegrationEventManager, IntegrationEventManager>()
.AddScoped<IDomainEventsDispatcher, DomainEventsDispatcher>()
.AddScoped<IIntegrationEventsDispatcher, IntegrationEventsDispatcher>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public sealed class ActivateAccountTests(AppFixture app) : UserAccessTests(app)
public static string GetUrl(Guid userId) => $"/api/v1/users/{userId}/activate";

[Fact]
public async Task ActivateAccount_Should_SetUserStatusAsActivatedInDatabase()
public async Task ActivateAccount_ThatIsNotActivated_Should_SetUserStatusAsActivatedInDatabase()
{
//arrange
var user = UserGenerators.User
Expand All @@ -30,4 +30,54 @@ await UseDbContextAsync(dbContext =>
activatedUser.ShouldNotBeNull();
activatedUser.Status.Should().Be(UserStatus.Activated);
}

[Fact]
public async Task ActivateAccount_ThatIsActivated_Should_ResultInBadRequest_DomainError()
{
//arrange
var user = UserGenerators.User
.Clone()
.WithStatus(UserStatus.Activated)
.Generate();

await UseDbContextAsync(dbContext =>
{
dbContext.Add(user);
return dbContext.SaveChangesAsync();
});

//act
var response = await Client.PostAsync(GetUrl(user.Id), null);

//assert
response.Should().Be400BadRequest();

var problemDetails = await response.ReadProblemDetailsAsync();
problemDetails.ShouldContainError(UserErrors.AccountAlreadyActivated);
}

[Fact]
public async Task ActivateAccount_ThatIsGenerated_Should_ResultInBadRequest_DomainError()
{
//arrange
var user = UserGenerators.User
.Clone()
.WithStatus(UserStatus.Generated)
.Generate();

await UseDbContextAsync(dbContext =>
{
dbContext.Add(user);
return dbContext.SaveChangesAsync();
});

//act
var response = await Client.PostAsync(GetUrl(user.Id), null);

//assert
response.Should().Be400BadRequest();

var problemDetails = await response.ReadProblemDetailsAsync();
problemDetails.ShouldContainError(UserErrors.CannotActivateGeneratedAccount);
}
}
Loading

0 comments on commit 9e68bdf

Please sign in to comment.