Skip to content

Commit

Permalink
Account Activation (#14)
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 committed May 15, 2024
1 parent ef153ce commit 04165c4
Show file tree
Hide file tree
Showing 19 changed files with 318 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,10 @@ public sealed class ClientOptions : IAppOptions

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

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

[Required]
public required string CompleteAccountRegistrationUrl { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ public async Task<Result> Handle(ActivateAccountCommand command, CancellationTok
var user = await _userRepository.GetUserByIdAsync(command.UserId, ct);
return await user
.EnsureNotNull(UserErrors.UserNotFound)
.Tap(user => user.Activate())
.TapAsync(_ => _unitOfWork.SaveChangesAsync(ct))
.ToResultAsync();
.Then(user => user.Activate())
.TapAsync(() => _unitOfWork.SaveChangesAsync(ct));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using TeamUp.Common.Application;
using TeamUp.UserAccess.Application.Abstractions;
using TeamUp.UserAccess.Contracts;
using TeamUp.UserAccess.Contracts.CompleteRegistration;
using TeamUp.UserAccess.Domain;

namespace TeamUp.UserAccess.Application;

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

public CompleteRegistrationCommandHandler(IUserRepository userRepository, IPasswordService passwordService, IUnitOfWork<UserAccessModuleId> 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)
.Then(user => user.CompleteGeneratedRegistration(_passwordService.HashPassword(command.Password)))
.TapAsync(() => _unitOfWork.SaveChangesAsync(ct));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using FluentValidation;

using TeamUp.Common.Contracts;

namespace TeamUp.UserAccess.Contracts.CompleteRegistration;

public sealed record CompleteRegistrationCommand : ICommand
{
public required UserId UserId { get; init; }
public required string Password { get; init; }

public sealed class Validator : AbstractValidator<CompleteRegistrationCommand>
{
public Validator()
{
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.Password).NotEmpty();
}
}
}
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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ namespace TeamUp.UserAccess.Domain.EventHandlers;
internal sealed class UserCreatedEventHandler : IDomainEventHandler<UserCreatedDomainEvent>
{
private readonly IIntegrationEventPublisher<UserAccessModuleId> _publisher;
private readonly IClientUrlGenerator _urlGenerator;

public UserCreatedEventHandler(IIntegrationEventPublisher<UserAccessModuleId> publisher)
public UserCreatedEventHandler(IIntegrationEventPublisher<UserAccessModuleId> publisher, IClientUrlGenerator urlGenerator)
{
_publisher = publisher;
_urlGenerator = urlGenerator;
}

public Task Handle(UserCreatedDomainEvent domainEvent, CancellationToken ct)
Expand All @@ -26,14 +28,28 @@ public Task Handle(UserCreatedDomainEvent domainEvent, CancellationToken ct)

_publisher.Publish(userCrated);

var emailCreated = new EmailCreatedIntegrationEvent
if (domainEvent.User.State == UserState.NotActivated)
{
Email = domainEvent.User.Email,
Subject = "Activation Email",
Message = $"Activate your account! /api/v1/users/{domainEvent.User.Id.Value}/activate"
};

_publisher.Publish(emailCreated);
var emailCreated = new EmailCreatedIntegrationEvent
{
Email = domainEvent.User.Email,
Subject = "Successful Registration",
Message = $"You need to activate at your account at {_urlGenerator.GetActivationUrl(domainEvent.User.Id)} to finalize your registration."
};

_publisher.Publish(emailCreated);
}
else if (domainEvent.User.State == UserState.Generated)
{
var emailCreated = new EmailCreatedIntegrationEvent
{
Email = domainEvent.User.Email,
Subject = "Account has been created",
Message = $"You need to finalize your registration at {_urlGenerator.GetCompleteAccountRegistrationUrl(domainEvent.User.Id)}."
};

_publisher.Publish(emailCreated);
}

return Task.CompletedTask;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using TeamUp.UserAccess.Contracts;

namespace TeamUp.UserAccess.Domain;

public interface IClientUrlGenerator
{
public string GetActivationUrl(UserId userId);
public string GetCompleteAccountRegistrationUrl(UserId userId);
}
25 changes: 24 additions & 1 deletion src/Modules/UserAccess/TeamUp.UserAccess.Domain/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,33 @@ public void Delete()
AddDomainEvent(new UserDeletedDomainEvent(this));
}

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

State = UserState.Activated;
AddDomainEvent(new UserActivatedDomainEvent(this));
return Result.Success;
}

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

