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.
159 lines
6.4 KiB
C#
159 lines
6.4 KiB
C#
using Elsa.UserTasks.Contracts;
|
|
using Elsa.UserTasks.Models;
|
|
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
|
|
using Elsa.UserTasks.Persistence.ConformanceTests.Providers;
|
|
|
|
namespace Elsa.UserTasks.Persistence.ConformanceTests;
|
|
|
|
/// <summary>
|
|
/// The behaviour every <see cref="IUserTaskGuestSessionIssuer"/> owes its callers. A guest credential is a
|
|
/// bearer secret with no identity behind it, so "revoked" has to mean revoked in every store, immediately.
|
|
/// </summary>
|
|
public abstract class UserTaskGuestSessionConformanceTests(UserTaskStoreFixture fixture) : UserTaskConformanceTestBase(fixture)
|
|
{
|
|
private IUserTaskGuestSessionIssuer Sessions => Fixture.GuestSessions;
|
|
|
|
[ConformanceFact]
|
|
public async Task AnIssuedCredentialResolvesToItsInvitationsSubjectAndActions()
|
|
{
|
|
await ActivateAsync();
|
|
var subject = Subject("guest-1");
|
|
var issued = await IssueAsync(Invitation(), subject);
|
|
|
|
var session = await Sessions.ResolveAsync(issued.Token!);
|
|
|
|
Assert.True(issued.Succeeded);
|
|
Assert.NotNull(session);
|
|
Assert.Equal(TenantId, session!.TenantId);
|
|
Assert.Equal("Complete", Assert.Single(session.AllowedActions));
|
|
Assert.True(subject.Matches(session.Subject));
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task AnUnknownOrEmptyCredentialResolvesToNothing()
|
|
{
|
|
await ActivateAsync();
|
|
await IssueAsync(Invitation(), Subject("guest-1"));
|
|
|
|
Assert.Null(await Sessions.ResolveAsync($"not-a-credential-{Guid.NewGuid():N}"));
|
|
Assert.Null(await Sessions.ResolveAsync(""));
|
|
Assert.Null(await Sessions.ResolveAsync(" "));
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task RevokingForATaskKillsEveryCredentialIssuedForIt()
|
|
{
|
|
await ActivateAsync();
|
|
var taskId = $"task-{Guid.NewGuid():N}";
|
|
var first = await IssueAsync(Invitation(taskId: taskId, id: "invitation-a"), Subject("guest-1"));
|
|
var second = await IssueAsync(Invitation(taskId: taskId, id: "invitation-b"), Subject("guest-2"));
|
|
|
|
await Sessions.RevokeForTaskAsync(TenantId, taskId);
|
|
|
|
Assert.Null(await Sessions.ResolveAsync(first.Token!));
|
|
Assert.Null(await Sessions.ResolveAsync(second.Token!));
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task RevokingForOneInvitationLeavesAnotherInvitationsSessionAlive()
|
|
{
|
|
await ActivateAsync();
|
|
var taskId = $"task-{Guid.NewGuid():N}";
|
|
var revoked = await IssueAsync(Invitation(taskId: taskId, id: "invitation-a"), Subject("guest-1"));
|
|
var survivor = await IssueAsync(Invitation(taskId: taskId, id: "invitation-b"), Subject("guest-2"));
|
|
|
|
await Sessions.RevokeForInvitationAsync(TenantId, "invitation-a");
|
|
|
|
// Scoped revocation is the whole point: withdrawing one guest link must not sign the other guest
|
|
// out, and must not leave the withdrawn one usable either.
|
|
Assert.Null(await Sessions.ResolveAsync(revoked.Token!));
|
|
Assert.NotNull(await Sessions.ResolveAsync(survivor.Token!));
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task RevocationIsScopedByTenant()
|
|
{
|
|
await ActivateAsync();
|
|
var issued = await IssueAsync(Invitation(id: "invitation-a"), Subject("guest-1"));
|
|
|
|
await Sessions.RevokeForInvitationAsync("other-tenant", "invitation-a");
|
|
Assert.NotNull(await Sessions.ResolveAsync(issued.Token!));
|
|
|
|
await Sessions.RevokeForTaskAsync("other-tenant", "task-1");
|
|
Assert.NotNull(await Sessions.ResolveAsync(issued.Token!));
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task RevokingTwiceIsHarmless()
|
|
{
|
|
await ActivateAsync();
|
|
var issued = await IssueAsync(Invitation(id: "invitation-a"), Subject("guest-1"));
|
|
|
|
await Sessions.RevokeForInvitationAsync(TenantId, "invitation-a");
|
|
await Sessions.RevokeForInvitationAsync(TenantId, "invitation-a");
|
|
|
|
Assert.Null(await Sessions.ResolveAsync(issued.Token!));
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task ASessionStopsResolvingOnceItExpiresWithoutAnExplicitRevoke()
|
|
{
|
|
await ActivateAsync();
|
|
Fixture.Settings.GuestSessionLifetime = TimeSpan.FromMinutes(30);
|
|
var issued = await IssueAsync(Invitation(expiresAt: Clock.UtcNow.AddDays(1)), Subject("guest-1"));
|
|
Assert.NotNull(await Sessions.ResolveAsync(issued.Token!));
|
|
|
|
Clock.Advance(TimeSpan.FromMinutes(31));
|
|
|
|
Assert.Null(await Sessions.ResolveAsync(issued.Token!));
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task ASessionNeverOutlivesTheInvitationItCameFrom()
|
|
{
|
|
await ActivateAsync();
|
|
Fixture.Settings.GuestSessionLifetime = TimeSpan.FromDays(7);
|
|
var invitationExpiry = Clock.UtcNow.AddMinutes(10);
|
|
|
|
var issued = await IssueAsync(Invitation(expiresAt: invitationExpiry), Subject("guest-1"));
|
|
|
|
Assert.Equal(invitationExpiry, issued.ExpiresAt);
|
|
Clock.Advance(TimeSpan.FromMinutes(11));
|
|
Assert.Null(await Sessions.ResolveAsync(issued.Token!));
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task AnAlreadyExpiredInvitationIssuesNothingAtAll()
|
|
{
|
|
await ActivateAsync();
|
|
|
|
var issued = await IssueAsync(Invitation(expiresAt: Clock.UtcNow.AddMinutes(-1)), Subject("guest-1"));
|
|
|
|
Assert.False(issued.Succeeded);
|
|
Assert.Null(issued.Token);
|
|
Assert.Equal("session-unavailable", issued.FailureCode);
|
|
}
|
|
|
|
[ConformanceFact]
|
|
public async Task TheRawCredentialIsNeverRecoverableFromTheStore()
|
|
{
|
|
await ActivateAsync();
|
|
var issued = await IssueAsync(Invitation(), Subject("guest-1"));
|
|
var session = await Sessions.ResolveAsync(issued.Token!);
|
|
|
|
// The credential is a bearer secret: the store keeps a hash, so nothing it exposes can be replayed.
|
|
Assert.NotNull(session);
|
|
Assert.DoesNotContain(issued.Token!, System.Text.Json.JsonSerializer.Serialize(session), StringComparison.Ordinal);
|
|
}
|
|
|
|
private Task<GuestSessionResult> IssueAsync(UserTaskInvitation invitation, ParticipantReference subject) =>
|
|
Sessions.IssueAsync(invitation, subject);
|
|
|
|
private UserTaskInvitation Invitation(string? taskId = null, string id = "invitation-1", DateTimeOffset? expiresAt = null) =>
|
|
new(id, TenantId, taskId ?? $"task-{Guid.NewGuid():N}", "guest@example.com", $"HASH-{Guid.NewGuid():N}",
|
|
UserTaskInvitationStatus.Consumed, Clock.UtcNow, expiresAt ?? Clock.UtcNow.AddDays(1), "bearer")
|
|
{
|
|
AllowedActions = ["Complete"]
|
|
};
|
|
}
|