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.
283 lines
13 KiB
C#
283 lines
13 KiB
C#
using System.Text.Json;
|
|
using Elsa.UserTasks.Models;
|
|
using Elsa.UserTasks.Options;
|
|
using Elsa.UserTasks.Services;
|
|
using Xunit;
|
|
|
|
namespace Elsa.UserTasks.UnitTests;
|
|
|
|
/// <summary>
|
|
/// Covers the invitation and guest-session boundary: one-time secrets, generic anonymous responses, rate
|
|
/// limiting, task-scoped guest authorization, and revocation.
|
|
/// </summary>
|
|
public class UserTaskInvitationTests
|
|
{
|
|
private const string Tenant = UserTaskTestFixture.TenantId;
|
|
|
|
private readonly UserTaskTestFixture _fixture = new();
|
|
|
|
private static Func<UserTaskDefinitionSnapshot, UserTaskDefinitionSnapshot> WithBearerInvitation(params string[] actions) =>
|
|
definition => definition with { Invitations = [new("bearer", actions.Length > 0 ? actions : ["Approve"], BearerOnly: true)] };
|
|
|
|
[Fact]
|
|
public async Task Issue_KeepsTheSecretOutOfTheApiResultAndOffTheAuditTrail()
|
|
{
|
|
var manager = _fixture.ManagerActor();
|
|
var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation());
|
|
|
|
var issued = await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(task.Revision, "bearer", ["Approve"]), manager);
|
|
Assert.NotNull(issued);
|
|
|
|
await _fixture.DrainOutboxAsync();
|
|
var token = _fixture.Dispatcher.Token;
|
|
Assert.NotNull(token);
|
|
Assert.DoesNotContain(token, JsonSerializer.Serialize(issued));
|
|
|
|
var stored = await _fixture.Repository.GetAsync(Tenant, task.Id);
|
|
Assert.DoesNotContain(token, JsonSerializer.Serialize(stored!.Events));
|
|
Assert.DoesNotContain(token, JsonSerializer.Serialize(stored.Invitations));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Issue_RefusesToBroadenAnInvitationBeyondTheActivityDefinition()
|
|
{
|
|
var manager = _fixture.ManagerActor();
|
|
var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation("Approve"));
|
|
|
|
// "Reject" is a configured task action but was not part of the materialized invitation definition.
|
|
Assert.Null(await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(task.Revision, "bearer", ["Approve", "Reject"]), manager));
|
|
Assert.Null(await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(task.Revision, "bearer", ["Reject"]), manager));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Issue_RequiresTheInvitePermissionAndManagerRelationship()
|
|
{
|
|
var participant = _fixture.Actor("user-1", "read:user-tasks", "invite:user-tasks");
|
|
var task = await _fixture.ProjectAsync(participant.Subject, WithBearerInvitation());
|
|
|
|
Assert.Null(await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(task.Revision, "bearer", ["Approve"]), participant));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Verify_ConsumesTheWinningInvitationAndRevokesItsSiblings()
|
|
{
|
|
var manager = _fixture.ManagerActor();
|
|
var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation());
|
|
|
|
await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(1, "bearer", ["Approve"]), manager);
|
|
await _fixture.DrainOutboxAsync();
|
|
var first = _fixture.Dispatcher.Token!;
|
|
await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(2, "bearer", ["Approve"]), manager);
|
|
await _fixture.DrainOutboxAsync();
|
|
var second = _fixture.Dispatcher.Token!;
|
|
|
|
var verified = await _fixture.Invitations.VerifyAsync(new(first));
|
|
Assert.True(verified.Succeeded);
|
|
Assert.Equal(task.Id, verified.TaskId);
|
|
Assert.NotNull(verified.SessionToken);
|
|
|
|
// The sibling and a replay of the winner are both rejected, with the same opaque code.
|
|
var sibling = await _fixture.Invitations.VerifyAsync(new(second));
|
|
var replay = await _fixture.Invitations.VerifyAsync(new(first));
|
|
Assert.False(sibling.Succeeded);
|
|
Assert.False(replay.Succeeded);
|
|
Assert.Equal("invitation-unavailable", sibling.FailureCode);
|
|
Assert.Equal(sibling.FailureCode, replay.FailureCode);
|
|
|
|
var claimed = await _fixture.Repository.GetAsync(Tenant, task.Id);
|
|
Assert.Equal(UserTaskStatus.Assigned, claimed!.Status);
|
|
Assert.Equal("guest", claimed.Assignee!.Provider);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Verify_ReturnsTheSameFailureForUnknownExpiredAndWrongCodeInvitations()
|
|
{
|
|
var fixture = new UserTaskTestFixture(verifier: new UserTaskTestFixture.AcceptingVerifier());
|
|
var manager = fixture.ManagerActor();
|
|
var task = await fixture.ProjectAsync(fixture.Actor("user-1").Subject,
|
|
definition => definition with { Invitations = [new("code", ["Approve"])] });
|
|
|
|
await fixture.Invitations.IssueAsync(Tenant, task.Id, new(task.Revision, "code", ["Approve"]), manager);
|
|
await fixture.DrainOutboxAsync();
|
|
var token = fixture.Dispatcher.Token!;
|
|
|
|
var unknown = await fixture.Invitations.VerifyAsync(new("not-a-real-token", "correct"));
|
|
var wrongCode = await fixture.Invitations.VerifyAsync(new(token, "wrong"));
|
|
Assert.False(unknown.Succeeded);
|
|
Assert.False(wrongCode.Succeeded);
|
|
Assert.Equal(unknown.FailureCode, wrongCode.FailureCode);
|
|
Assert.Null(unknown.TaskId);
|
|
Assert.Null(wrongCode.TaskId);
|
|
|
|
fixture.Clock.UtcNow = fixture.Clock.UtcNow.AddDays(30);
|
|
var expired = await fixture.Invitations.VerifyAsync(new(token, "correct"));
|
|
Assert.False(expired.Succeeded);
|
|
Assert.Equal(unknown.FailureCode, expired.FailureCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Describe_ReturnsTheSameShapeForAnUnknownToken()
|
|
{
|
|
var known = await _fixture.Invitations.DescribeAsync("unknown-token-a");
|
|
var other = await _fixture.Invitations.DescribeAsync("unknown-token-b");
|
|
|
|
Assert.Equal(known, other);
|
|
Assert.True(known.RequiresCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RateLimiter_StopsAcceptingOnceTheBudgetForACallerIsSpent()
|
|
{
|
|
var options = Microsoft.Extensions.Options.Options.Create(new UserTasksOptions { AnonymousRateLimit = 3, AnonymousRateLimitWindow = TimeSpan.FromMinutes(5) });
|
|
var limiter = new SlidingWindowUserTaskInvitationRateLimiter(_fixture.Clock, options);
|
|
|
|
Assert.True(await limiter.TryAcquireAsync("10.0.0.1"));
|
|
Assert.True(await limiter.TryAcquireAsync("10.0.0.1"));
|
|
Assert.True(await limiter.TryAcquireAsync("10.0.0.1"));
|
|
Assert.False(await limiter.TryAcquireAsync("10.0.0.1"));
|
|
|
|
// A different caller has its own budget, and the window resets on its own.
|
|
Assert.True(await limiter.TryAcquireAsync("10.0.0.2"));
|
|
_fixture.Clock.UtcNow = _fixture.Clock.UtcNow.AddMinutes(6);
|
|
Assert.True(await limiter.TryAcquireAsync("10.0.0.1"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuestSession_IsScopedToItsOwnTaskAndCannotReachAnother()
|
|
{
|
|
var manager = _fixture.ManagerActor();
|
|
var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation());
|
|
var (guest, _) = await _fixture.IssueGuestSessionAsync(task, manager);
|
|
|
|
var ownDetail = await _fixture.Manager.GetAsync(Tenant, task.Id, guest);
|
|
Assert.NotNull(ownDetail);
|
|
Assert.True(ownDetail.Disclosure.GuestVisible);
|
|
|
|
var other = await _fixture.Manager.ProjectAsync(new(Tenant, "definition-2", "instance-2", "activity-2", "bookmark-2",
|
|
new() { Title = "Other", Actions = [new("Approve", "Approve")] }, [], [], _fixture.Clock.UtcNow, "task-2"));
|
|
Assert.Null(await _fixture.Manager.GetAsync(Tenant, other.Task.Id, guest));
|
|
|
|
var cross = await _fixture.Manager.CompleteAsync(Tenant, other.Task.Id, new(other.Task.Revision, "op-1", "Approve"), guest);
|
|
Assert.False(cross.Accepted);
|
|
Assert.Equal("forbidden", cross.ConflictCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuestProjection_OmitsWorkflowContextParticipantsAndHistory()
|
|
{
|
|
var manager = _fixture.ManagerActor();
|
|
var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation());
|
|
var (guest, _) = await _fixture.IssueGuestSessionAsync(task, manager);
|
|
|
|
var detail = await _fixture.Manager.GetAsync(Tenant, task.Id, guest);
|
|
Assert.NotNull(detail);
|
|
Assert.Null(detail.Workflow);
|
|
Assert.Null(detail.WorkflowInstanceId);
|
|
Assert.Null(detail.Assignee);
|
|
Assert.Null(detail.CandidateSummary);
|
|
Assert.False(detail.Disclosure.CanViewHistory);
|
|
|
|
var events = await _fixture.Manager.GetEventsAsync(Tenant, task.Id, null, 50, guest);
|
|
Assert.NotNull(events);
|
|
Assert.Empty(events.Items);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuestCompletion_IsLimitedToTheActionsItsInvitationWasIssuedFor()
|
|
{
|
|
var manager = _fixture.ManagerActor();
|
|
var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation("Approve"));
|
|
var (guest, _) = await _fixture.IssueGuestSessionAsync(task, manager);
|
|
var current = await _fixture.Repository.GetAsync(Tenant, task.Id);
|
|
|
|
// "Reject" is a valid task action but was never granted to this guest link.
|
|
var rejected = await _fixture.Manager.CompleteAsync(Tenant, task.Id, new(current!.Revision, "op-reject", "Reject"), guest);
|
|
Assert.False(rejected.Accepted);
|
|
Assert.Equal("forbidden", rejected.ConflictCode);
|
|
|
|
var approved = await _fixture.Manager.CompleteAsync(Tenant, task.Id, new(current.Revision, "op-approve", "Approve"), guest);
|
|
Assert.True(approved.Accepted);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(UserTaskAccessOperation.Claim)]
|
|
[InlineData(UserTaskAccessOperation.Release)]
|
|
[InlineData(UserTaskAccessOperation.Assign)]
|
|
[InlineData(UserTaskAccessOperation.UpdateScheduling)]
|
|
[InlineData(UserTaskAccessOperation.Cancel)]
|
|
[InlineData(UserTaskAccessOperation.Manage)]
|
|
[InlineData(UserTaskAccessOperation.IssueInvitation)]
|
|
[InlineData(UserTaskAccessOperation.RetryResolution)]
|
|
public async Task GuestSession_IsDeniedEveryManagementOperation(UserTaskAccessOperation operation)
|
|
{
|
|
var manager = _fixture.ManagerActor();
|
|
var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation());
|
|
var (guest, _) = await _fixture.IssueGuestSessionAsync(task, manager);
|
|
var current = await _fixture.Repository.GetAsync(Tenant, task.Id);
|
|
|
|
Assert.False(await _fixture.Policy.AuthorizeAsync(current!, guest, operation));
|
|
Assert.Null(await _fixture.Policy.CreateScopeAsync(guest, UserTaskQueryScopeKind.Assigned));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuestSession_StopsResolvingOnceTheTaskCloses()
|
|
{
|
|
var manager = _fixture.ManagerActor();
|
|
var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation());
|
|
var (_, credential) = await _fixture.IssueGuestSessionAsync(task, manager);
|
|
Assert.NotNull(await _fixture.GuestActors.ResolveAsync(credential));
|
|
|
|
await _fixture.Projection.FinalizeBookmarkRemovalAsync(new(Tenant, task.Id, task.BookmarkId, _fixture.Clock.UtcNow));
|
|
|
|
Assert.Null(await _fixture.GuestActors.ResolveAsync(credential));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuestSession_ExpiresAtTheHostCeilingEvenWhenTheInvitationLivesLonger()
|
|
{
|
|
var options = new UserTasksOptions { GuestSessionLifetime = TimeSpan.FromMinutes(30), DefaultInvitationLifetime = TimeSpan.FromDays(7) };
|
|
var fixture = new UserTaskTestFixture(options);
|
|
var manager = fixture.ManagerActor();
|
|
var task = await fixture.ProjectAsync(fixture.Actor("user-1").Subject, WithBearerInvitation());
|
|
var (_, credential) = await fixture.IssueGuestSessionAsync(task, manager);
|
|
|
|
fixture.Clock.UtcNow = fixture.Clock.UtcNow.AddMinutes(31);
|
|
|
|
Assert.Null(await fixture.GuestActors.ResolveAsync(credential));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Outbox_RetriesADispatchFailureAndAbandonsItOnceTheScheduleIsExhausted()
|
|
{
|
|
var options = new UserTasksOptions { InvitationDeliveryRetryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2)] };
|
|
var fixture = new UserTaskTestFixture(options);
|
|
var manager = fixture.ManagerActor();
|
|
var task = await fixture.ProjectAsync(fixture.Actor("user-1").Subject, WithBearerInvitation());
|
|
await fixture.Invitations.IssueAsync(Tenant, task.Id, new(task.Revision, "bearer", ["Approve"]), manager);
|
|
|
|
for (var attempt = 0; attempt < 2; attempt++)
|
|
{
|
|
var due = Assert.Single(await fixture.Outbox.DequeueDueAsync(10));
|
|
await fixture.Outbox.RescheduleAsync(due.Id, fixture.Clock.UtcNow);
|
|
}
|
|
|
|
// The schedule is exhausted, so the encrypted secret is dropped rather than retried forever.
|
|
var third = Assert.Single(await fixture.Outbox.DequeueDueAsync(10));
|
|
await fixture.Outbox.RescheduleAsync(third.Id, fixture.Clock.UtcNow);
|
|
Assert.Empty(await fixture.Outbox.DequeueDueAsync(10));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuestActorResolver_ReadsOnlyItsOwnAuthorizationScheme()
|
|
{
|
|
var manager = _fixture.ManagerActor();
|
|
var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation());
|
|
var (_, credential) = await _fixture.IssueGuestSessionAsync(task, manager);
|
|
|
|
Assert.Equal(credential, UserTaskGuestActorResolver.ReadCredential($"UserTaskSession {credential}"));
|
|
Assert.Null(UserTaskGuestActorResolver.ReadCredential($"Bearer {credential}"));
|
|
Assert.Null(UserTaskGuestActorResolver.ReadCredential(null));
|
|
Assert.Null(await _fixture.GuestActors.ResolveAsync("not-a-session"));
|
|
}
|
|
}
|