< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.HardDeleteScopeCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/HardDeleteScopeCommandHandler.cs
Line coverage
98%
Covered lines: 63
Uncovered lines: 1
Coverable lines: 64
Total lines: 158
Line coverage: 98.4%
Branch coverage
87%
Covered branches: 7
Total branches: 8
Branch coverage: 87.5%
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()87.5%8897.67%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Application/ArturRios.Heimdall.Command/Handlers/HardDeleteScopeCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Input;
 3using ArturRios.Heimdall.Command.Output;
 4using ArturRios.Heimdall.Domain.Entities;
 5using ArturRios.Heimdall.Domain.Enums;
 6using ArturRios.Heimdall.Domain.Persistence;
 7using ArturRios.Heimdall.Shared.Messages;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using Microsoft.EntityFrameworkCore;
 11
 12namespace ArturRios.Heimdall.Command.Handlers;
 13
 14/// <summary>
 15///     Handles <see cref="HardDeleteScopeCommand" /> (UC-05): locates the scope (AF-05a), then
 16///     permanently deletes its Users (via <c>SCOPE_USER</c>), Google Users, and applications, and
 17///     finally the scope itself — whose <c>ON DELETE CASCADE</c> foreign keys remove the
 18///     <c>SCOPE_OWNER</c>/<c>SCOPE_USER</c> join rows and any remaining scope permissions. Owner
 19///     person records (<c>ScopeAdmin</c>s) are never removed. The response reports the totals of
 20///     the scope's members and permissions, counted regardless of their individual deletion state.
 21///     A failure is returned as errors on the <see cref="DataOutput{T}" /> or, from a set-based
 22///     statement, thrown; either way nothing is kept.
 23/// </summary>
 24/// <remarks>
 25///     <para>
 26///         Before anything is removed, the <c>PublicId</c> of every member person and Google User about
 27///         to go is tombstoned (<see cref="ErasedSubject" />, NFR-21, #195), one per row, in one write.
 28///         Audit entries name their target by that bare id, so without the tombstone nothing would say,
 29///         once the rows are gone, that those entries name people whose targets must be cleared. The
 30///         scope's own id is not tombstoned: a scope is not a person, and its entries stay as recorded.
 31///         A retry writes them again without error.
 32///     </para>
 33///     <para>
 34///         <b>All or nothing</b> (#212). The tombstones, the members and the scope are removed in one
 35///         <see cref="IWriteTransactions" /> transaction, committed only once the scope is gone, so a
 36///         failure at any step leaves the scope with every member it had, and no tombstone for a live
 37///         row.
 38///     </para>
 39///     <para>
 40///         <b>Only the ids</b> (#204). The members are never loaded: their ids and public ids are
 41///         projected, which is all the tombstones and the deletes need, and
 42///         <see cref="IScopeCascades.DeleteMembersAsync" /> removes them by id a bounded batch per statement,
 43///         inside the same transaction, so a large tenant neither fills the request's memory with whole
 44///         rows — password hashes included — nor sends one statement per member.
 45///     </para>
 46///     <para>
 47///         <b>Under the scope's lock</b> (#202). The transaction first locks the scope's row
 48///         (<see cref="IInvariantLocks" />), which every writer that can take an owner out of the scope
 49///         also holds, and reads the scope again under it. So the deletion and an owner change of the same
 50///         scope run one after the other: an owner removal that follows finds the scope gone (AF-22a),
 51///         and the deletion never acts on a scope another request removed after it was read.
 52///     </para>
 53/// </remarks>
 1454public class HardDeleteScopeCommandHandler(
 1455    IAsyncReadOnlyRepository<Scope, long> scopeReader,
 1456    IAsyncRepository<Scope, long> scopeWriter,
 1457    IAsyncReadOnlyRepository<Person, long> personReader,
 1458    IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader,
 1459    IAsyncReadOnlyRepository<Application, long> applicationReader,
 1460    IAsyncReadOnlyRepository<ScopePermission, long> scopePermissionReader,
 1461    IErasedSubjects erasedSubjects,
 1462    IScopeCascades scopeCascades,
 1463    IWriteTransactions transactions,
 1464    IInvariantLocks locks)
 65    : ICommandHandlerAsync<HardDeleteScopeCommand, HardDeleteScopeCommandOutput>
 66{
 67    public async Task<DataOutput<HardDeleteScopeCommandOutput?>> HandleAsync(HardDeleteScopeCommand command, Cancellatio
 68    {
 1469        var output = DataOutput<HardDeleteScopeCommandOutput?>.New;
 70
 71        // Step 2 (AF-05a): locate the scope in ANY deletion state — an already logically-deleted
 72        // scope can still be hard-deleted.
 1473        var scope = await scopeReader.Query().FirstOrDefaultAsync(x => x.PublicId == command.Id);
 74
 1475        if (scope is null)
 76        {
 277            return output.WithError(ScopeMessages.ScopeNotFound);
 78        }
 79
 80        // Steps 3-6 land together or not at all (#212): leaving this block by any return or exception
 81        // before the commit rolls every one of them back.
 1282        await using var transaction = await transactions.BeginAsync();
 1283        await locks.LockAsync([scope.Id]);
 84
 85        // Read again under the lock: a hard delete that committed since the read above answers AF-05a.
 1286        if (!await scopeReader.Query().AnyAsync(x => x.Id == scope.Id))
 87        {
 088            return output.WithError(ScopeMessages.ScopeNotFound);
 89        }
 90
 91        // The scope's members, regardless of individual deletion state: the rows steps 3 and 4 act on,
 92        // and the totals step 7 reports. Only the ids are read (#204).
 1293        var users = await personReader.Query()
 1294            .Where(p => p.ScopeMembership != null && p.ScopeMembership.ScopeId == scope.Id)
 1295            .Select(p => new { p.Id, p.PublicId })
 1296            .ToListAsync();
 1297        var googleUsers = await googleUserReader.Query()
 1298            .Where(g => g.ScopeId == scope.Id)
 1299            .Select(g => new { g.Id, g.PublicId })
 12100            .ToListAsync();
 12101        var applicationIds = await applicationReader.Query()
 12102            .Where(a => a.ScopeId == scope.Id)
 12103            .Select(a => a.Id)
 12104            .ToListAsync();
 12105        var scopePermissionCount = await scopePermissionReader.Query()
 12106            .CountAsync(p => p.ScopeId == scope.Id);
 107
 108        // Step 3 (NFR-21, #195): tombstone exactly the persons and Google Users step 4 removes,
 109        // before any of it happens. Owners are not members and are not removed, so they are not
 110        // tombstoned.
 12111        var erased = users
 5009112            .Select(user => new ErasedSubject
 5009113            {
 5009114                Id = user.PublicId, Kind = (int)ErasedSubjectKinds.Person, CreatedAt = DateTime.UtcNow
 5009115            })
 5116            .Concat(googleUsers.Select(googleUser => new ErasedSubject
 5117            {
 5118                Id = googleUser.PublicId, Kind = (int)ErasedSubjectKinds.GoogleUser, CreatedAt = DateTime.UtcNow
 5119            }))
 12120            .ToList();
 121
 12122        if (erased.Count > 0)
 123        {
 7124            await erasedSubjects.RecordAsync(erased);
 125        }
 126
 127        // Step 4: delete the members explicitly, in an order that never violates a foreign key
 128        // (applications reference their owning person, so they go first), in batches.
 12129        await scopeCascades.DeleteMembersAsync(new ScopeMemberIds(
 12130            applicationIds,
 5131            googleUsers.Select(googleUser => googleUser.Id).ToList(),
 5021132            users.Select(user => user.Id).ToList()));
 133
 134        // Steps 5-6: delete the scope; its ON DELETE CASCADE foreign keys clear the SCOPE_OWNER and any
 135        // remaining SCOPE_USER join rows, as well as any remaining scope permissions. Owner person
 136        // records are untouched.
 12137        var scopeDelete = await scopeWriter.DeleteAsync(scope);
 138
 12139        if (!scopeDelete.Success)
 140        {
 1141            return output.WithErrors(scopeDelete.Errors);
 142        }
 143
 11144        await transaction.CommitAsync();
 145
 146        // Step 7: return the scope id and the member/permission totals.
 11147        return output
 11148            .WithData(new HardDeleteScopeCommandOutput
 11149            {
 11150                Id = scope.PublicId,
 11151                UserCount = users.Count,
 11152                GoogleUserCount = googleUsers.Count,
 11153                ApplicationCount = applicationIds.Count,
 11154                ScopePermissionCount = scopePermissionCount
 11155            })
 11156            .WithMessage(ScopeMessages.ScopeHardDeletedSuccessfully);
 14157    }
 158}