elsa-core/test/unit/Elsa.UserTasks.UnitTests/UserTaskTestFixture.cs

231 lines
11 KiB
C#
Raw Permalink Normal View History

using Elsa.Authorization;
2026-08-24 22:09:06 +00:00
using Elsa.Common;
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Models;
using Elsa.UserTasks.Options;
using Elsa.UserTasks.Permissions;
2026-08-24 22:09:06 +00:00
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);
}
/// <summary>A permission on the <c>user-tasks</c> resource, so tests name a verb rather than a string.</summary>
public static string Grant(string verb) => new Permission(UserTasksResourcePermissions.UserTasks, verb).ToString();
2026-08-24 22:09:06 +00:00
public UserTaskActor Actor(string id, params string[] permissions) =>
new(new(TenantId, "oidc", UserTaskParticipantType.User, id), [])
{
Permissions = new HashSet<string>(
permissions.Length > 0 ? permissions : [Grant(CoreVerbs.View), Grant(UserTaskVerbs.Claim), Grant(UserTaskVerbs.Complete)],
StringComparer.Ordinal)
2026-08-24 22:09:06 +00:00
};
public UserTaskActor ManagerActor(string id = "manager-1") => Actor(id) with
{
IsManager = true,
Permissions = new HashSet<string>([
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)
2026-08-24 22:09:06 +00:00
};
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>A definition carrying a bearer-verified guest invitation, which issuance requires.</summary>
public static Func<UserTaskDefinitionSnapshot, UserTaskDefinitionSnapshot> WithBearerInvitation(params string[] actions) =>
definition => definition with { Invitations = [new("bearer", actions.Length > 0 ? actions : ["Approve"], BearerOnly: true)] };
2026-08-24 22:09:06 +00:00
/// <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);
}
}
fix(user-tasks): let managers revoke a consumed guest invitation (#7984) * fix(user-tasks): let managers revoke a consumed guest invitation Verification marks the winning invitation Consumed, which is what issues the guest session — but RevokeAsync rejected Consumed and never touched sessions at all. A manager therefore could not withdraw a live guest credential: it stayed authorized until its TTL elapsed or the task closed. The invitations contract specifies a revocable, task-scoped session, so this was a real gap. RevokeAsync now accepts a consumed invitation, rejecting only the already terminal Revoked and Expired states, and revokes the sessions that invitation issued. Revocation is scoped to one invitation rather than the whole task, so other guests keep working: UserTaskGuestSession carries its InvitationId and IUserTaskGuestSessionIssuer gains RevokeForInvitationAsync, implemented for both the in-memory and EF Core stores. Reassignment already cut a guest off, because the policy requires the guest to still be the assignee. That remains the recovery path for abandoned guest work; this restores the documented direct revocation alongside it. Adds three tests. The first fails against the previous behavior. Reported by Greptile on #7955. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(user-tasks): make guest-session revocation fail closed and retryable Greptile review of the previous commit found three real problems with it. Revocation committed the invitation as Revoked before revoking its sessions, so a session-store failure left a live credential behind a guard that rejected the retry. Sessions are now swept before the terminal state is committed: a failure commits nothing, leaves the invitation revocable, and a retry repairs it. A retry against an already-revoked invitation is idempotently successful and re-runs the sweep, so a caller repairing a partial failure is never told no. Verification could also hand back a credential that outlived a concurrent revoke: the manager's sweep ran before the session reached the store and found nothing. VerifyAsync now re-reads the committed invitation after issuing and withdraws the credential unless it is still the consumed one it verified. Invitation-scoped revocation queried an unindexed column, so every revoke scanned a growing tenant partition of retained session rows. Adds the (TenantId, InvitationId) index to the EF model and migration, and advertises the same index from the VNext schema provider. Adds three tests covering the injected store failure, the idempotent retry, and the revoke-during-verify race. RevokingAnAlreadyRevokedInvitationIsRefused asserted the behavior this commit deliberately changes, so it is repurposed to cover the refusal that remains: an unknown invitation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(user-tasks): sweep guest sessions on both sides of the revoke commit Moving the sweep before the commit closed the fail-open failure path but opened its mirror: a concurrent verification can issue a session after the sweep, still read Consumed at its settled-state check because the revoke has not committed yet, and hand back a credential that outlives a successful revoke. Revocation now sweeps after the commit as well. Anything issued in that window is caught by the second sweep, and any verification that issues after the commit sees the revoked state at its own settled-state check and withdraws its own credential. The first sweep still runs before the commit, so a session-store failure commits nothing and stays retryable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 00:38:12 +00:00
/// <summary>
/// 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.
/// </summary>
public sealed class FaultyRevocationSessionIssuer(IUserTaskGuestSessionIssuer inner, int failures) : IUserTaskGuestSessionIssuer
{
private int _remaining = failures;
public int RevokeCallCount { get; private set; }
public Task<GuestSessionResult> IssueAsync(UserTaskInvitation invitation, ParticipantReference subject, CancellationToken cancellationToken = default) =>
inner.IssueAsync(invitation, subject, cancellationToken);
public Task<UserTaskGuestSession?> 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);
}
}
2026-08-24 22:09:06 +00:00
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));
}