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>
This commit is contained in:
Sipke Schoorstra 2026-08-25 02:38:12 +02:00 committed by GitHub
parent 5862bb84e3
commit 9e079b27db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 275 additions and 7 deletions

View file

@ -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 <credential>` 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

View file

@ -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)

View file

@ -73,7 +73,7 @@ public sealed class EFCoreUserTaskGuestSessionIssuer(
return null;
var actions = JsonSerializer.Deserialize<List<string>>(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);
}
}
/// <summary>

View file

@ -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 });
});
}
}

View file

@ -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();
}

View file

@ -139,6 +139,12 @@ public interface IUserTaskGuestSessionIssuer
/// <summary>Revokes every session issued for a task. Called when the task reaches a terminal state.</summary>
Task RevokeForTaskAsync(string tenantId, string taskId, CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
Task RevokeForInvitationAsync(string tenantId, string invitationId, CancellationToken cancellationToken = default);
}
/// <summary>

View file

@ -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<string> AllowedActions,
DateTimeOffset ExpiresAt);

View file

@ -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)

View file

@ -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;
}

View file

@ -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<InvalidOperationException>(
() => 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);
}
/// <summary>Runs a callback immediately after a session is issued, to drive the revoke-during-verify race.</summary>
private sealed class RevokeOnIssueSessionIssuer(IUserTaskGuestSessionIssuer inner, Func<Task> afterIssue) : IUserTaskGuestSessionIssuer
{
public async Task<GuestSessionResult> IssueAsync(UserTaskInvitation invitation, ParticipantReference subject, CancellationToken cancellationToken = default)
{
var result = await inner.IssueAsync(invitation, subject, cancellationToken);
await afterIssue();
return result;
}
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) => 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()
{

View file

@ -104,6 +104,34 @@ public sealed class UserTaskTestFixture
}
}
/// <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);
}
}
public sealed class TestClock : ISystemClock
{
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow;