2026-08-27 10:06:52 +00:00
|
|
|
using Elsa.Authorization;
|
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-24 22:09:06 +00:00
|
|
|
using Elsa.Common;
|
|
|
|
|
using Elsa.UserTasks.Contracts;
|
|
|
|
|
using Elsa.UserTasks.Models;
|
|
|
|
|
using Elsa.UserTasks.Options;
|
2026-08-27 10:06:52 +00:00
|
|
|
using Elsa.UserTasks.Permissions;
|
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-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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 10:06:52 +00:00
|
|
|
/// <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();
|
|
|
|
|
|
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-24 22:09:06 +00:00
|
|
|
public UserTaskActor Actor(string id, params string[] permissions) =>
|
|
|
|
|
new(new(TenantId, "oidc", UserTaskParticipantType.User, id), [])
|
|
|
|
|
{
|
2026-08-27 10:06:52 +00:00
|
|
|
Permissions = new HashSet<string>(
|
|
|
|
|
permissions.Length > 0 ? permissions : [Grant(CoreVerbs.View), Grant(UserTaskVerbs.Claim), Grant(UserTaskVerbs.Complete)],
|
|
|
|
|
StringComparer.Ordinal)
|
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-24 22:09:06 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
public UserTaskActor ManagerActor(string id = "manager-1") => Actor(id) with
|
|
|
|
|
{
|
|
|
|
|
IsManager = true,
|
|
|
|
|
Permissions = new HashSet<string>([
|
2026-08-27 10:06:52 +00:00
|
|
|
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)
|
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-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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 10:06:52 +00:00
|
|
|
/// <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)] };
|
|
|
|
|
|
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-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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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-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));
|
|
|
|
|
}
|