< Summary

Information
Class: Elsa.UserTasks.Services.UserTaskModelMapper
Assembly: Elsa.UserTasks
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Services/UserTaskModelMapper.cs
Line coverage
91%
Covered lines: 148
Uncovered lines: 14
Coverable lines: 162
Total lines: 212
Line coverage: 91.3%
Branch coverage
72%
Covered branches: 67
Total branches: 92
Branch coverage: 72.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
ActionName(...)30%231050%
ToSummaryAsync()82.14%2828100%
ToDetailAsync()100%1616100%
ToCapabilitiesAsync()0%620%
ToEventSummary(...)100%22100%
ToWorkflowContext(...)100%11100%
ToFormProjection(...)100%88100%
ReadFieldValue(...)50%66100%
DescribeCandidates(...)56.25%191678.57%
NullIfEmpty(...)50%22100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.UserTasks/Services/UserTaskModelMapper.cs

#LineLine coverage
 1using System.Text.Json;
 2using Elsa.UserTasks.Contracts;
 3using Elsa.UserTasks.Models;
 4
 5namespace Elsa.UserTasks.Services;
 6
 7/// <summary>
 8/// Projects the internal aggregate onto the wire contract. Every protected value passes through an explicit
 9/// policy decision here, so an endpoint can never widen disclosure by choosing a different response type.
 10/// </summary>
 11public static class UserTaskModelMapper
 12{
 113    private static readonly UserTaskAccessOperation[] ActionOperations =
 114    [
 115        UserTaskAccessOperation.Claim,
 116        UserTaskAccessOperation.Release,
 117        UserTaskAccessOperation.Assign,
 118        UserTaskAccessOperation.UpdateScheduling,
 119        UserTaskAccessOperation.Complete,
 120        UserTaskAccessOperation.Cancel,
 121        UserTaskAccessOperation.IssueInvitation,
 122        UserTaskAccessOperation.RetryResolution
 123    ];
 24
 1225    public static string ActionName(UserTaskAccessOperation operation) => operation switch
 1226    {
 427        UserTaskAccessOperation.Claim => "claim",
 328        UserTaskAccessOperation.Release => "release",
 029        UserTaskAccessOperation.Assign => "assign",
 030        UserTaskAccessOperation.UpdateScheduling => "update-scheduling",
 531        UserTaskAccessOperation.Complete => "complete",
 032        UserTaskAccessOperation.Cancel => "cancel",
 033        UserTaskAccessOperation.IssueInvitation => "invite",
 034        UserTaskAccessOperation.RetryResolution => "retry-resolution",
 035        _ => operation.ToString().ToLowerInvariant()
 1236    };
 37
 38    public static async Task<UserTaskSummary> ToSummaryAsync(UserTask task, UserTaskActor actor, IUserTaskAccessPolicy p
 39    {
 940        var allowed = new List<string>();
 16241        foreach (var operation in ActionOperations)
 42        {
 7243            if (await policy.AuthorizeAsync(task, actor, operation, cancellationToken))
 1244                allowed.Add(ActionName(operation));
 45        }
 46
 47        // Blocking health is an operator signal. Surfacing it to an ordinary participant would leak that a
 48        // directory or form provider failed, so it is folded away unless the caller manages the tenant.
 949        var healthVisible = actor.IsManager && !actor.IsGuest;
 950        var workflowVisible = !actor.IsGuest;
 951        return new UserTaskSummary
 952        {
 953            Id = task.Id,
 954            Title = task.Title,
 955            Summary = task.Summary,
 956            Reference = task.Reference,
 957            Tags = task.Tags.ToArray(),
 958            TaskType = task.TaskType,
 959            Status = task.Status.ToString(),
 960            Priority = task.Priority,
 961            Assignee = actor.IsGuest ? null : UserTaskParticipantSummary.From(task.Assignee),
 962            CandidateSummary = actor.IsGuest ? null : DescribeCandidates(task),
 963            DueAt = task.DueAt,
 964            IsOverdue = task.IsOverdue,
 965            CreatedAt = task.CreatedAt,
 966            UpdatedAt = task.UpdatedAt,
 967            AssignedAt = actor.IsGuest ? null : task.AssignedAt,
 968            CompletedAt = task.CompletedAt,
 969            WorkflowDefinitionId = workflowVisible ? NullIfEmpty(task.WorkflowDefinitionId) : null,
 970            WorkflowDefinitionName = workflowVisible ? task.WorkflowDefinitionName : null,
 971            WorkflowDefinitionVersion = workflowVisible ? task.WorkflowDefinitionVersion : null,
 972            WorkflowInstanceId = workflowVisible ? NullIfEmpty(task.WorkflowInstanceId) : null,
 973            WorkflowInstanceReference = workflowVisible ? task.WorkflowInstanceReference : null,
 974            HealthSeverity = healthVisible ? task.HealthSeverity?.ToString() : null,
 975            HealthCode = healthVisible ? task.HealthCode : null,
 976            AllowedActions = allowed,
 977            Revision = task.Revision
 978        };
 979    }
 80
 81    public static async Task<UserTaskDetail> ToDetailAsync(UserTask task, UserTaskActor actor, IUserTaskAccessPolicy pol
 82    {
 583        var summary = await ToSummaryAsync(task, actor, policy, cancellationToken);
 584        var canReadProtected = await policy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadProtected, cancellat
 585        var canViewHistory = !actor.IsGuest && (actor.IsManager || canReadProtected);
 586        var disclosure = new UserTaskDisclosure
 587        {
 588            CanViewProtected = canReadProtected,
 589            CanViewWorkflow = !actor.IsGuest,
 590            CanViewHistory = canViewHistory,
 591            GuestVisible = actor.IsGuest
 592        };
 93
 94        // Guests may only complete the action keys their invitation was issued for, so the action list they
 95        // receive is intersected with that allowlist rather than showing the workflow's full action set.
 596        var actions = task.Actions
 1097            .Where(action => !actor.IsGuest || actor.GuestAllowedActions.Contains(action.Key))
 898            .Select(action => new UserTaskFormAction(action.Key, action.Label))
 599            .ToArray();
 100
 5101        return new UserTaskDetail
 5102        {
 5103            Id = summary.Id,
 5104            Title = summary.Title,
 5105            Summary = summary.Summary,
 5106            Reference = summary.Reference,
 5107            Tags = summary.Tags,
 5108            TaskType = summary.TaskType,
 5109            Status = summary.Status,
 5110            Priority = summary.Priority,
 5111            Assignee = summary.Assignee,
 5112            CandidateSummary = summary.CandidateSummary,
 5113            DueAt = summary.DueAt,
 5114            IsOverdue = summary.IsOverdue,
 5115            CreatedAt = summary.CreatedAt,
 5116            UpdatedAt = summary.UpdatedAt,
 5117            AssignedAt = summary.AssignedAt,
 5118            CompletedAt = summary.CompletedAt,
 5119            WorkflowDefinitionId = summary.WorkflowDefinitionId,
 5120            WorkflowDefinitionName = summary.WorkflowDefinitionName,
 5121            WorkflowDefinitionVersion = summary.WorkflowDefinitionVersion,
 5122            WorkflowInstanceId = summary.WorkflowInstanceId,
 5123            WorkflowInstanceReference = summary.WorkflowInstanceReference,
 5124            HealthSeverity = summary.HealthSeverity,
 5125            HealthCode = summary.HealthCode,
 5126            AllowedActions = summary.AllowedActions,
 5127            Revision = summary.Revision,
 5128            Instructions = canReadProtected ? task.Instructions : null,
 5129            Data = canReadProtected ? task.TaskData : null,
 5130            Disclosure = disclosure,
 5131            Workflow = disclosure.CanViewWorkflow ? ToWorkflowContext(task) : null,
 5132            Form = ToFormProjection(task, actions, canReadProtected),
 5133            Actions = actions,
 5134            Outcome = canReadProtected ? task.CompletionActionKey : null,
 5135            Response = canReadProtected ? task.CompletionData : null,
 5136            CompletedBy = canReadProtected && !actor.IsGuest ? UserTaskParticipantSummary.From(task.CompletedBy) : null
 5137        };
 5138    }
 139
 140    public static async Task<UserTaskCapabilities> ToCapabilitiesAsync(UserTask task, UserTaskActor actor, IUserTaskAcce
 141    {
 0142        var summary = await ToSummaryAsync(task, actor, policy, cancellationToken);
 0143        return new UserTaskCapabilities(task.Id, task.Revision, summary.AllowedActions,
 0144            await policy.AuthorizeAsync(task, actor, UserTaskAccessOperation.ReadProtected, cancellationToken),
 0145            actor.IsManager && !actor.IsGuest);
 0146    }
 147
 148    public static UserTaskEventSummary ToEventSummary(UserTaskEvent @event) =>
 2149        new(@event.Id, @event.EventType, @event.Reason, @event.OccurredAt, @event.Actor?.DisplayName);
 150
 3151    private static UserTaskWorkflowContext ToWorkflowContext(UserTask task) => new()
 3152    {
 3153        DefinitionId = NullIfEmpty(task.WorkflowDefinitionId),
 3154        DefinitionName = task.WorkflowDefinitionName,
 3155        DefinitionVersion = task.WorkflowDefinitionVersion,
 3156        InstanceId = NullIfEmpty(task.WorkflowInstanceId),
 3157        InstanceReference = task.WorkflowInstanceReference
 3158    };
 159
 160    private static UserTaskFormProjection? ToFormProjection(UserTask task, IReadOnlyCollection<UserTaskFormAction> actio
 161    {
 5162        if (task.PinnedForm is not { } form)
 4163            return null;
 164
 4165        var fields = form.Fields.Select(descriptor => new UserTaskFormField
 4166        {
 4167            Key = descriptor.Key,
 4168            Label = descriptor.Label,
 4169            Type = descriptor.Type,
 4170            Required = descriptor.Required,
 4171            Masked = descriptor.Masked,
 4172            CanReveal = descriptor.Masked && descriptor.CanReveal && canReadProtected,
 4173            // A masked value never rides along with the form. It is disclosed only through the explicit,
 4174            // audited reveal command, so an accidental log or screenshot of the detail response is inert.
 4175            Value = canReadProtected && !descriptor.Masked ? ReadFieldValue(task.TaskData, descriptor.Key) : null
 4176        }).ToArray();
 177
 1178        return new UserTaskFormProjection
 1179        {
 1180            Provider = form.Requested.ProviderName,
 1181            Key = form.Requested.Key,
 1182            Version = form.PinnedVersion,
 1183            Fields = fields,
 1184            Actions = actions
 1185        };
 186    }
 187
 188    internal static JsonElement? ReadFieldValue(JsonElement? data, string key) =>
 3189        data is { ValueKind: JsonValueKind.Object } element && element.TryGetProperty(key, out var value) ? value.Clone(
 190
 191    private static string? DescribeCandidates(UserTask task)
 192    {
 7193        var users = task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot
 0194            ? task.SnapshotMembers.Count(x => x.Type == UserTaskParticipantType.User)
 7195            : task.CandidateUsers.Count;
 7196        var groups = task.MembershipResolutionMode == UserTaskMembershipResolutionMode.Snapshot
 7197            ? task.SnapshotGroups.Count
 7198            : task.CandidateGroups.Count;
 7199        if (users == 0 && groups == 0)
 0200            return null;
 201
 202        // Counts only: disclosing which peers are eligible would let any candidate enumerate the others.
 7203        var parts = new List<string>(2);
 7204        if (users > 0)
 7205            parts.Add(users == 1 ? "1 user" : $"{users} users");
 7206        if (groups > 0)
 0207            parts.Add(groups == 1 ? "1 group" : $"{groups} groups");
 7208        return string.Join(", ", parts);
 209    }
 210
 20211    private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
 212}