diff --git a/doc/user-tasks.md b/doc/user-tasks.md index 8db70d9c9..0f303056a 100644 --- a/doc/user-tasks.md +++ b/doc/user-tasks.md @@ -51,6 +51,8 @@ Guest invitations are task-scoped and do not create Elsa users. Core hashes the The transient delivery outbox encrypts pending secrets with ASP.NET Core Data Protection, so the module needs an `IDataProtectionProvider`. Web hosts register one by default; a non-web host that enables invitations must call `AddDataProtection()` itself. +Revoking an invitation revokes the session it issued, including after the invitation has been consumed — that is the case where a live guest credential exists and a manager needs to withdraw it. Revocation is scoped to the one invitation, so other guests on the same task keep working. + A guest presents its session as `Authorization: UserTaskSession ` against `/user-task-sessions/current` and `/user-task-sessions/current/complete`. The session identifies the task, so no task ID appears in the route and a guest can never address a task other than the one its invitation was issued for. Completion is intersected with the action allowlist pinned at issuance, and every session for a task is revoked as soon as that task closes. ## Studio and custom applications diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/Migrations/UserTasksSchemaMigration.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/Migrations/UserTasksSchemaMigration.cs index dfdaaa967..8ca6b9bca 100644 --- a/src/modules/Elsa.UserTasks.Persistence.EFCore/Migrations/UserTasksSchemaMigration.cs +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/Migrations/UserTasksSchemaMigration.cs @@ -247,6 +247,7 @@ public static class UserTasksSchemaMigration migrationBuilder.CreateIndex(name: "IX_UserTaskInvitationDeliveries_Tenant_Invitation", table: "UserTaskInvitationDeliveries", columns: ["TenantId", "InvitationId"], schema: schema, unique: true); migrationBuilder.CreateIndex(name: "IX_UserTaskGuestSessions_SessionTokenHash", table: "UserTaskGuestSessions", column: "SessionTokenHash", schema: schema, unique: true); migrationBuilder.CreateIndex(name: "IX_UserTaskGuestSessions_Tenant_Task_ExpiresAt", table: "UserTaskGuestSessions", columns: ["TenantId", "TaskId", "ExpiresAt"], schema: schema); + migrationBuilder.CreateIndex(name: "IX_UserTaskGuestSessions_Tenant_Invitation", table: "UserTaskGuestSessions", columns: ["TenantId", "InvitationId"], schema: schema); } public static void Down(MigrationBuilder migrationBuilder, string schema) diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskGuestStores.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskGuestStores.cs index 4ea91eb98..b1ec3d778 100644 --- a/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskGuestStores.cs +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/Repositories/EFCoreUserTaskGuestStores.cs @@ -73,7 +73,7 @@ public sealed class EFCoreUserTaskGuestSessionIssuer( return null; var actions = JsonSerializer.Deserialize>(row.CapabilitiesJson, JsonOptions) ?? []; - return new(row.TenantId, row.TaskId, subject, actions, row.ExpiresAt); + return new(row.TenantId, row.TaskId, row.InvitationId, subject, actions, row.ExpiresAt); } public async Task RevokeForTaskAsync(string tenantId, string taskId, CancellationToken cancellationToken = default) @@ -83,6 +83,14 @@ public sealed class EFCoreUserTaskGuestSessionIssuer( .Where(x => x.TenantId == tenantId && x.TaskId == taskId && x.RevokedAt == null) .ExecuteUpdateAsync(x => x.SetProperty(p => p.RevokedAt, clock.UtcNow), cancellationToken); } + + public async Task RevokeForInvitationAsync(string tenantId, string invitationId, CancellationToken cancellationToken = default) + { + await using var dbContext = await store.CreateDbContextAsync(cancellationToken); + await dbContext.UserTaskGuestSessions + .Where(x => x.TenantId == tenantId && x.InvitationId == invitationId && x.RevokedAt == null) + .ExecuteUpdateAsync(x => x.SetProperty(p => p.RevokedAt, clock.UtcNow), cancellationToken); + } } /// diff --git a/src/modules/Elsa.UserTasks.Persistence.EFCore/UserTaskEntityConfiguration.cs b/src/modules/Elsa.UserTasks.Persistence.EFCore/UserTaskEntityConfiguration.cs index abd44c349..0227ed4af 100644 --- a/src/modules/Elsa.UserTasks.Persistence.EFCore/UserTaskEntityConfiguration.cs +++ b/src/modules/Elsa.UserTasks.Persistence.EFCore/UserTaskEntityConfiguration.cs @@ -184,6 +184,9 @@ internal static class UserTaskEntityConfiguration entity.Property(x => x.CapabilitiesJson).IsRequired(); entity.HasIndex(x => x.SessionTokenHash).IsUnique(); entity.HasIndex(x => new { x.TenantId, x.TaskId, x.ExpiresAt }); + // Invitation-scoped revocation filters on this pair; without it every revoke scans the + // tenant partition, which keeps growing because expired session rows are retained. + entity.HasIndex(x => new { x.TenantId, x.InvitationId }); }); } } diff --git a/src/modules/Elsa.UserTasks.Persistence.VNext/UserTaskPersistenceSchemaProvider.cs b/src/modules/Elsa.UserTasks.Persistence.VNext/UserTaskPersistenceSchemaProvider.cs index ea67f725c..018d04112 100644 --- a/src/modules/Elsa.UserTasks.Persistence.VNext/UserTaskPersistenceSchemaProvider.cs +++ b/src/modules/Elsa.UserTasks.Persistence.VNext/UserTaskPersistenceSchemaProvider.cs @@ -71,7 +71,7 @@ public sealed class UserTaskPersistenceSchemaProvider : IPersistenceSchemaProvid .RequiredField("Id", PersistenceColumnType.String, 450).RequiredField("TenantId", PersistenceColumnType.String, 450).RequiredField("TaskId", PersistenceColumnType.String, 450) .RequiredField("InvitationId", PersistenceColumnType.String, 450).RequiredField("SessionTokenHash", PersistenceColumnType.String, 256).RequiredField("GuestParticipantJson", PersistenceColumnType.Json) .RequiredField("CapabilitiesJson", PersistenceColumnType.Json).RequiredField("IssuedAt", PersistenceColumnType.DateTimeOffset).RequiredField("ExpiresAt", PersistenceColumnType.DateTimeOffset).Field("RevokedAt", PersistenceColumnType.DateTimeOffset) - .Key("PK_UserTaskGuestSessions", "Id").Index("IX_UserTaskGuestSessions_SessionTokenHash", "SessionTokenHash", unique: true).Index("IX_UserTaskGuestSessions_Tenant_Task_ExpiresAt", ["TenantId", "TaskId", "ExpiresAt"]), @namespace: "Elsa.UserTasks"); + .Key("PK_UserTaskGuestSessions", "Id").Index("IX_UserTaskGuestSessions_SessionTokenHash", "SessionTokenHash", unique: true).Index("IX_UserTaskGuestSessions_Tenant_Task_ExpiresAt", ["TenantId", "TaskId", "ExpiresAt"]).Index("IX_UserTaskGuestSessions_Tenant_Invitation", ["TenantId", "InvitationId"]), @namespace: "Elsa.UserTasks"); return schema.Build(); } diff --git a/src/modules/Elsa.UserTasks/Contracts/UserTaskContracts.cs b/src/modules/Elsa.UserTasks/Contracts/UserTaskContracts.cs index 6b298a307..e562e91ba 100644 --- a/src/modules/Elsa.UserTasks/Contracts/UserTaskContracts.cs +++ b/src/modules/Elsa.UserTasks/Contracts/UserTaskContracts.cs @@ -139,6 +139,12 @@ public interface IUserTaskGuestSessionIssuer /// Revokes every session issued for a task. Called when the task reaches a terminal state. Task RevokeForTaskAsync(string tenantId, string taskId, CancellationToken cancellationToken = default); + + /// + /// Revokes the sessions issued from one invitation. This is what makes a guest credential withdrawable: + /// consuming an invitation is what creates the session, so revoking the invitation must kill it too. + /// + Task RevokeForInvitationAsync(string tenantId, string invitationId, CancellationToken cancellationToken = default); } /// diff --git a/src/modules/Elsa.UserTasks/Models/UserTaskModels.cs b/src/modules/Elsa.UserTasks/Models/UserTaskModels.cs index 4fcf0127d..bdf91369c 100644 --- a/src/modules/Elsa.UserTasks/Models/UserTaskModels.cs +++ b/src/modules/Elsa.UserTasks/Models/UserTaskModels.cs @@ -347,6 +347,7 @@ public sealed record GuestSessionResult(bool Succeeded, string? Token = null, Da public sealed record UserTaskGuestSession( string TenantId, string TaskId, + string InvitationId, ParticipantReference Subject, IReadOnlyCollection AllowedActions, DateTimeOffset ExpiresAt); diff --git a/src/modules/Elsa.UserTasks/Services/DefaultUserTaskInvitationService.cs b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskInvitationService.cs index 636d69270..a39630fe7 100644 --- a/src/modules/Elsa.UserTasks/Services/DefaultUserTaskInvitationService.cs +++ b/src/modules/Elsa.UserTasks/Services/DefaultUserTaskInvitationService.cs @@ -110,10 +110,26 @@ public sealed class DefaultUserTaskInvitationService( var task = await repository.GetAsync(tenantId, taskId, cancellationToken); if (task == null || !await accessPolicy.AuthorizeAsync(task, actor, UserTaskAccessOperation.IssueInvitation, cancellationToken)) return false; + var existing = task.Invitations.FirstOrDefault(x => x.Id == invitationId); + if (existing == null || existing.Status is UserTaskInvitationStatus.Expired) + return false; + + // Swept on both sides of the commit, and both sides are load-bearing. This first sweep runs before + // anything is committed, so a session-store failure leaves the invitation revocable and a retry + // repairs it rather than stranding a live credential behind a guard that rejects the retry. + await sessionIssuer.RevokeForInvitationAsync(tenantId, invitationId, cancellationToken); + + // Already revoked: the sweep above was the only work left, so a retry succeeds idempotently + // instead of reporting a failure the caller cannot act on. + if (existing.Status is UserTaskInvitationStatus.Revoked) + return true; + + // A consumed invitation is precisely the case worth revoking: consuming it is what issued the guest + // session, so refusing here would leave a live credential that no manager could withdraw. if (!await repository.TryMutateAsync(tenantId, taskId, expectedRevision, current => { var invitation = current.Invitations.FirstOrDefault(x => x.Id == invitationId); - if (invitation == null || invitation.Status is UserTaskInvitationStatus.Revoked or UserTaskInvitationStatus.Consumed or UserTaskInvitationStatus.Expired) + if (invitation == null || invitation.Status is UserTaskInvitationStatus.Revoked or UserTaskInvitationStatus.Expired) return false; var index = current.Invitations.IndexOf(invitation); current.Invitations[index] = invitation with { Status = UserTaskInvitationStatus.Revoked, RevokedAt = clock.UtcNow }; @@ -123,6 +139,12 @@ public sealed class DefaultUserTaskInvitationService( }, cancellationToken)) return false; + // The second sweep closes the mirror window: a concurrent verification can issue a session after the + // first sweep and still read Consumed before this commit lands. Anything issued in that window is + // caught here, and any verification that issues after the commit sees the revoked state at its own + // settled-state check and withdraws its own credential. + await sessionIssuer.RevokeForInvitationAsync(tenantId, invitationId, cancellationToken); + var committed = await repository.GetAsync(tenantId, taskId, cancellationToken); if (committed != null) await notifications.PublishAsync(new UserTaskInvitationChanged(tenantId, taskId, committed.Status, committed.Revision), cancellationToken); @@ -197,9 +219,20 @@ public sealed class DefaultUserTaskInvitationService( var consumed = invitation with { Status = UserTaskInvitationStatus.Consumed, VerifiedAt = verifiedAt, ConsumedAt = verifiedAt }; var session = await sessionIssuer.IssueAsync(consumed, subject, cancellationToken); - return session.Succeeded - ? new UserTaskInvitationVerificationResultWithSession(true, task.Id, session.Token, session.ExpiresAt) - : Failed(); + if (!session.Succeeded) + return Failed(); + + // A manager can revoke between the claim above and the session landing in the store, and that + // revocation would find nothing to sweep. Re-read the committed invitation and withdraw the + // credential we just issued if it is no longer the consumed one we verified. + var settled = await repository.GetAsync(task.TenantId, task.Id, cancellationToken); + if (settled?.Invitations.FirstOrDefault(x => x.Id == invitation.Id) is not { Status: UserTaskInvitationStatus.Consumed }) + { + await sessionIssuer.RevokeForInvitationAsync(task.TenantId, invitation.Id, cancellationToken); + return Failed(); + } + + return new UserTaskInvitationVerificationResultWithSession(true, task.Id, session.Token, session.ExpiresAt); } private async Task<(UserTask Task, UserTaskInvitation Invitation)?> ResolveOpenInvitationAsync(string? token, CancellationToken cancellationToken) diff --git a/src/modules/Elsa.UserTasks/Services/UserTaskGuestSecurity.cs b/src/modules/Elsa.UserTasks/Services/UserTaskGuestSecurity.cs index 3117a3bea..3ad42a02b 100644 --- a/src/modules/Elsa.UserTasks/Services/UserTaskGuestSecurity.cs +++ b/src/modules/Elsa.UserTasks/Services/UserTaskGuestSecurity.cs @@ -30,7 +30,7 @@ public sealed class InMemoryUserTaskGuestSessionIssuer(ISystemClock clock, IOpti var token = DefaultUserTaskInvitationService.Base64Url(RandomNumberGenerator.GetBytes(32)); _sessions[DefaultUserTaskInvitationService.HashToken(token)] = new UserTaskGuestSession( - invitation.TenantId, invitation.TaskId, subject, invitation.AllowedActions.ToArray(), expiresAt); + invitation.TenantId, invitation.TaskId, invitation.Id, subject, invitation.AllowedActions.ToArray(), expiresAt); return Task.FromResult(new GuestSessionResult(true, token, expiresAt, TaskId: invitation.TaskId)); } @@ -56,6 +56,13 @@ public sealed class InMemoryUserTaskGuestSessionIssuer(ISystemClock clock, IOpti return Task.CompletedTask; } + public Task RevokeForInvitationAsync(string tenantId, string invitationId, CancellationToken cancellationToken = default) + { + foreach (var entry in _sessions.Where(x => x.Value.TenantId == tenantId && x.Value.InvitationId == invitationId).ToArray()) + _sessions.TryRemove(entry.Key, out _); + return Task.CompletedTask; + } + private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) => left <= right ? left : right; } diff --git a/test/unit/Elsa.UserTasks.UnitTests/UserTaskInvitationTests.cs b/test/unit/Elsa.UserTasks.UnitTests/UserTaskInvitationTests.cs index 0dcdd9f80..f6f6c8b97 100644 --- a/test/unit/Elsa.UserTasks.UnitTests/UserTaskInvitationTests.cs +++ b/test/unit/Elsa.UserTasks.UnitTests/UserTaskInvitationTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Elsa.UserTasks.Contracts; using Elsa.UserTasks.Models; using Elsa.UserTasks.Options; using Elsa.UserTasks.Services; @@ -219,6 +220,184 @@ public class UserTaskInvitationTests 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() { diff --git a/test/unit/Elsa.UserTasks.UnitTests/UserTaskTestFixture.cs b/test/unit/Elsa.UserTasks.UnitTests/UserTaskTestFixture.cs index 40b17aece..e3a5f280b 100644 --- a/test/unit/Elsa.UserTasks.UnitTests/UserTaskTestFixture.cs +++ b/test/unit/Elsa.UserTasks.UnitTests/UserTaskTestFixture.cs @@ -104,6 +104,34 @@ public sealed class UserTaskTestFixture } } + /// + /// 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;