elsa-core/test/unit/Elsa.UserTasks.UnitTests/UserTaskTestFixture.cs
Sipke Schoorstra ffff359756
feat(user-tasks): add identity-neutral workflow-bound human tasks (#7955)
Adds durable, identity-neutral, workflow-bound human tasks, and reconciles the
REST surface with the approved Studio contract.

- Flat summary/detail DTOs, a global capability descriptor, and workflow context
  captured at activation.
- Scope is part of the list authorization predicate; manager decisions require
  manage:user-tasks; a denied command answers 404 so it cannot prove a task exists.
- Guest sessions are task-scoped, action-allowlisted, and revoked when the task
  closes. Invitations resolve by token hash through the repository, wait in a
  Data Protection encrypted outbox, and are rate limited per caller.
- Masked form values are disclosed only through an audited reveal command.
- Store-specific concurrency failures are translated into a single
  UserTaskRevisionConflictException, so a concurrent edit returns the documented
  revision-conflict result behind any provider instead of a 500.

EF Core (SQLite, SQL Server, PostgreSQL, MySQL, Oracle) and VNext persistence,
hosted due/reconciliation/delivery workers, docs, and 49 tests.

Note: this branch also carries two commits inherited from its branch point that
are not part of User Tasks and are squashed in here — the revert-version
allocation change from #7917 (WorkflowDefinitionPublisher.RevertVersionAsync now
allocates from the last version rather than the latest) and an NU1903 package
pin. Merged deliberately rather than rebased out.
2026-08-25 00:09:06 +02:00

192 lines
9 KiB
C#

using Elsa.Common;
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Models;
using Elsa.UserTasks.Options;
using Elsa.UserTasks.Repositories;
using Elsa.UserTasks.Services;
using Elsa.Workflows;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
namespace Elsa.UserTasks.UnitTests;
/// <summary>
/// Wires the Core User Tasks slice against in-memory doubles. Every test shares this arrangement so a
/// behavior change surfaces in one place instead of being restated in each test body.
/// </summary>
public sealed class UserTaskTestFixture
{
public const string TenantId = "tenant";
public InMemoryUserTaskRepository Repository { get; } = new();
public TestClock Clock { get; } = new();
public TestIdentityGenerator Identity { get; } = new();
public TestResumer Resumer { get; } = new();
public TestSink Sink { get; } = new();
public CapturingDispatcher Dispatcher { get; } = new();
public DefaultUserTaskAccessPolicy Policy { get; } = new();
public IOptions<UserTasksOptions> Options { get; }
public InMemoryUserTaskGuestSessionIssuer GuestSessions { get; }
public InMemoryUserTaskInvitationOutbox Outbox { get; }
public DefaultUserTaskInvitationService Invitations { get; }
public UserTaskGuestActorResolver GuestActors { get; }
public DefaultUserTaskManager Manager { get; }
public DefaultUserTaskProjectionService Projection { get; }
public UserTaskTestFixture(UserTasksOptions? options = null, IUserTaskInvitationVerifier? verifier = null, params IUserTaskFormProvider[] formProviders)
{
Options = Microsoft.Extensions.Options.Options.Create(options ?? new UserTasksOptions());
GuestSessions = new(Clock, Options);
Outbox = new(new PassthroughDataProtectionProvider(), Clock, Options);
Manager = new(Repository, Policy, formProviders, Resumer, Sink, Identity, Clock, Options);
Projection = new(Manager, Repository, Sink, GuestSessions, Identity, Clock);
Invitations = new(Repository, Policy, Outbox, verifier ?? new DefaultUserTaskInvitationVerifier(), GuestSessions, Sink, Identity, Clock, Options);
GuestActors = new(GuestSessions);
}
public UserTaskActor Actor(string id, params string[] permissions) =>
new(new(TenantId, "oidc", UserTaskParticipantType.User, id), [])
{
Permissions = new HashSet<string>(permissions.Length > 0 ? permissions : ["read:user-tasks", "claim:user-tasks", "complete:user-tasks"], StringComparer.OrdinalIgnoreCase)
};
public UserTaskActor ManagerActor(string id = "manager-1") => Actor(id) with
{
IsManager = true,
Permissions = new HashSet<string>([
"read:user-tasks", "claim:user-tasks", "complete:user-tasks", "assign:user-tasks",
"update:user-tasks", "cancel:user-tasks", "invite:user-tasks", "manage:user-tasks"
], StringComparer.OrdinalIgnoreCase)
};
public UserTaskMaterialization Materialization(ParticipantReference candidate, Func<UserTaskDefinitionSnapshot, UserTaskDefinitionSnapshot>? configure = null)
{
var definition = new UserTaskDefinitionSnapshot
{
Title = "Approval",
CandidateUsers = [candidate],
Instructions = "private instructions",
Actions = [new("Approve", "Approve"), new("Reject", "Reject")]
};
return new(TenantId, "definition", "instance", "activity", "bookmark",
configure?.Invoke(definition) ?? definition, [], [], Clock.UtcNow, "task-1",
"Approval workflow", 3, "correlation-1");
}
/// <summary>Projects a task and returns it, so tests can start from a committed projection in one line.</summary>
public async Task<UserTask> ProjectAsync(ParticipantReference candidate, Func<UserTaskDefinitionSnapshot, UserTaskDefinitionSnapshot>? configure = null) =>
(await Manager.ProjectAsync(Materialization(candidate, configure))).Task;
/// <summary>Drives the guest flow end to end and returns the resolved guest actor.</summary>
public async Task<(UserTaskActor Guest, string Credential)> IssueGuestSessionAsync(UserTask task, UserTaskActor manager, string verifierName = "bearer", params string[] actions)
{
var issued = await Invitations.IssueAsync(TenantId, task.Id, new(task.Revision, verifierName, actions.Length > 0 ? actions : ["Approve"]), manager);
if (issued == null)
throw new InvalidOperationException("The invitation could not be issued.");
await DrainOutboxAsync();
var verified = await Invitations.VerifyAsync(new(Dispatcher.Token!));
if (!verified.Succeeded)
throw new InvalidOperationException("The invitation could not be verified.");
var guest = await GuestActors.ResolveAsync(verified.SessionToken!)
?? throw new InvalidOperationException("The guest session did not resolve.");
return (guest, verified.SessionToken!);
}
/// <summary>Runs the delivery step the hosted worker would normally perform.</summary>
public async Task DrainOutboxAsync()
{
foreach (var delivery in await Outbox.DequeueDueAsync(50))
{
await Dispatcher.DispatchAsync(delivery);
await Outbox.CompleteAsync(delivery.Id);
}
}
public sealed class TestClock : ISystemClock
{
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class TestIdentityGenerator : IIdentityGenerator
{
private int _counter;
public string GenerateId() => $"id-{Interlocked.Increment(ref _counter)}";
}
public sealed class TestResumer : IUserTaskWorkflowResumer
{
public UserTaskStimulus? LastStimulus { get; private set; }
public Task ResumeAsync(UserTask task, UserTaskStimulus stimulus, CancellationToken cancellationToken = default)
{
LastStimulus = stimulus;
return Task.CompletedTask;
}
}
public sealed class TestSink : IUserTaskNotificationSink
{
public List<UserTaskLifecycleNotification> Published { get; } = [];
public Task PublishAsync(UserTaskLifecycleNotification notification, CancellationToken cancellationToken = default)
{
Published.Add(notification);
return Task.CompletedTask;
}
}
public sealed class CapturingDispatcher : IUserTaskInvitationDispatcher
{
public string? Token { get; private set; }
public List<string> Tokens { get; } = [];
public Task DispatchAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default)
{
Token = delivery.Token;
Tokens.Add(delivery.Token);
return Task.CompletedTask;
}
}
/// <summary>Accepts any challenge. Used to exercise the non-bearer verification path.</summary>
public sealed class AcceptingVerifier(string? subject = null) : IUserTaskInvitationVerifier
{
public Task<UserTaskInvitationVerificationResult> VerifyAsync(UserTaskInvitationChallenge challenge, CancellationToken cancellationToken = default) =>
Task.FromResult(new UserTaskInvitationVerificationResult(challenge.Code == "correct", Subject: subject));
}
/// <summary>
/// Data Protection stand-in. The outbox's contract is only that the ciphertext round-trips, so the test
/// double marks the payload rather than pulling the full key-management stack into a unit test.
/// </summary>
private sealed class PassthroughDataProtectionProvider : IDataProtectionProvider, IDataProtector
{
private const string Marker = "protected:";
public IDataProtector CreateProtector(string purpose) => this;
public byte[] Protect(byte[] plaintext) => System.Text.Encoding.UTF8.GetBytes(Marker + Convert.ToBase64String(plaintext));
public byte[] Unprotect(byte[] protectedData)
{
var value = System.Text.Encoding.UTF8.GetString(protectedData);
if (!value.StartsWith(Marker, StringComparison.Ordinal))
throw new System.Security.Cryptography.CryptographicException("The payload was not protected by this provider.");
return Convert.FromBase64String(value[Marker.Length..]);
}
}
}
/// <summary>A form provider that returns a fixed descriptor set, including one masked, revealable field.</summary>
public sealed class TestFormProvider(params UserTaskFormFieldDescriptor[] fields) : IUserTaskFormProvider
{
public string Name => "test";
public Task<ResolvedUserTaskForm?> ResolveAsync(UserTaskFormReference reference, CancellationToken cancellationToken = default) =>
Task.FromResult<ResolvedUserTaskForm?>(new(reference, "v1", new Dictionary<string, object?>()) { Fields = fields });
public Task<UserTaskFormValidationResult> ValidateAndNormalizeAsync(ResolvedUserTaskForm form, string actionKey, System.Text.Json.JsonElement data, CancellationToken cancellationToken = default) =>
Task.FromResult(new UserTaskFormValidationResult(true, data));
}