Password = password;
State = UserState.Activated;

return Result.Success;
}

internal static Expression<Func<User, bool>> AccountHasExpiredExpression(DateTime utcNow)
Expand Down
4 changes: 4 additions & 0 deletions src/Modules/UserAccess/TeamUp.UserAccess.Domain/UserErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ public static class UserErrors
public static readonly NotFoundError UserNotFound = new("UsersAccess.NotFound", "User not found.");

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

public static readonly DomainError CannotActivateGeneratedAccount = new("UsersAccess.Domain.ActivateGeneratedAccount", "Cannot activate generated account.");
public static readonly DomainError AccountAlreadyActivated = new("UsersAccess.Domain.AccountAlreadyActivated", "Account is already activated.");
public static readonly DomainError CannotCompleteRegistrationOfNonGeneratedAccount = new("UsersAccess.Domain.CompleteNonGeneratedAccount", "Cannot complete registration of non-generated account.");
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public void MapEndpoint(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,41 @@
using MediatR;

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;

using TeamUp.Common.Endpoints;
using TeamUp.UserAccess.Contracts;
using TeamUp.UserAccess.Contracts.CompleteRegistration;

namespace TeamUp.UserAccess.Endpoints;

public sealed class CompleteRegistrationEndpoint : IEndpoint
{
public void MapEndpoint(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 = UserId.FromGuid(userId),
Password = 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 @@ -26,7 +26,7 @@ public void MapEndpoint(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
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public EndpointGroupBuilder MapEndpoints(EndpointGroupBuilder group)
.AddEndpoint<RegisterUserEndpoint>()
.AddEndpoint<LoginEndpoint>()
.AddEndpoint<DeleteUserEndpoint>()
.AddEndpoint<ActivateAccountEndpoint>();
.AddEndpoint<ActivateAccountEndpoint>()
.AddEndpoint<CompleteRegistrationEndpoint>();
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.Extensions.Options;

using TeamUp.Common.Infrastructure.Options;
using TeamUp.UserAccess.Contracts;
using TeamUp.UserAccess.Domain;

namespace TeamUp.UserAccess.Infrastructure.Services;

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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public override void ConfigureServices(IServiceCollection services)
services
.AddSingleton<IPasswordService, PasswordService>()
.AddSingleton<ITokenService, JwtTokenService>()
.AddSingleton<IClientUrlGenerator, ClientUrlGenerator>()
.AddScoped<IUserAccessQueryContext, UserAccessDbQueryContextFacade>()
.AddScoped<IUserRepository, UserRepository>()
.AddScoped<UserFactory>()
Expand Down
4 changes: 3 additions & 1 deletion src/TeamUp.Bootstrapper/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"ExpirationMinutes": 30
},
"Client": {
"Url": "https://localhost"
"Url": "https://localhost",
"ActivateAccountUrl": "{0}/activate/{1}",
"CompleteAccountRegistrationUrl": "{0}/complete-registration/{1}"
},
"RabbitMq": {
"ConnectionString": "amqp://guest:guest@localhost:5672"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using TeamUp.Tests.Common.DataGenerators.UserAccess;
using TeamUp.UserAccess.Domain;
using TeamUp.UserAccess.Infrastructure.Persistence;

namespace TeamUp.Tests.EndToEnd.EndpointTests.UserAccess;
Expand All @@ -9,7 +10,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 @@ -36,4 +37,54 @@ public async Task ActivateAccount_Should_SetUserStatusAsActivatedInDatabase()
activatedUser.State.Should().Be(UserState.Activated);
});
}

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

await UseDbContextAsync<UserAccessDbContext>(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(UserState.Generated)
.Generate();

await UseDbContextAsync<UserAccessDbContext>(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 04165c4

Please sign in to comment.