Runs one suite unchanged against every implementation of IUserTaskRepository, IUserTaskGuestSessionIssuer, and IUserTaskInvitationOutbox, plus a fault-injection suite driving the real DefaultUserTaskManager and DefaultUserTaskInvitationService against a real store. Gated providers report as skipped with a reason rather than passing vacuously; ConformanceCoverageTests fails when a provider that must run is unreachable or its variable is set but empty. The suite found three defects, fixed here: - VNextUserTaskRepository supplied no index values for WorkflowDefinitionId, WorkflowInstanceId, ActivityInstanceId, CreatedAt, or CompletedAt, all declared by its own schema provider, so every write through the VNext provider threw. - The same provider resolved invitation token hashes by scanning on Status alone, which matched no declared index, so anonymous invitation verification always threw. - EFCoreUserTaskInvitationOutbox persisted the delivery recipient but never read it back, so durably queued invitations reached the dispatcher with no address. Also switches new ADRs to date-prefixed identifiers and generates doc/adr/toc.md via scripts/adr/generate-toc.sh, with a --check mode and pull-request workflow so the index is never hand-edited again.
307 lines
15 KiB
C#
307 lines
15 KiB
C#
using Elsa.UserTasks.Contracts;
|
|
using Elsa.UserTasks.Models;
|
|
using Elsa.UserTasks.Persistence.ConformanceTests.Faults;
|
|
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
|
|
using Elsa.UserTasks.Persistence.ConformanceTests.Providers;
|
|
using Elsa.UserTasks.Services;
|
|
using Elsa.Workflows;
|
|
|
|
namespace Elsa.UserTasks.Persistence.ConformanceTests;
|
|
|
|
/// <summary>
|
|
/// Runs the real <see cref="DefaultUserTaskManager"/> and <see cref="DefaultUserTaskInvitationService"/>
|
|
/// against a real store, with failures injected at the seams between them.
|
|
///
|
|
/// Every P1 defect in the User Tasks build was found this way and none by a happy-path test, so injecting
|
|
/// faults is treated here as a first-class part of the contract rather than a special case: a cross-store
|
|
/// operation must either commit fully or leave the caller able to retry, and a retry must converge.
|
|
/// </summary>
|
|
public abstract class UserTaskFaultInjectionConformanceTests(UserTaskStoreFixture fixture) : UserTaskConformanceTestBase(fixture)
|
|
{
|
|
private readonly TestIdentityGenerator _identity = new();
|
|
private readonly TestSink _sink = new();
|
|
private readonly DefaultUserTaskAccessPolicy _policy = new();
|
|
|
|
[ConformanceFact]
|
|
public async Task AConcurrentEditMakesTheManagerReportAConflictInsteadOfFaulting()
|
|
{
|
|
await ActivateAsync();
|
|
var task = await ProjectAsync(CreateTask(Subject()));
|
|
var manager = CreateManager(Repository);
|
|
|
|
// Someone else moves the task on, so the revision the caller holds is now stale.
|
|
var concurrent = await GetAsync(task.Id);
|
|
concurrent.Priority = 90;
|
|
await Repository.SaveAsync(concurrent, concurrent.Revision);
|
|
|
|
var result = await manager.ClaimAsync(TenantId, task.Id, new(task.Revision, "claim-1"), Actor());
|
|
|
|
// The store raises its own concurrency exception underneath. It must surface as the documented
|
|
// conflict result rather than escaping to the unhandled-error middleware as a 500.
|
|
Assert.False(result.Accepted);
|
|
Assert.Equal("revision-conflict", result.ConflictCode);
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task TwoCompletionsOnOneRevisionLeaveExactlyOneWinnerAndOneConflict()
|
|
{
|
|
await ActivateAsync();
|
|
var subject = Subject();
|
|
var task = await ProjectAsync(CreateTask(subject));
|
|
var actor = Actor();
|
|
var manager = CreateManager(Repository);
|
|
var contender = CreateManager(Fixture.CreateSecondRepository());
|
|
|
|
var claimed = await manager.ClaimAsync(TenantId, task.Id, new(task.Revision, "claim-1"), actor);
|
|
Assert.True(claimed.Accepted);
|
|
var revision = claimed.Task!.Revision;
|
|
|
|
var first = await manager.CompleteAsync(TenantId, task.Id, new(revision, "op-first", "Complete"), actor);
|
|
var second = await contender.CompleteAsync(TenantId, task.Id, new(revision, "op-second", "Complete"), actor);
|
|
|
|
Assert.True(first.Accepted);
|
|
Assert.False(second.Accepted);
|
|
// The loser gets a conflict it can act on, never an unhandled exception and never a silent success.
|
|
Assert.Equal("revision-conflict", second.ConflictCode);
|
|
// Completion is two-phase: the manager records the intent and the workflow resumption settles it.
|
|
// With no resumer attached, Completing is the committed state, and only one of the two got there.
|
|
Assert.Equal(UserTaskStatus.Completing, (await GetAsync(task.Id)).Status);
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task AFailureInThePreCommitSweepLeavesTheInvitationRevocableAndTheRetryRepairsIt()
|
|
{
|
|
await ActivateAsync();
|
|
var (task, invitationId, credential, sessions) = await IssueGuestSessionAsync();
|
|
var invitations = CreateInvitationService(Repository, sessions);
|
|
sessions.ResetCounters();
|
|
|
|
// Fail the sweep that runs before anything is committed.
|
|
sessions.FailRevokeForInvitationWhen = ordinal => ordinal == 1;
|
|
var current = await GetAsync(task.Id);
|
|
await Assert.ThrowsAsync<InjectedStoreFaultException>(() =>
|
|
invitations.RevokeAsync(TenantId, task.Id, invitationId, current.Revision, ManagerActor()));
|
|
|
|
// Nothing was committed, so the invitation is still open and the credential is still live. Failing
|
|
// closed here is the point: committing the terminal state first would strand a live credential
|
|
// behind a guard that rejects the retry.
|
|
var afterFailure = await GetAsync(task.Id);
|
|
Assert.Equal(UserTaskInvitationStatus.Consumed, Invitation(afterFailure, invitationId).Status);
|
|
Assert.NotNull(await sessions.ResolveAsync(credential));
|
|
|
|
sessions.FailRevokeForInvitationWhen = null;
|
|
Assert.True(await invitations.RevokeAsync(TenantId, task.Id, invitationId, afterFailure.Revision, ManagerActor()));
|
|
Assert.Equal(UserTaskInvitationStatus.Revoked, Invitation(await GetAsync(task.Id), invitationId).Status);
|
|
Assert.Null(await sessions.ResolveAsync(credential));
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task AFailureInThePostCommitSweepIsRepairedIdempotentlyByARetry()
|
|
{
|
|
await ActivateAsync();
|
|
var (task, invitationId, credential, sessions) = await IssueGuestSessionAsync();
|
|
var invitations = CreateInvitationService(Repository, sessions);
|
|
sessions.ResetCounters();
|
|
|
|
// Let the pre-commit sweep run and fail the one after the commit, so the aggregate reads Revoked
|
|
// while a session issued in the commit window could still be live.
|
|
sessions.FailRevokeForInvitationWhen = ordinal => ordinal == 2;
|
|
var current = await GetAsync(task.Id);
|
|
await Assert.ThrowsAsync<InjectedStoreFaultException>(() =>
|
|
invitations.RevokeAsync(TenantId, task.Id, invitationId, current.Revision, ManagerActor()));
|
|
Assert.Equal(UserTaskInvitationStatus.Revoked, Invitation(await GetAsync(task.Id), invitationId).Status);
|
|
|
|
// The retry finds an already-revoked invitation. It must report success and sweep again rather than
|
|
// reporting a failure the caller cannot act on and leaving the credential behind.
|
|
sessions.FailRevokeForInvitationWhen = null;
|
|
var sweepsBefore = sessions.RevokeForInvitationCallCount;
|
|
var afterFailure = await GetAsync(task.Id);
|
|
Assert.True(await invitations.RevokeAsync(TenantId, task.Id, invitationId, afterFailure.Revision, ManagerActor()));
|
|
|
|
Assert.True(sessions.RevokeForInvitationCallCount > sweepsBefore);
|
|
Assert.Null(await sessions.ResolveAsync(credential));
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task ASuccessfulRevocationSweepsSessionsOnBothSidesOfTheCommit()
|
|
{
|
|
await ActivateAsync();
|
|
var (task, invitationId, credential, sessions) = await IssueGuestSessionAsync();
|
|
var invitations = CreateInvitationService(Repository, sessions);
|
|
sessions.ResetCounters();
|
|
|
|
var current = await GetAsync(task.Id);
|
|
Assert.True(await invitations.RevokeAsync(TenantId, task.Id, invitationId, current.Revision, ManagerActor()));
|
|
|
|
// 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, sessions.RevokeForInvitationCallCount);
|
|
Assert.Null(await sessions.ResolveAsync(credential));
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task ACredentialIssuedInsideTheRevokeCommitWindowIsDeadEitherWay()
|
|
{
|
|
await ActivateAsync();
|
|
var task = await ProjectAsync(CreateTask(Subject()));
|
|
var sessions = new FaultingGuestSessionIssuer(Fixture.GuestSessions);
|
|
var dispatcher = new CapturingDispatcher();
|
|
var invitations = CreateInvitationService(Repository, sessions);
|
|
|
|
var issued = await invitations.IssueAsync(TenantId, task.Id, new(task.Revision, "bearer", ["Complete"]), ManagerActor());
|
|
Assert.NotNull(issued);
|
|
await DrainOutboxAsync(dispatcher);
|
|
|
|
// Revoke from inside IssueAsync, so the revocation runs after the guest session lands in the store
|
|
// but before verification re-reads the settled invitation state.
|
|
sessions.AfterIssue = async () =>
|
|
{
|
|
var current = await GetAsync(task.Id);
|
|
await invitations.RevokeAsync(TenantId, task.Id, issued!.Invitation.Id, current.Revision, ManagerActor());
|
|
};
|
|
|
|
var verified = await invitations.VerifyAsync(new(dispatcher.Token!));
|
|
|
|
// Whichever side wins, no live credential may survive a successful revoke.
|
|
Assert.False(verified.Succeeded);
|
|
Assert.Equal("invitation-unavailable", verified.FailureCode);
|
|
Assert.Null(verified.SessionToken);
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task AFailedSaveCommitsNothingAndTheRetrySucceedsOnTheSameRevision()
|
|
{
|
|
await ActivateAsync();
|
|
var task = await ProjectAsync(CreateTask(Subject()));
|
|
var faulting = new FaultingUserTaskRepository(Repository) { FailSaveCalls = 1 };
|
|
|
|
var attempt = await GetAsync(task.Id);
|
|
attempt.Priority = 77;
|
|
await Assert.ThrowsAsync<InjectedStoreFaultException>(() => faulting.SaveAsync(attempt, task.Revision));
|
|
|
|
// A store that fails must leave the aggregate exactly as it was, revision included, or the retry
|
|
// the caller is about to make would come back as a conflict it cannot explain.
|
|
var afterFailure = await GetAsync(task.Id);
|
|
Assert.Equal(task.Priority, afterFailure.Priority);
|
|
Assert.Equal(task.Revision, afterFailure.Revision);
|
|
|
|
await faulting.SaveAsync(attempt, task.Revision);
|
|
Assert.Equal(2, faulting.SaveCallCount);
|
|
Assert.Equal(77, (await GetAsync(task.Id)).Priority);
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task AFailedCompareAndSwapCommitsNothingAndTheRetryConverges()
|
|
{
|
|
await ActivateAsync();
|
|
var task = await ProjectAsync(CreateTask(Subject()));
|
|
var faulting = new FaultingUserTaskRepository(Repository) { FailTryMutateCalls = 1 };
|
|
var mutation = (UserTask current) =>
|
|
{
|
|
current.Status = UserTaskStatus.Cancelled;
|
|
return true;
|
|
};
|
|
|
|
await Assert.ThrowsAsync<InjectedStoreFaultException>(() => faulting.TryMutateAsync(TenantId, task.Id, task.Revision, mutation));
|
|
Assert.Equal(task.Status, (await GetAsync(task.Id)).Status);
|
|
|
|
Assert.True(await faulting.TryMutateAsync(TenantId, task.Id, task.Revision, mutation));
|
|
Assert.Equal(2, faulting.TryMutateCallCount);
|
|
Assert.Equal(UserTaskStatus.Cancelled, (await GetAsync(task.Id)).Status);
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task AnAuditWriteFailureDoesNotConsumeTheCallersRevision()
|
|
{
|
|
await ActivateAsync();
|
|
var task = await ProjectAsync(CreateTask(Subject()));
|
|
var faulting = new FaultingUserTaskRepository(Repository) { FailAppendEventCalls = 1 };
|
|
|
|
await Assert.ThrowsAsync<InjectedStoreFaultException>(() =>
|
|
faulting.AppendEventAsync(TenantId, task.Id, new($"event-{Guid.NewGuid():N}", TenantId, task.Id, task.Revision, "Viewed", Clock.UtcNow)));
|
|
|
|
// The command the caller was already holding a revision for still commits.
|
|
var held = await GetAsync(task.Id);
|
|
held.Priority = 33;
|
|
await Repository.SaveAsync(held, task.Revision);
|
|
Assert.Equal(33, (await GetAsync(task.Id)).Priority);
|
|
}
|
|
|
|
private async Task<(UserTask Task, string InvitationId, string Credential, FaultingGuestSessionIssuer Sessions)> IssueGuestSessionAsync()
|
|
{
|
|
var task = await ProjectAsync(CreateTask(Subject()));
|
|
var sessions = new FaultingGuestSessionIssuer(Fixture.GuestSessions);
|
|
var dispatcher = new CapturingDispatcher();
|
|
var invitations = CreateInvitationService(Repository, sessions);
|
|
|
|
var issued = await invitations.IssueAsync(TenantId, task.Id, new(task.Revision, "bearer", ["Complete"]), ManagerActor())
|
|
?? throw new InvalidOperationException("The invitation could not be issued.");
|
|
await DrainOutboxAsync(dispatcher);
|
|
|
|
var verified = await invitations.VerifyAsync(new(dispatcher.Token!));
|
|
if (!verified.Succeeded)
|
|
throw new InvalidOperationException($"The invitation could not be verified: {verified.FailureCode}.");
|
|
|
|
return (await GetAsync(task.Id), issued.Invitation.Id, verified.SessionToken!, sessions);
|
|
}
|
|
|
|
private async Task DrainOutboxAsync(CapturingDispatcher dispatcher)
|
|
{
|
|
foreach (var delivery in await Fixture.Outbox.DequeueDueAsync(100))
|
|
{
|
|
await dispatcher.DispatchAsync(delivery);
|
|
await Fixture.Outbox.CompleteAsync(delivery.Id);
|
|
}
|
|
}
|
|
|
|
private static UserTaskInvitation Invitation(UserTask task, string invitationId) =>
|
|
task.Invitations.Single(x => x.Id == invitationId);
|
|
|
|
private DefaultUserTaskManager CreateManager(IUserTaskRepository repository) =>
|
|
new(repository, _policy, [], new NoOpResumer(), _sink, _identity, Clock, Fixture.Options);
|
|
|
|
private DefaultUserTaskInvitationService CreateInvitationService(IUserTaskRepository repository, IUserTaskGuestSessionIssuer sessions) =>
|
|
new(repository, _policy, Fixture.Outbox, new DefaultUserTaskInvitationVerifier(), sessions, _sink, _identity, Clock, Fixture.Options);
|
|
|
|
private UserTaskActor Actor(string id = "user-1") => new(Subject(id), [])
|
|
{
|
|
Permissions = new HashSet<string>(["read:user-tasks", "claim:user-tasks", "complete:user-tasks"], StringComparer.OrdinalIgnoreCase)
|
|
};
|
|
|
|
private UserTaskActor ManagerActor() => Actor("manager-1") with
|
|
{
|
|
IsManager = true,
|
|
Permissions = new HashSet<string>([
|
|
"read:user-tasks", "claim:user-tasks", "complete:user-tasks", "assign:user-tasks",
|
|
"update:user-tasks", "cancel:user-tasks", "invite:user-tasks", "manage:user-tasks"
|
|
], StringComparer.OrdinalIgnoreCase)
|
|
};
|
|
|
|
private sealed class TestIdentityGenerator : IIdentityGenerator
|
|
{
|
|
private int _counter;
|
|
public string GenerateId() => $"id-{Interlocked.Increment(ref _counter)}-{Guid.NewGuid():N}";
|
|
}
|
|
|
|
private sealed class NoOpResumer : IUserTaskWorkflowResumer
|
|
{
|
|
public Task ResumeAsync(UserTask task, UserTaskStimulus stimulus, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
|
}
|
|
|
|
private sealed class TestSink : IUserTaskNotificationSink
|
|
{
|
|
public Task PublishAsync(UserTaskLifecycleNotification notification, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
|
}
|
|
|
|
private sealed class CapturingDispatcher : IUserTaskInvitationDispatcher
|
|
{
|
|
public string? Token { get; private set; }
|
|
|
|
public Task DispatchAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default)
|
|
{
|
|
Token = delivery.Token;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
}
|