< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.CreateUserCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/CreateUserCommandHandler.cs
Line coverage
96%
Covered lines: 60
Uncovered lines: 2
Coverable lines: 62
Total lines: 142
Line coverage: 96.7%
Branch coverage
85%
Covered branches: 12
Total branches: 14
Branch coverage: 85.7%
Method coverage
100%
Covered methods: 2
Fully covered methods: 1
Total methods: 2
Method coverage: 100%
Full method coverage: 50%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()78.57%141496.22%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/CreateUserCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Data.Relational.Core.Repositories;
 3using ArturRios.Heimdall.Command.Input;
 4using ArturRios.Heimdall.Command.Output;
 5using ArturRios.Heimdall.Command.Services;
 6using ArturRios.Heimdall.Domain.Entities;
 7using ArturRios.Heimdall.Domain.Enums;
 8using ArturRios.Heimdall.Shared.Messages;
 9using ArturRios.Heimdall.Shared.Services;
 10using ArturRios.Mediator.Command.Interfaces;
 11using ArturRios.Output;
 12using ArturRios.Util.Hashing;
 13using FluentValidation;
 14using Microsoft.EntityFrameworkCore;
 15
 16namespace ArturRios.Heimdall.Command.Handlers;
 17
 18/// <summary>
 19///     Handles <see cref="CreateUserCommand" /> (UC-06 path a): validates input, verifies the target
 20///     scope exists and is active (AF-06b), enforces scope ownership for a Scope Admin actor (AF-06e),
 21///     checks the email is unique among the scope's Users (AF-06a), then creates a <c>User</c> with a
 22///     <c>SCOPE_USER</c> row and issues a verification token. A System Admin actor bypasses the
 23///     ownership check.
 24/// </summary>
 2125public class CreateUserCommandHandler(
 2126    IValidator<CreateUserCommand> validator,
 2127    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 2128    IAsyncReadOnlyRepository<Person, long> personReader,
 2129    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 2130    IAsyncRepository<Person, long> personWriter,
 2131    IScopeOwnershipChecker scopeOwnership,
 2132    IEmailVerificationService emailVerification)
 33    : ICommandHandlerAsync<CreateUserCommand, CreatePersonCommandOutput>
 34{
 35    public async Task<DataOutput<CreatePersonCommandOutput?>> HandleAsync(CreateUserCommand command, CancellationToken c
 36    {
 2137        var output = DataOutput<CreatePersonCommandOutput?>.New;
 38
 39        // AF-06d: validate input shape.
 2140        var validation = await validator.ValidateAsync(command);
 41
 2142        if (!validation.IsValid)
 43        {
 044            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 45        }
 46
 47        // AF-06b: the target scope must exist and not be logically deleted.
 2148        var scope = await scopeReader.Query()
 2149            .FirstOrDefaultAsync(x => x.PublicId == command.ScopeId && !x.IsDeleted);
 50
 2151        if (scope is null)
 52        {
 253            return output.WithError(PersonMessages.ScopeNotFound);
 54        }
 55
 56        // AF-06e: a Scope Admin actor may only act on a scope they own; a System Admin bypasses.
 1957        if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id))
 58        {
 259            return output.WithError(PersonMessages.NotScopeOwner);
 60        }
 61
 62        // AF-06a: a User's email must be unique among the scope's Users, compared case-insensitively
 63        // (LOWER() in SQL). Trimmed first, here and where it is stored: sign-in and recovery match the
 64        // stored address exactly, case aside, so surrounding whitespace would make it unreachable.
 1765        var address = command.Email.Trim();
 1766        var email = address.ToLower();
 67
 1768        var emailTaken = await personReader.Query().AnyAsync(person =>
 1769            !person.IsDeleted && person.Email.ToLower() == email &&
 1770            person.ScopeMembership != null && person.ScopeMembership.ScopeId == scope.Id);
 71
 1772        if (emailTaken)
 73        {
 474            return output.WithError(PersonMessages.EmailAlreadyExists);
 75        }
 76
 77        // FR-GO-07: the scope's address space is shared with its Google Users, so the second half of
 78        // the rule is a second read — the same pair GoogleSignInCommandHandler makes before signing a
 79        // Google account up, in the other order. Without it the rule held in one direction only: a
 80        // Google sign-up was refused an address a User held, while a User could still be created on
 81        // an address a Google User held, leaving one scope with two identities for one address.
 1382        var takenByGoogleUser = await googleUserReader.Query()
 1383            .AnyAsync(googleUser => googleUser.ScopeId == scope.Id && googleUser.Email.ToLower() == email);
 84
 1385        if (takenByGoogleUser)
 86        {
 187            return output.WithError(PersonMessages.EmailAlreadyExists);
 88        }
 89
 90        // Create the User with its SCOPE_USER membership row.
 1291        var (passwordHash, salt) = await PasswordHashGate.Shared.EncodeWithRandomSaltAsync(command.Password);
 92
 1293        var newPerson = new Person
 1294        {
 1295            Name = command.Name,
 1296            Email = address,
 1297            PasswordHash = passwordHash,
 1298            Salt = salt,
 1299            RoleId = (long)Roles.User,
 12100            ScopeMembership = new ScopeUser { ScopeId = scope.Id },
 12101            // Kept in step with the membership row above; see Person.ScopeId for why the scope is
 12102            // carried here as well. It is what the per-scope uniqueness index is written over.
 12103            ScopeId = scope.Id
 12104        };
 105
 106        // NFR-23: the basis the tenant declares for its own users, or contract performance.
 12107        LegalBasisRecorder.Record(newPerson, scope, DateTime.UtcNow);
 108
 12109        var creation = await personWriter.CreateAsync(newPerson);
 110
 12111        if (!creation.Success)
 112        {
 113            // The per-scope uniqueness index refusing the insert means another request took the
 114            // address between this handler's check above and this write — the race the index exists
 115            // to catch. It is the same conflict AF-06a describes, so it is reported as AF-06a rather
 116            // than as a persistence failure: a caller who lost the race should not be able to tell
 117            // they lost a race, only that the address is taken.
 3118            if (creation.Errors.Contains(RelationalErrors.UniqueViolationMessage))
 119            {
 3120                return output.WithError(PersonMessages.EmailAlreadyExists);
 121            }
 122
 0123            return output.WithErrors(creation.Errors);
 124        }
 125
 126        // FR-EV-01/02: issue and send the verification token.
 9127        await emailVerification.IssueAndSendAsync(newPerson);
 128
 9129        return output
 9130            .WithData(new CreatePersonCommandOutput
 9131            {
 9132                Id = newPerson.PublicId,
 9133                Name = newPerson.Name,
 9134                Email = newPerson.Email,
 9135                Role = (int)Roles.User,
 9136                EmailVerified = newPerson.EmailVerified,
 9137                ScopeId = scope.PublicId,
 9138                CreatedAt = newPerson.CreatedAt
 9139            })
 9140            .WithMessage(PersonMessages.PersonCreatedSuccessfully);
 21141    }
 142}