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)); }