< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.CreateScopeOwnerCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/CreateScopeOwnerCommandHandler.cs
Line coverage
96%
Covered lines: 50
Uncovered lines: 2
Coverable lines: 52
Total lines: 114
Line coverage: 96.1%
Branch coverage
80%
Covered branches: 8
Total branches: 10
Branch coverage: 80%
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()80%101097.72%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/CreateScopeOwnerCommandHandler.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.Heimdall.Shared.Services;
 9using ArturRios.Mediator.Command.Interfaces;
 10using ArturRios.Output;
 11using ArturRios.Util.Hashing;
 12using FluentValidation;
 13using Microsoft.EntityFrameworkCore;
 14
 15namespace ArturRios.Heimdall.Command.Handlers;
 16
 17/// <summary>
 18///     Handles <see cref="CreateScopeOwnerCommand" /> (UC-06 path c, FR-SC-12): validates input,
 19///     verifies the target scope exists and is active (AF-06b), enforces scope ownership for a Scope
 20///     Admin actor (AF-06e), checks the email is unique among admin persons system-wide (AF-06a), then
 21///     creates a <c>ScopeAdmin</c> with a <c>SCOPE_OWNER</c> row making them a co-owner, and issues a
 22///     verification token. A System Admin actor bypasses the ownership check.
 23/// </summary>
 1124public class CreateScopeOwnerCommandHandler(
 1125    IValidator<CreateScopeOwnerCommand> validator,
 1126    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 1127    IAsyncReadOnlyRepository<Person, long> personReader,
 1128    IAsyncRepository<Person, long> personWriter,
 1129    IScopeOwnershipChecker scopeOwnership,
 1130    IEmailVerificationService emailVerification)
 31    : ICommandHandlerAsync<CreateScopeOwnerCommand, CreatePersonCommandOutput>
 32{
 33    public async Task<DataOutput<CreatePersonCommandOutput?>> HandleAsync(CreateScopeOwnerCommand command, CancellationT
 34    {
 1135        var output = DataOutput<CreatePersonCommandOutput?>.New;
 36
 37        // AF-06d: validate input shape.
 1138        var validation = await validator.ValidateAsync(command);
 39
 1140        if (!validation.IsValid)
 41        {
 042            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 43        }
 44
 45        // AF-06b: the target scope must exist and not be logically deleted.
 1146        var scope = await scopeReader.Query()
 1147            .FirstOrDefaultAsync(x => x.PublicId == command.ScopeId && !x.IsDeleted);
 48
 1149        if (scope is null)
 50        {
 251            return output.WithError(PersonMessages.ScopeNotFound);
 52        }
 53
 54        // AF-06e: a Scope Admin actor may only act on a scope they own; a System Admin bypasses.
 955        if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id))
 56        {
 257            return output.WithError(PersonMessages.NotScopeOwner);
 58        }
 59
 60        // AF-06a: admin emails are unique system-wide, compared case-insensitively (LOWER() in SQL).
 61        // Trimmed first, here and where it is stored: sign-in and recovery match the stored address
 62        // exactly, case aside, so surrounding whitespace would make it unreachable.
 763        var address = command.Email.Trim();
 764        var email = address.ToLower();
 65
 766        var emailTaken = await personReader.Query().AnyAsync(person =>
 767            !person.IsDeleted && person.Email.ToLower() == email &&
 768            (person.RoleId == (long)Roles.SystemAdmin || person.RoleId == (long)Roles.ScopeAdmin));
 69
 770        if (emailTaken)
 71        {
 372            return output.WithError(PersonMessages.EmailAlreadyExists);
 73        }
 74
 75        // Create the ScopeAdmin with a SCOPE_OWNER row linking them to the scope as a co-owner.
 476        var (passwordHash, salt) = await PasswordHashGate.Shared.EncodeWithRandomSaltAsync(command.Password);
 77
 478        var newPerson = new Person
 479        {
 480            Name = command.Name,
 481            Email = address,
 482            PasswordHash = passwordHash,
 483            Salt = salt,
 484            RoleId = (long)Roles.ScopeAdmin,
 485            ScopeOwnerships = [new ScopeOwner { ScopeId = scope.Id }]
 486        };
 87
 88        // NFR-23: recorded against the scope they will own.
 489        LegalBasisRecorder.Record(newPerson, scope, DateTime.UtcNow);
 90
 491        var creation = await personWriter.CreateAsync(newPerson);
 92
 493        if (!creation.Success)
 94        {
 095            return output.WithErrors(creation.Errors);
 96        }
 97
 98        // FR-EV-01/02: issue and send the verification token.
 499        await emailVerification.IssueAndSendAsync(newPerson);
 100
 4101        return output
 4102            .WithData(new CreatePersonCommandOutput
 4103            {
 4104                Id = newPerson.PublicId,
 4105                Name = newPerson.Name,
 4106                Email = newPerson.Email,
 4107                Role = (int)Roles.ScopeAdmin,
 4108                EmailVerified = newPerson.EmailVerified,
 4109                ScopeId = scope.PublicId,
 4110                CreatedAt = newPerson.CreatedAt
 4111            })
 4112            .WithMessage(PersonMessages.PersonCreatedSuccessfully);
 11113    }
 114}