using Elsa.Authorization;
using Elsa.Common;
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Models;
using Elsa.UserTasks.Options;
using Elsa.UserTasks.Permissions;
using Elsa.UserTasks.Repositories;
using Elsa.UserTasks.Services;
using Elsa.Workflows;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
namespace Elsa.UserTasks.UnitTests;
///
/// 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.
///
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 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);
}
/// A permission on the user-tasks resource, so tests name a verb rather than a string.
public static string Grant(string verb) => new Permission(UserTasksResourcePermissions.UserTasks, verb).ToString();
public UserTaskActor Actor(string id, params string[] permissions) =>
new(new(TenantId, "oidc", UserTaskParticipantType.User, id), [])
{
Permissions = new HashSet(
permissions.Length > 0 ? permissions : [Grant(CoreVerbs.View), Grant(UserTaskVerbs.Claim), Grant(UserTaskVerbs.Complete)],
StringComparer.Ordinal)
};
public UserTaskActor ManagerActor(string id = "manager-1") => Actor(id) with
{
IsManager = true,
Permissions = new HashSet([
Grant(CoreVerbs.View), Grant(UserTaskVerbs.Claim), Grant(UserTaskVerbs.Complete), Grant(UserTaskVerbs.Assign),
Grant(CoreVerbs.Update), Grant(UserTaskVerbs.Cancel), Grant(UserTaskVerbs.Invite), Grant(UserTaskVerbs.Supervise)
], StringComparer.Ordinal)
};
public UserTaskMaterialization Materialization(ParticipantReference candidate, Func? 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");
}
/// A definition carrying a bearer-verified guest invitation, which issuance requires.
public static Func WithBearerInvitation(params string[] actions) =>
definition => definition with { Invitations = [new("bearer", actions.Length > 0 ? actions : ["Approve"], BearerOnly: true)] };
/// Projects a task and returns it, so tests can start from a committed projection in one line.
public async Task ProjectAsync(ParticipantReference candidate, Func? configure = null) =>
(await Manager.ProjectAsync(Materialization(candidate, configure))).Task;
/// Drives the guest flow end to end and returns the resolved guest actor.
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!);
}
/// Runs the delivery step the hosted worker would normally perform.
public async Task DrainOutboxAsync()
{
foreach (var delivery in await Outbox.DequeueDueAsync(50))
{
await Dispatcher.DispatchAsync(delivery);
await Outbox.CompleteAsync(delivery.Id);
}
}
///
/// Wraps a real issuer and fails a configurable number of revocation calls, so tests can drive the
/// cross-store failure path between the invitation aggregate and the session store.
///
public sealed class FaultyRevocationSessionIssuer(IUserTaskGuestSessionIssuer inner, int failures) : IUserTaskGuestSessionIssuer
{
private int _remaining = failures;
public int RevokeCallCount { get; private set; }
public Task IssueAsync(UserTaskInvitation invitation, ParticipantReference subject, CancellationToken cancellationToken = default) =>
inner.IssueAsync(invitation, subject, cancellationToken);
public Task ResolveAsync(string credential, CancellationToken cancellationToken = default) =>
inner.ResolveAsync(credential, cancellationToken);
public Task RevokeForTaskAsync(string tenantId, string taskId, CancellationToken cancellationToken = default) =>
inner.RevokeForTaskAsync(tenantId, taskId, cancellationToken);
public Task RevokeForInvitationAsync(string tenantId, string invitationId, CancellationToken cancellationToken = default)
{
RevokeCallCount++;
if (_remaining-- > 0)
throw new InvalidOperationException("Simulated session-store failure.");
return inner.RevokeForInvitationAsync(tenantId, invitationId, cancellationToken);
}
}
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 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 Tokens { get; } = [];
public Task DispatchAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default)
{
Token = delivery.Token;
Tokens.Add(delivery.Token);
return Task.CompletedTask;
}
}
/// Accepts any challenge. Used to exercise the non-bearer verification path.
public sealed class AcceptingVerifier(string? subject = null) : IUserTaskInvitationVerifier
{
public Task VerifyAsync(UserTaskInvitationChallenge challenge, CancellationToken cancellationToken = default) =>
Task.FromResult(new UserTaskInvitationVerificationResult(challenge.Code == "correct", Subject: subject));
}
///
/// 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.
///
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..]);
}
}
}
/// A form provider that returns a fixed descriptor set, including one masked, revealable field.
public sealed class TestFormProvider(params UserTaskFormFieldDescriptor[] fields) : IUserTaskFormProvider
{
public string Name => "test";
public Task ResolveAsync(UserTaskFormReference reference, CancellationToken cancellationToken = default) =>
Task.FromResult(new(reference, "v1", new Dictionary()) { Fields = fields });
public Task ValidateAndNormalizeAsync(ResolvedUserTaskForm form, string actionKey, System.Text.Json.JsonElement data, CancellationToken cancellationToken = default) =>
Task.FromResult(new UserTaskFormValidationResult(true, data));
}