< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.CreateAdminCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/CreateAdminCommandHandler.cs
Line coverage
97%
Covered lines: 41
Uncovered lines: 1
Coverable lines: 42
Total lines: 94
Line coverage: 97.6%
Branch coverage
83%
Covered branches: 5
Total branches: 6
Branch coverage: 83.3%
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()83.33%6697.22%

File(s)

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

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Input;
 3using ArturRios.Heimdall.Command.Output;
 4using ArturRios.Heimdall.Command.Services;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Enums;
 7using ArturRios.Heimdall.Shared.Messages;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using ArturRios.Util.Hashing;
 11using FluentValidation;
 12using Microsoft.EntityFrameworkCore;
 13
 14namespace ArturRios.Heimdall.Command.Handlers;
 15
 16/// <summary>
 17///     Handles <see cref="CreateAdminCommand" /> (UC-06 path b): validates the request, verifies the
 18///     email is unique among admin persons system-wide (AF-06a), hashes the password, and creates a
 19///     <c>ScopeAdmin</c>/<c>SystemAdmin</c> with no scope association, then issues a verification
 20///     token. AF-06c (non-System-Admin) is enforced by the controller's role requirement.
 21/// </summary>
 7622public class CreateAdminCommandHandler(
 7623    IValidator<CreateAdminCommand> validator,
 7624    IAsyncReadOnlyRepository<Person, long> personReader,
 7625    IAsyncRepository<Person, long> personWriter,
 7626    IEmailVerificationService emailVerification)
 27    : ICommandHandlerAsync<CreateAdminCommand, CreatePersonCommandOutput>
 28{
 29    public async Task<DataOutput<CreatePersonCommandOutput?>> HandleAsync(CreateAdminCommand command, CancellationToken 
 30    {
 7631        var output = DataOutput<CreatePersonCommandOutput?>.New;
 32
 33        // AF-06d: validate input shape.
 7634        var validation = await validator.ValidateAsync(command);
 35
 7636        if (!validation.IsValid)
 37        {
 45738            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 39        }
 40
 41        // AF-06a: admin emails are unique system-wide, compared case-insensitively (LOWER() in SQL).
 42        // Trimmed first, here and where it is stored: sign-in and recovery match the stored address
 43        // exactly, case aside, so surrounding whitespace would make it unreachable.
 1044        var address = command.Email.Trim();
 1045        var email = address.ToLower();
 46
 1047        var emailTaken = await personReader.Query().AnyAsync(person =>
 1048            !person.IsDeleted && person.Email.ToLower() == email &&
 1049            (person.RoleId == (long)Roles.SystemAdmin || person.RoleId == (long)Roles.ScopeAdmin));
 50
 1051        if (emailTaken)
 52        {
 453            return output.WithError(PersonMessages.EmailAlreadyExists);
 54        }
 55
 56        // Create the admin person with no SCOPE_OWNER/SCOPE_USER row.
 657        var (passwordHash, salt) = await PasswordHashGate.Shared.EncodeWithRandomSaltAsync(command.Password);
 58
 659        var newPerson = new Person
 660        {
 661            Name = command.Name,
 662            Email = address,
 663            PasswordHash = passwordHash,
 664            Salt = salt,
 665            RoleId = command.Role
 666        };
 67
 68        // NFR-23. No scope: an administrator exists to operate Heimdall itself, which is Heimdall's
 69        // own purpose rather than a tenant's, so the deployment default applies.
 670        LegalBasisRecorder.Record(newPerson, scope: null, DateTime.UtcNow);
 71
 672        var creation = await personWriter.CreateAsync(newPerson);
 73
 674        if (!creation.Success)
 75        {
 076            return output.WithErrors(creation.Errors);
 77        }
 78
 79        // FR-EV-01/02: issue and send the verification token.
 680        await emailVerification.IssueAndSendAsync(newPerson);
 81
 682        return output
 683            .WithData(new CreatePersonCommandOutput
 684            {
 685                Id = newPerson.PublicId,
 686                Name = newPerson.Name,
 687                Email = newPerson.Email,
 688                Role = command.Role,
 689                EmailVerified = newPerson.EmailVerified,
 690                CreatedAt = newPerson.CreatedAt
 691            })
 692            .WithMessage(PersonMessages.PersonCreatedSuccessfully);
 7693    }
 94}