using System.Text.Json; using Elsa.Authorization; using Elsa.UserTasks.Contracts; using Elsa.UserTasks.Models; using Elsa.UserTasks.Options; using Elsa.UserTasks.Permissions; using Elsa.UserTasks.Services; using Xunit; namespace Elsa.UserTasks.UnitTests; /// /// Covers the invitation and guest-session boundary: one-time secrets, generic anonymous responses, rate /// limiting, task-scoped guest authorization, and revocation. /// public class UserTaskInvitationTests { private const string Tenant = UserTaskTestFixture.TenantId; private readonly UserTaskTestFixture _fixture = new(); private static Func WithBearerInvitation(params string[] actions) => UserTaskTestFixture.WithBearerInvitation(actions); [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", UserTaskTestFixture.Grant(CoreVerbs.View), UserTaskTestFixture.Grant(UserTaskVerbs.Invite)); 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 RevokingAConsumedInvitationWithdrawsTheGuestSessionItIssued() { var manager = _fixture.ManagerActor(); var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation()); var (_, credential) = await _fixture.IssueGuestSessionAsync(task, manager); var current = (await _fixture.Repository.GetAsync(Tenant, task.Id))!; var invitation = Assert.Single(current.Invitations); // Verification consumed it; that is exactly the state a manager needs to be able to revoke. Assert.Equal(UserTaskInvitationStatus.Consumed, invitation.Status); Assert.NotNull(await _fixture.GuestActors.ResolveAsync(credential)); Assert.True(await _fixture.Invitations.RevokeAsync(Tenant, task.Id, invitation.Id, current.Revision, manager)); // The credential must stop working immediately rather than living out its TTL. Assert.Null(await _fixture.GuestActors.ResolveAsync(credential)); var revoked = Assert.Single((await _fixture.Repository.GetAsync(Tenant, task.Id))!.Invitations); Assert.Equal(UserTaskInvitationStatus.Revoked, revoked.Status); Assert.NotNull(revoked.RevokedAt); // Asserted at the credential, which is the actual boundary: a guest actor exists only because the // resolver produced one from a live session, so once the credential is dead no guest principal can // be formed and the request is rejected before it reaches the manager. Assert.Null(await _fixture.GuestActors.ResolveAsync(credential)); } [Fact] public async Task RevokingOneInvitationLeavesOtherGuestSessionsOnTheSameTaskIntact() { var manager = _fixture.ManagerActor(); var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, definition => definition with { Invitations = [ new("bearer-a", ["Approve"], BearerOnly: true), new("bearer-b", ["Approve"], BearerOnly: true) ] }); // A is verified, so it holds a live session. B is issued but never verified. var (_, credentialA) = await _fixture.IssueGuestSessionAsync(task, manager, "bearer-a"); var afterA = (await _fixture.Repository.GetAsync(Tenant, task.Id))!; await _fixture.Invitations.IssueAsync(Tenant, task.Id, new(afterA.Revision, "bearer-b", ["Approve"]), manager); var beforeRevoke = (await _fixture.Repository.GetAsync(Tenant, task.Id))!; var invitationB = beforeRevoke.Invitations.Single(x => x.VerifierName == "bearer-b"); Assert.True(await _fixture.Invitations.RevokeAsync(Tenant, task.Id, invitationB.Id, beforeRevoke.Revision, manager)); // Revocation is scoped to the invitation, so A's session survives B being withdrawn. Assert.NotNull(await _fixture.GuestActors.ResolveAsync(credentialA)); var after = (await _fixture.Repository.GetAsync(Tenant, task.Id))!; Assert.Equal(UserTaskInvitationStatus.Revoked, after.Invitations.Single(x => x.VerifierName == "bearer-b").Status); Assert.Equal(UserTaskInvitationStatus.Consumed, after.Invitations.Single(x => x.VerifierName == "bearer-a").Status); } [Fact] public async Task ARevocationThatFailsInTheSessionStoreLeavesTheInvitationRetryable() { var manager = _fixture.ManagerActor(); var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation()); var (_, credential) = await _fixture.IssueGuestSessionAsync(task, manager); // One injected failure in the session store, then it recovers. var faulty = new UserTaskTestFixture.FaultyRevocationSessionIssuer(_fixture.GuestSessions, failures: 1); var invitations = new DefaultUserTaskInvitationService(_fixture.Repository, _fixture.Policy, _fixture.Outbox, new DefaultUserTaskInvitationVerifier(), faulty, _fixture.Sink, _fixture.Identity, _fixture.Clock, _fixture.Options); var before = (await _fixture.Repository.GetAsync(Tenant, task.Id))!; var invitation = Assert.Single(before.Invitations); await Assert.ThrowsAsync( () => invitations.RevokeAsync(Tenant, task.Id, invitation.Id, before.Revision, manager)); // The failure must not commit the terminal state, or the retry guard would reject the repair and // strand a live credential. var afterFailure = (await _fixture.Repository.GetAsync(Tenant, task.Id))!; Assert.NotEqual(UserTaskInvitationStatus.Revoked, Assert.Single(afterFailure.Invitations).Status); Assert.True(await invitations.RevokeAsync(Tenant, task.Id, invitation.Id, afterFailure.Revision, manager)); Assert.Null(await _fixture.GuestActors.ResolveAsync(credential)); Assert.Equal(UserTaskInvitationStatus.Revoked, Assert.Single((await _fixture.Repository.GetAsync(Tenant, task.Id))!.Invitations).Status); } [Fact] public async Task RetryingRevocationOnAnAlreadyRevokedInvitationStillSweepsItsSessions() { var manager = _fixture.ManagerActor(); var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation()); var (_, credential) = await _fixture.IssueGuestSessionAsync(task, manager); var before = (await _fixture.Repository.GetAsync(Tenant, task.Id))!; var invitation = Assert.Single(before.Invitations); Assert.True(await _fixture.Invitations.RevokeAsync(Tenant, task.Id, invitation.Id, before.Revision, manager)); // A second call is idempotently successful and re-runs the sweep, so a caller repairing a partial // failure is never told "no" on an invitation whose sessions might still be live. var after = (await _fixture.Repository.GetAsync(Tenant, task.Id))!; Assert.True(await _fixture.Invitations.RevokeAsync(Tenant, task.Id, invitation.Id, after.Revision, manager)); Assert.Null(await _fixture.GuestActors.ResolveAsync(credential)); } [Fact] public async Task AnInvitationRevokedWhileVerificationIsInFlightDoesNotYieldALiveCredential() { 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); await _fixture.DrainOutboxAsync(); var token = _fixture.Dispatcher.Token!; // Revoke the moment the session lands in the store, which is the window where the manager's sweep // finds nothing and verification would otherwise hand back a credential that outlives the revoke. var racing = new RevokeOnIssueSessionIssuer(_fixture.GuestSessions, async () => { var current = (await _fixture.Repository.GetAsync(Tenant, task.Id))!; var invitation = Assert.Single(current.Invitations); await _fixture.Invitations.RevokeAsync(Tenant, task.Id, invitation.Id, current.Revision, manager); }); var invitations = new DefaultUserTaskInvitationService(_fixture.Repository, _fixture.Policy, _fixture.Outbox, new DefaultUserTaskInvitationVerifier(), racing, _fixture.Sink, _fixture.Identity, _fixture.Clock, _fixture.Options); var verified = await invitations.VerifyAsync(new(token)); Assert.False(verified.Succeeded); Assert.Equal("invitation-unavailable", verified.FailureCode); Assert.Null(verified.SessionToken); } /// Runs a callback immediately after a session is issued, to drive the revoke-during-verify race. private sealed class RevokeOnIssueSessionIssuer(IUserTaskGuestSessionIssuer inner, Func afterIssue) : IUserTaskGuestSessionIssuer { public async Task IssueAsync(UserTaskInvitation invitation, ParticipantReference subject, CancellationToken cancellationToken = default) { var result = await inner.IssueAsync(invitation, subject, cancellationToken); await afterIssue(); return result; } 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) => inner.RevokeForInvitationAsync(tenantId, invitationId, cancellationToken); } [Fact] public async Task ASuccessfulRevocationSweepsSessionsOnBothSidesOfTheCommit() { var manager = _fixture.ManagerActor(); var task = await _fixture.ProjectAsync(_fixture.Actor("user-1").Subject, WithBearerInvitation()); var (_, credential) = await _fixture.IssueGuestSessionAsync(task, manager); var counting = new UserTaskTestFixture.FaultyRevocationSessionIssuer(_fixture.GuestSessions, failures: 0); var invitations = new DefaultUserTaskInvitationService(_fixture.Repository, _fixture.Policy, _fixture.Outbox, new DefaultUserTaskInvitationVerifier(), counting, _fixture.Sink, _fixture.Identity, _fixture.Clock, _fixture.Options); var before = (await _fixture.Repository.GetAsync(Tenant, task.Id))!; Assert.True(await invitations.RevokeAsync(Tenant, task.Id, Assert.Single(before.Invitations).Id, before.Revision, manager)); // Both sweeps are load-bearing: the first keeps a store failure from committing, the second catches // a session a concurrent verification issued between the first sweep and the commit. Assert.Equal(2, counting.RevokeCallCount); Assert.Null(await _fixture.GuestActors.ResolveAsync(credential)); } [Fact] public async Task RevokingAnUnknownInvitationIsRefused() { 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); var current = (await _fixture.Repository.GetAsync(Tenant, task.Id))!; // Retrying a revoked invitation is deliberately idempotent so a partial failure stays repairable; // an invitation that does not exist is still a plain refusal. Assert.False(await _fixture.Invitations.RevokeAsync(Tenant, task.Id, "no-such-invitation", current.Revision, manager)); } [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")); } }