test(ext-auth): share store conformance scenarios (#8096)

* Add external authentication store conformance tests

* Cover concurrent external auth token takes

* Harden external authentication conformance races

* Fix concurrent registry version advances

* chore: retrigger hosted checks

* chore: retry code scanning

* ci: retry CodeQL checks

* test(ext-auth): make registry race deterministic

* test(ext-auth): coordinate conformance races

* test(ext-auth): close concurrency review gaps

* test(ext-auth): coordinate after reader disposal

* test(ext-auth): preserve race coordination failures
This commit is contained in:
Sipke Schoorstra 2026-09-13 15:58:40 +02:00 committed by GitHub
parent 1126f536f8
commit b23a10c2a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 723 additions and 9 deletions

View file

@ -160,16 +160,40 @@ public sealed class EFCoreConnectionRegistryVersionStore(ExternalAuthenticationD
{
await using var lease = await dbContextFactory.CreateAsync(cancellationToken);
var dbContext = lease.DbContext;
var changed = await dbContext.ExternalAuthenticationRegistryVersions.Where(x => x.Id == SingletonId)
.ExecuteUpdateAsync(x => x.SetProperty(y => y.Version, y => y.Version + 1), cancellationToken);
if (changed == 0)
while (true)
{
dbContext.ExternalAuthenticationRegistryVersions.Add(new ExternalAuthenticationRegistryVersion { Id = SingletonId, Version = 2 });
try { await dbContext.SaveChangesAsync(cancellationToken); }
catch (DbUpdateException) { return await AdvanceAsync(cancellationToken); }
return 2;
var current = await dbContext.ExternalAuthenticationRegistryVersions
.Where(x => x.Id == SingletonId)
.Select(x => (long?)x.Version)
.SingleOrDefaultAsync(cancellationToken);
if (current is not long currentVersion)
{
dbContext.ExternalAuthenticationRegistryVersions.Add(new ExternalAuthenticationRegistryVersion { Id = SingletonId, Version = 2 });
try
{
await dbContext.SaveChangesAsync(cancellationToken);
return 2;
}
catch (DbUpdateException)
{
// Another caller may have won the initialization race. Clear the failed Added entry before
// checking whether this was that expected duplicate-key outcome or another database failure.
dbContext.ChangeTracker.Clear();
if (!await dbContext.ExternalAuthenticationRegistryVersions.AnyAsync(x => x.Id == SingletonId, cancellationToken))
throw;
}
continue;
}
var next = currentVersion + 1;
var changed = await dbContext.ExternalAuthenticationRegistryVersions
.Where(x => x.Id == SingletonId && x.Version == currentVersion)
.ExecuteUpdateAsync(x => x.SetProperty(y => y.Version, next), cancellationToken);
if (changed == 1)
return next;
}
return await GetVersionAsync(cancellationToken);
}
public async ValueTask<bool> IsCurrentAsync(long version, CancellationToken cancellationToken = default) => await GetVersionAsync(cancellationToken) == version;

View file

@ -151,7 +151,6 @@ public sealed class EFCoreExternalIdentityProvisioner(
try
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
oldEntity = await dbContext.ExternalIdentityLinks.AsNoTracking().SingleOrDefaultAsync(
x => x.Id == request.LinkId && x.TenantId == request.TenantId,
cancellationToken);
@ -175,6 +174,9 @@ public sealed class EFCoreExternalIdentityProvisioner(
new ProvisioningRequest(request.TenantId, normalizedConnectionKey, request.Identity, null, request.UserId),
cancellationToken: cancellationToken);
// Keep the optimistic reads outside the SQLite transaction so concurrent callers can observe the same
// old link; the guarded delete and replacement insert remain atomic within the transaction.
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
var deleted = await dbContext.ExternalIdentityLinks
.Where(x => x.Id == request.LinkId && x.TenantId == request.TenantId)
.ExecuteDeleteAsync(cancellationToken);

View file

@ -0,0 +1,688 @@
using System.Data.Common;
using Elsa.Common;
using Elsa.Common.Models;
using Elsa.Common.Services;
using Elsa.ExternalAuthentication.Contracts;
using Elsa.ExternalAuthentication.Models;
using Elsa.ExternalAuthentication.Persistence.EFCore;
using Elsa.ExternalAuthentication.Persistence.EFCore.Stores;
using Elsa.ExternalAuthentication.Services;
using Elsa.ExternalAuthentication.Stores.InMemory;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using Elsa.Identity.Providers;
using Elsa.Identity.Services;
using Elsa.Persistence.EFCore;
using Elsa.Testing.Shared.Multitenancy;
using Elsa.Workflows;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Elsa.ExternalAuthentication.IntegrationTests.Persistence;
public abstract class ExternalAuthenticationStoreConformanceTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 13, 12, 0, 0, TimeSpan.Zero);
protected abstract Task<ExternalAuthenticationStoreScenario> CreateScenarioAsync();
[Fact]
public async Task RefreshTokenHashesAreGloballyUniqueAndFailedWritesDoNotMutateState()
{
await using var scenario = await CreateScenarioAsync();
var first = CreateSession("session-a", "tenant-a", "refresh-a");
var second = CreateSession("session-b", "tenant-b", "refresh-a");
await scenario.SessionStore.SaveAsync(first);
await scenario.AssertRefreshTokenConflictAsync(() => scenario.SessionStore.SaveAsync(second).AsTask());
Assert.Null(await scenario.SessionStore.FindByIdAsync(second.Id));
Assert.Equal(first.Id, (await scenario.SessionStore.FindByRefreshTokenHashAsync(first.CurrentRefreshTokenHash!))!.Id);
second.CurrentRefreshTokenHash = "refresh-b";
await scenario.SessionStore.SaveAsync(second);
await scenario.AssertRefreshTokenConflictAsync(() => scenario.SessionStore.TryRotateRefreshTokenAsync(first.Id, "refresh-a", 0, "refresh-b", Now.AddMinutes(1)).AsTask());
var persistedFirst = await scenario.SessionStore.FindByIdAsync(first.Id);
var persistedSecond = await scenario.SessionStore.FindByIdAsync(second.Id);
Assert.NotNull(persistedFirst);
Assert.NotNull(persistedSecond);
Assert.Equal("refresh-a", persistedFirst.CurrentRefreshTokenHash);
Assert.Equal(0, persistedFirst.RefreshGeneration);
Assert.Equal(Now, persistedFirst.LastRefreshedAt);
Assert.Equal("refresh-b", persistedSecond.CurrentRefreshTokenHash);
Assert.Equal(second.Id, (await scenario.SessionStore.FindByRefreshTokenHashAsync("refresh-b"))!.Id);
}
[Fact]
public async Task OneShotStoresTakeOnceAndLeaveExpiredEntriesUnconsumed()
{
await using var scenario = await CreateScenarioAsync();
var expiresAt = Now.AddMinutes(1);
var transaction = CreateTransaction("state-hash", expiresAt);
await scenario.StateStore.PutAsync("ExternalSignIn", transaction.HandleHash, transaction, expiresAt);
var stateTaken = Assert.IsType<TakeResult<BrokerTransaction>.Taken>(await scenario.StateStore.TryTakeAsync<BrokerTransaction>("ExternalSignIn", transaction.HandleHash));
Assert.Equal(transaction.HandleHash, stateTaken.Value.HandleHash);
Assert.IsType<TakeResult<BrokerTransaction>.AlreadyConsumed>(await scenario.StateStore.TryTakeAsync<BrokerTransaction>("ExternalSignIn", transaction.HandleHash));
var grant = CreateGrant("grant-hash", expiresAt);
await scenario.GrantStore.SaveAsync(grant);
var grantTaken = Assert.IsType<TakeResult<AuthorizationGrant>.Taken>(await scenario.GrantStore.TryTakeAsync(grant.CodeHash));
Assert.Equal(grant.CodeHash, grantTaken.Value.CodeHash);
Assert.IsType<TakeResult<AuthorizationGrant>.AlreadyConsumed>(await scenario.GrantStore.TryTakeAsync(grant.CodeHash));
var preview = CreatePreview("preview-hash", "administrator-a", expiresAt);
await scenario.PreviewStore.SaveAsync(preview);
var previewTaken = Assert.IsType<TakeResult<PreviewResult>.Taken>(await scenario.PreviewStore.TryTakeAsync(preview.HandleHash, preview.AdministratorId));
Assert.Equal(preview.HandleHash, previewTaken.Value.HandleHash);
Assert.IsType<TakeResult<PreviewResult>.AlreadyConsumed>(await scenario.PreviewStore.TryTakeAsync(preview.HandleHash, preview.AdministratorId));
scenario.Clock.UtcNow = expiresAt;
var expiredTransaction = CreateTransaction("expired-state", expiresAt);
await scenario.StateStore.PutAsync("ExternalSignIn", expiredTransaction.HandleHash, expiredTransaction, expiresAt);
Assert.IsType<TakeResult<BrokerTransaction>.Expired>(await scenario.StateStore.TryTakeAsync<BrokerTransaction>("ExternalSignIn", expiredTransaction.HandleHash));
Assert.IsType<TakeResult<BrokerTransaction>.Expired>(await scenario.StateStore.TryTakeAsync<BrokerTransaction>("ExternalSignIn", expiredTransaction.HandleHash));
var expiredGrant = CreateGrant("expired-grant", expiresAt);
await scenario.GrantStore.SaveAsync(expiredGrant);
Assert.IsType<TakeResult<AuthorizationGrant>.Expired>(await scenario.GrantStore.TryTakeAsync(expiredGrant.CodeHash));
Assert.IsType<TakeResult<AuthorizationGrant>.Expired>(await scenario.GrantStore.TryTakeAsync(expiredGrant.CodeHash));
var expiredPreview = CreatePreview("expired-preview", "administrator-a", expiresAt);
await scenario.PreviewStore.SaveAsync(expiredPreview);
Assert.IsType<TakeResult<PreviewResult>.Expired>(await scenario.PreviewStore.TryTakeAsync(expiredPreview.HandleHash, expiredPreview.AdministratorId));
Assert.IsType<TakeResult<PreviewResult>.Expired>(await scenario.PreviewStore.TryTakeAsync(expiredPreview.HandleHash, expiredPreview.AdministratorId));
}
[Fact]
public async Task ConcurrentOneShotTakesAllowExactlyOneConsumerPerStore()
{
await using var scenario = await CreateScenarioAsync();
var expiresAt = Now.AddMinutes(1);
var transaction = CreateTransaction("concurrent-state", expiresAt);
await scenario.StateStore.PutAsync("ExternalSignIn", transaction.HandleHash, transaction, expiresAt);
var stateResults = await RunConcurrentlyAsync(scenario, ConformanceRacePoint.StateTake,
() => scenario.StateStore.TryTakeAsync<BrokerTransaction>("ExternalSignIn", transaction.HandleHash),
() => scenario.StateStore.TryTakeAsync<BrokerTransaction>("ExternalSignIn", transaction.HandleHash));
Assert.Single(stateResults.OfType<TakeResult<BrokerTransaction>.Taken>());
Assert.Single(stateResults.OfType<TakeResult<BrokerTransaction>.AlreadyConsumed>());
var grant = CreateGrant("concurrent-grant", expiresAt);
await scenario.GrantStore.SaveAsync(grant);
var grantResults = await RunConcurrentlyAsync(scenario, ConformanceRacePoint.AuthorizationGrantTake,
() => scenario.GrantStore.TryTakeAsync(grant.CodeHash),
() => scenario.GrantStore.TryTakeAsync(grant.CodeHash));
Assert.Single(grantResults.OfType<TakeResult<AuthorizationGrant>.Taken>());
Assert.Single(grantResults.OfType<TakeResult<AuthorizationGrant>.AlreadyConsumed>());
var preview = CreatePreview("concurrent-preview", "administrator-a", expiresAt);
await scenario.PreviewStore.SaveAsync(preview);
var previewResults = await RunConcurrentlyAsync(scenario, ConformanceRacePoint.PreviewTake,
() => scenario.PreviewStore.TryTakeAsync(preview.HandleHash, preview.AdministratorId),
() => scenario.PreviewStore.TryTakeAsync(preview.HandleHash, preview.AdministratorId));
Assert.Single(previewResults.OfType<TakeResult<PreviewResult>.Taken>());
Assert.Single(previewResults.OfType<TakeResult<PreviewResult>.AlreadyConsumed>());
}
[Fact]
public async Task ConnectionStoreEnforcesScopedDuplicateKeysAndRevisionCas()
{
await using var scenario = await CreateScenarioAsync();
var created = Assert.IsType<ConnectionMutationResult.Created>(await scenario.ConnectionStore.CreateAsync(CreateConnection("connection-a", "tenant-a", "contoso")));
Assert.Equal(1, created.Connection.Revision);
Assert.IsType<ConnectionMutationResult.DuplicateKey>(await scenario.ConnectionStore.CreateAsync(CreateConnection("connection-b", "tenant-a", "contoso")));
Assert.IsType<ConnectionMutationResult.Created>(await scenario.ConnectionStore.CreateAsync(CreateConnection("connection-c", "tenant-b", "contoso")));
created.Connection.DisplayName = "Updated";
var updated = Assert.IsType<ConnectionMutationResult.Updated>(await scenario.ConnectionStore.UpdateAsync(created.Connection, 1));
Assert.Equal(2, updated.Connection.Revision);
Assert.Equal("Updated", updated.Connection.DisplayName);
var conflict = Assert.IsType<ConnectionMutationResult.RevisionConflict>(await scenario.ConnectionStore.UpdateAsync(created.Connection, 1));
Assert.Equal(2, conflict.CurrentRevision);
}
[Fact]
public async Task IdentityLinksCreateIdempotentlyAndReplaceWithoutViolatingUniqueness()
{
await using var scenario = await CreateScenarioAsync();
var oldIdentity = Identity("subject-old");
var oldRequest = new ProvisioningRequest("tenant-a", "contoso", oldIdentity, null, "user-a");
var created = await scenario.IdentityProvisioner.CreateLinkOrGetExistingAsync(oldRequest);
var converged = await scenario.IdentityProvisioner.CreateLinkOrGetExistingAsync(oldRequest with { ExistingUserId = "user-b" });
Assert.True(created.WasLinkCreated);
Assert.False(converged.WasLinkCreated);
Assert.Equal(created.Link.Id, converged.Link.Id);
Assert.Equal("user-a", converged.UserId);
var conflicting = await scenario.IdentityProvisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", Identity("subject-conflict"), null, "user-b"));
var conflict = Assert.IsType<ExternalIdentityLinkReplaceResult.Conflict>(await scenario.IdentityProvisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", created.Link.Id, "user-a", "contoso", Identity("subject-conflict"))));
Assert.Equal(conflicting.Link.Id, conflict.ConflictingLink.Id);
Assert.Equal(created.Link.Id, conflict.OldLink.Id);
Assert.Equal(created.Link.Id, (await scenario.IdentityProvisioner.FindLinkAsync("tenant-a", "contoso", oldIdentity))!.Id);
var replaced = Assert.IsType<ExternalIdentityLinkReplaceResult.Success>(await scenario.IdentityProvisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", created.Link.Id, "user-b", "contoso", Identity("subject-new"))));
Assert.NotEqual(created.Link.Id, replaced.NewLink.Id);
Assert.Equal("user-b", replaced.NewLink.UserId);
Assert.Null(await scenario.IdentityProvisioner.FindLinkAsync("tenant-a", "contoso", oldIdentity));
Assert.Equal(replaced.NewLink.Id, (await scenario.IdentityProvisioner.FindLinkAsync("tenant-a", "contoso", Identity("subject-new")))!.Id);
}
[Fact]
public async Task ConcurrentIdentityLinkCreationConvergesOnOneWinner()
{
await using var scenario = await CreateScenarioAsync();
var request = new ProvisioningRequest("tenant-a", "contoso", Identity("subject-race"), null, "user-a");
var results = await RunConcurrentlyAsync(scenario, ConformanceRacePoint.IdentityLinkCreate,
() => scenario.IdentityProvisioner.CreateLinkOrGetExistingAsync(request),
() => scenario.IdentityProvisioner.CreateLinkOrGetExistingAsync(request));
Assert.Single(results, x => x.WasLinkCreated);
Assert.Single(results, x => !x.WasLinkCreated);
Assert.Single(results.Select(x => x.Link.Id).Distinct(StringComparer.Ordinal));
Assert.Equal(results[0].Link.Id, (await scenario.IdentityProvisioner.FindLinkAsync(request.TenantId, request.ConnectionKey, request.Identity))!.Id);
}
[Fact]
public async Task ConcurrentIdentityLinkReplacementUsesTheOldLinkAsAnAtomicGuard()
{
await using var scenario = await CreateScenarioAsync();
var old = (await scenario.IdentityProvisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", Identity("subject-old"), null, "user-a"))).Link;
// SQLite coordinates both initial reads before either caller starts the replacement transaction, proving that
// each guarded DELETE races from the same observed old-link state.
var results = await RunConcurrentlyAsync(scenario, ConformanceRacePoint.IdentityLinkReplace,
() => scenario.IdentityProvisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", Identity("subject-a"))),
() => scenario.IdentityProvisioner.ReplaceAsync(new ExternalIdentityLinkReplaceRequest("tenant-a", old.Id, "user-a", "contoso", Identity("subject-b"))));
Assert.Single(results.OfType<ExternalIdentityLinkReplaceResult.Success>());
Assert.Single(results.OfType<ExternalIdentityLinkReplaceResult.NotFound>());
var replacementA = await scenario.IdentityProvisioner.FindLinkAsync("tenant-a", "contoso", Identity("subject-a"));
var replacementB = await scenario.IdentityProvisioner.FindLinkAsync("tenant-a", "contoso", Identity("subject-b"));
Assert.True((replacementA is null) != (replacementB is null));
Assert.Null(await scenario.IdentityProvisioner.FindLinkAsync("tenant-a", "contoso", Identity("subject-old")));
}
[Fact]
public async Task ConcurrentRegistryVersionInitializationRecoversFromInsertRace()
{
await using var scenario = await CreateScenarioAsync();
var concurrentAdvances = await RunConcurrentlyAsync(scenario, ConformanceRacePoint.RegistryVersionInitialization,
() => scenario.RegistryVersionStore.AdvanceAsync(),
() => scenario.RegistryVersionStore.AdvanceAsync());
Assert.Equal([2L, 3L], concurrentAdvances.OrderBy(x => x).ToArray());
Assert.Equal(3L, await scenario.RegistryVersionStore.GetVersionAsync());
Assert.True(await scenario.RegistryVersionStore.IsCurrentAsync(3));
}
[Fact]
public async Task RegistryVersionStoreReportsPreviousVersionAsNotCurrentAfterAdvance()
{
await using var scenario = await CreateScenarioAsync();
var initial = await scenario.RegistryVersionStore.GetVersionAsync();
Assert.True(await scenario.RegistryVersionStore.IsCurrentAsync(initial));
var seeded = await scenario.RegistryVersionStore.AdvanceAsync();
Assert.Equal(initial + 1, seeded);
Assert.False(await scenario.RegistryVersionStore.IsCurrentAsync(initial));
Assert.True(await scenario.RegistryVersionStore.IsCurrentAsync(seeded));
var concurrentAdvances = await RunConcurrentlyAsync(scenario, ConformanceRacePoint.RegistryVersionAdvance,
() => scenario.RegistryVersionStore.AdvanceAsync(),
() => scenario.RegistryVersionStore.AdvanceAsync());
Assert.Equal([seeded + 1, seeded + 2], concurrentAdvances.OrderBy(x => x).ToArray());
var afterConcurrent = await scenario.RegistryVersionStore.GetVersionAsync();
Assert.Equal(seeded + 2, afterConcurrent);
Assert.True(await scenario.RegistryVersionStore.IsCurrentAsync(afterConcurrent));
var advanced = await scenario.RegistryVersionStore.AdvanceAsync();
Assert.Equal(afterConcurrent + 1, advanced);
Assert.False(await scenario.RegistryVersionStore.IsCurrentAsync(initial));
Assert.True(await scenario.RegistryVersionStore.IsCurrentAsync(advanced));
}
private static async Task<T[]> RunConcurrentlyAsync<T>(Func<ValueTask<T>> first, Func<ValueTask<T>> second)
{
var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var tasks = new[]
{
RunAfterStartAsync(start.Task, first),
RunAfterStartAsync(start.Task, second)
};
start.SetResult();
return await Task.WhenAll(tasks);
}
private static async Task<T[]> RunConcurrentlyAsync<T>(
ExternalAuthenticationStoreScenario scenario,
ConformanceRacePoint racePoint,
Func<ValueTask<T>> first,
Func<ValueTask<T>> second)
{
var race = scenario.Races.Arm(racePoint);
var operations = RunConcurrentlyAsync(first, second);
try
{
await race.BothParticipantsReached.WaitAsync(TimeSpan.FromSeconds(10));
race.ReleaseParticipants();
await race.BothMutationsReached.WaitAsync(TimeSpan.FromSeconds(10));
race.ReleaseMutations();
return await operations;
}
catch
{
race.Release();
// Drain the operations without masking the original coordination failure. SuppressThrowing is supported
// on Task, not Task<T>, so cast only for the await configuration.
await ((Task)operations).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
throw;
}
finally
{
scenario.Races.Disarm(race);
}
}
private static async Task<T> RunAfterStartAsync<T>(Task start, Func<ValueTask<T>> operation)
{
await start;
return await operation();
}
private static ExternalAuthenticationSession CreateSession(string id, string tenantId, string refreshTokenHash) => new()
{
Id = id,
AuthenticationClientId = "studio",
TenantId = tenantId,
UserId = "user-a",
ConnectionKey = "contoso",
ConnectionMaterialRevision = "revision-a",
Issuer = "https://issuer.example",
SubjectHash = "subject-hash",
StartedAt = Now,
LastRefreshedAt = Now,
ExpiresAt = Now.AddHours(1),
RefreshExpiresAt = Now.AddMinutes(30),
CurrentRefreshTokenHash = refreshTokenHash
};
private static BrokerTransaction CreateTransaction(string handleHash, DateTimeOffset expiresAt) => new()
{
HandleHash = handleHash,
Purpose = BrokerTransactionPurpose.ExternalSignIn,
ClientId = "studio",
CallbackUri = new Uri("https://studio.example/callback"),
ReturnPath = "/",
TenantId = "tenant-a",
PkceChallenge = "challenge",
ExpiresAt = expiresAt
};
private static AuthorizationGrant CreateGrant(string codeHash, DateTimeOffset expiresAt) => new()
{
CodeHash = codeHash,
ClientId = "studio",
CallbackUri = new Uri("https://studio.example/callback"),
TenantId = "tenant-a",
UserId = "user-a",
PkceChallenge = "challenge",
ExpiresAt = expiresAt
};
private static PreviewResult CreatePreview(string handleHash, string administratorId, DateTimeOffset expiresAt) => new(
handleHash,
administratorId,
"tenant-a",
"connection-a",
"revision-a",
"https://issuer.example",
"su***ct",
new Dictionary<string, IReadOnlyCollection<string>>(),
"allowed",
[],
[],
expiresAt,
null);
private static IdentityProviderConnection CreateConnection(string id, string tenantId, string key) => new()
{
Id = id,
TenantId = tenantId,
Key = key,
AdapterType = "openid-connect",
AdapterSettingsVersion = 1,
AdapterSettings = System.Text.Json.JsonDocument.Parse("{}").RootElement.Clone(),
DisplayName = key,
MaterialRevision = "revision-a",
CreatedAt = Now,
UpdatedAt = Now
};
private static ExternalIdentity Identity(string subject) => new("https://issuer.example", subject, new Dictionary<string, IReadOnlyCollection<string>>());
}
[CollectionDefinition(Name)]
public sealed class ExternalAuthenticationInMemoryConformanceCollection
{
public const string Name = "ExternalAuthentication:InMemory";
}
[CollectionDefinition(Name)]
public sealed class ExternalAuthenticationSqliteConformanceCollection
{
public const string Name = "ExternalAuthentication:EFCore.Sqlite";
}
[Collection(ExternalAuthenticationInMemoryConformanceCollection.Name)]
public sealed class InMemoryExternalAuthenticationStoreConformanceTests : ExternalAuthenticationStoreConformanceTests
{
protected override Task<ExternalAuthenticationStoreScenario> CreateScenarioAsync() => ExternalAuthenticationStoreScenario.CreateInMemoryAsync();
}
[Collection(ExternalAuthenticationSqliteConformanceCollection.Name)]
public sealed class SqliteExternalAuthenticationStoreConformanceTests : ExternalAuthenticationStoreConformanceTests
{
protected override Task<ExternalAuthenticationStoreScenario> CreateScenarioAsync() => ExternalAuthenticationStoreScenario.CreateSqliteAsync();
}
public enum ConformanceRacePoint
{
StateTake,
AuthorizationGrantTake,
PreviewTake,
IdentityLinkCreate,
IdentityLinkReplace,
RegistryVersionInitialization,
RegistryVersionAdvance
}
public sealed class ExternalAuthenticationStoreScenario(
ConformanceClock clock,
IExternalAuthenticationStateStore stateStore,
IAuthorizationGrantStore grantStore,
IExternalAuthenticationSessionStore sessionStore,
IPreviewResultStore previewStore,
IIdentityProviderConnectionStore connectionStore,
IExternalIdentityProvisioner identityProvisioner,
IConnectionRegistryVersionStore registryVersionStore,
Func<Func<Task>, Task> assertRefreshTokenConflictAsync,
ExternalAuthenticationStoreScenario.ConformanceRaceCoordinator races,
Func<ValueTask> disposeAsync) : IAsyncDisposable
{
public ConformanceClock Clock { get; } = clock;
public IExternalAuthenticationStateStore StateStore { get; } = stateStore;
public IAuthorizationGrantStore GrantStore { get; } = grantStore;
public IExternalAuthenticationSessionStore SessionStore { get; } = sessionStore;
public IPreviewResultStore PreviewStore { get; } = previewStore;
public IIdentityProviderConnectionStore ConnectionStore { get; } = connectionStore;
public IExternalIdentityProvisioner IdentityProvisioner { get; } = identityProvisioner;
public IConnectionRegistryVersionStore RegistryVersionStore { get; } = registryVersionStore;
internal ConformanceRaceCoordinator Races { get; } = races;
public Task AssertRefreshTokenConflictAsync(Func<Task> operation) => assertRefreshTokenConflictAsync(operation);
public ValueTask DisposeAsync() => disposeAsync();
public static async Task<ExternalAuthenticationStoreScenario> CreateInMemoryAsync()
{
var clock = new ConformanceClock();
var (users, userProvider) = await CreateUsersAsync();
var hasher = new HmacExternalAuthenticationHandleHasher();
var races = new ConformanceRaceCoordinator(false);
var provisioner = new InMemoryExternalIdentityProvisioner(
users,
userProvider,
Substitute.For<IRoleProvider>(),
new GuidIdentityGenerator(),
clock,
hasher,
new InMemoryExternalIdentityProvisionerState());
return new(
clock,
new InMemoryExternalAuthenticationStateStore(clock),
new InMemoryAuthorizationGrantStore(clock),
new InMemoryExternalAuthenticationSessionStore(clock),
new InMemoryPreviewResultStore(clock),
new InMemoryIdentityProviderConnectionStore(),
provisioner,
new InMemoryConnectionRegistryVersionStore(),
AssertInMemoryRefreshTokenConflictAsync,
races,
() =>
{
hasher.Dispose();
return ValueTask.CompletedTask;
});
}
public static async Task<ExternalAuthenticationStoreScenario> CreateSqliteAsync()
{
var databasePath = Path.Combine(Path.GetTempPath(), $"elsa-external-authentication-conformance-{Guid.NewGuid():N}.db");
var hasher = new HmacExternalAuthenticationHandleHasher();
var races = new ConformanceRaceCoordinator(true);
ServiceProvider? services = null;
var clock = new ConformanceClock();
try
{
var optionsBuilder = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>();
optionsBuilder.UseElsaDbContextOptions(null);
optionsBuilder.UseSqlite($"Data Source={databasePath};Default Timeout=30", sqlite => sqlite.MigrationsAssembly(typeof(Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.ExternalAuthenticationDbContextFactory).Assembly.FullName));
optionsBuilder.AddInterceptors(races);
var options = optionsBuilder.Options;
services = new ServiceCollection()
.AddSingleton<IDbContextFactory<ExternalAuthenticationElsaDbContext>>(serviceProvider => new TestDbContextFactory(options, serviceProvider))
.BuildServiceProvider();
var dbContextFactory = services.GetRequiredService<IDbContextFactory<ExternalAuthenticationElsaDbContext>>();
var leaseFactory = new ExternalAuthenticationDbContextLeaseFactory(services.GetRequiredService<IServiceScopeFactory>());
await using (var dbContext = await dbContextFactory.CreateDbContextAsync())
await dbContext.Database.EnsureCreatedAsync();
var (users, userProvider) = await CreateUsersAsync();
var provisioner = new EFCoreExternalIdentityProvisioner(
dbContextFactory,
users,
userProvider,
Substitute.For<IRoleProvider>(),
hasher,
new GuidIdentityGenerator(),
clock,
NullLogger<EFCoreExternalIdentityProvisioner>.Instance);
return new(
clock,
new EFCoreExternalAuthenticationStateStore(leaseFactory, clock),
new EFCoreAuthorizationGrantStore(leaseFactory, clock),
new EFCoreExternalAuthenticationSessionStore(leaseFactory, clock),
new EFCorePreviewResultStore(leaseFactory, clock),
new EFCoreIdentityProviderConnectionStore(leaseFactory),
provisioner,
new EFCoreConnectionRegistryVersionStore(leaseFactory),
AssertSqliteRefreshTokenConflictAsync,
races,
async () =>
{
hasher.Dispose();
await services!.DisposeAsync();
SqliteConnection.ClearAllPools();
File.Delete(databasePath);
});
}
catch
{
hasher.Dispose();
if (services is not null)
await services.DisposeAsync();
SqliteConnection.ClearAllPools();
File.Delete(databasePath);
throw;
}
}
private static Task AssertInMemoryRefreshTokenConflictAsync(Func<Task> operation) =>
Assert.ThrowsAsync<InvalidOperationException>(operation);
private static async Task AssertSqliteRefreshTokenConflictAsync(Func<Task> operation)
{
var exception = await Record.ExceptionAsync(operation);
var sqliteException = exception switch
{
DbUpdateException { InnerException: SqliteException inner } => inner,
SqliteException direct => direct,
_ => throw new Xunit.Sdk.XunitException($"Expected a SQLite uniqueness violation, received {exception?.GetType().FullName ?? "no exception"}.")
};
Assert.Equal(19, sqliteException.SqliteErrorCode);
Assert.Contains("UNIQUE constraint failed", sqliteException.Message, StringComparison.Ordinal);
Assert.Contains("ExternalAuthenticationSessionRefreshTokens.Hash", sqliteException.Message, StringComparison.Ordinal);
}
// SQLite uses reader-disposal boundaries to hold both callers after EF has consumed their results and before
// mutation. The in-memory implementations complete synchronously while holding their private lock, so there is no
// safe public boundary at which a test can pause both callers without changing production code; its completed handle
// still exercises the same shared outcomes.
public sealed class ConformanceRaceCoordinator(bool interceptCommands) : DbCommandInterceptor
{
private ConformanceRaceHandle? _activeRace;
public ConformanceRaceHandle Arm(ConformanceRacePoint point)
{
var race = interceptCommands ? new ConformanceRaceHandle(point) : ConformanceRaceHandle.Completed(point);
if (interceptCommands && Interlocked.CompareExchange(ref _activeRace, race, null) is not null)
throw new InvalidOperationException("A conformance race is already active.");
return race;
}
public void Disarm(ConformanceRaceHandle race) => Interlocked.CompareExchange(ref _activeRace, null, race);
public override InterceptionResult DataReaderDisposing(
DbCommand command,
DataReaderDisposingEventData eventData,
InterceptionResult result)
{
// EF calls this after CloseAsync, so the query result has been consumed and the provider reader is closed.
var race = Volatile.Read(ref _activeRace);
if (race is not null && race.Matches(command.CommandText))
race.ParticipantReachedAsync(CancellationToken.None).AsTask().GetAwaiter().GetResult();
return result;
}
public override async ValueTask<int> NonQueryExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
var race = Volatile.Read(ref _activeRace);
if (race is not null && race.MatchesMutation(command.CommandText))
await race.MutationReachedAsync(cancellationToken);
return result;
}
}
public sealed class ConformanceRaceHandle(ConformanceRacePoint point, bool completed = false)
{
private readonly TaskCompletionSource _bothParticipantsReached = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _bothMutationsReached = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _releaseParticipants = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _releaseMutations = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly bool _completed = completed;
private readonly bool _hasMutationBarrier = point == ConformanceRacePoint.RegistryVersionAdvance;
private int _participantCount;
private int _mutationCount;
public ConformanceRacePoint Point { get; } = point;
public Task BothParticipantsReached => _completed ? Task.CompletedTask : _bothParticipantsReached.Task;
public Task BothMutationsReached => _completed || !_hasMutationBarrier ? Task.CompletedTask : _bothMutationsReached.Task;
public static ConformanceRaceHandle Completed(ConformanceRacePoint point) => new(point, true);
public async ValueTask ParticipantReachedAsync(CancellationToken cancellationToken)
{
if (_completed)
return;
var participantNumber = Interlocked.Increment(ref _participantCount);
if (participantNumber == 2)
_bothParticipantsReached.TrySetResult();
if (participantNumber <= 2)
await _releaseParticipants.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken);
}
public async ValueTask MutationReachedAsync(CancellationToken cancellationToken)
{
if (_completed || !_hasMutationBarrier)
return;
var mutationNumber = Interlocked.Increment(ref _mutationCount);
if (mutationNumber == 2)
_bothMutationsReached.TrySetResult();
if (mutationNumber <= 2)
await _releaseMutations.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken);
}
public void ReleaseParticipants() => _releaseParticipants.TrySetResult();
public void ReleaseMutations() => _releaseMutations.TrySetResult();
public void Release()
{
ReleaseParticipants();
ReleaseMutations();
}
public bool Matches(string commandText) => Point switch
{
ConformanceRacePoint.StateTake => IsSelectFrom(commandText, "ExternalAuthenticationBrokerTransactions"),
ConformanceRacePoint.AuthorizationGrantTake => IsSelectFrom(commandText, "ExternalAuthenticationAuthorizationGrants"),
ConformanceRacePoint.PreviewTake => IsSelectFrom(commandText, "ExternalAuthenticationPreviewResults"),
ConformanceRacePoint.IdentityLinkCreate or ConformanceRacePoint.IdentityLinkReplace => IsSelectFrom(commandText, "ExternalIdentityLinks"),
ConformanceRacePoint.RegistryVersionInitialization or ConformanceRacePoint.RegistryVersionAdvance => IsSelectFrom(commandText, "ExternalAuthenticationRegistryVersions"),
_ => false
};
public bool MatchesMutation(string commandText) => Point == ConformanceRacePoint.RegistryVersionAdvance &&
commandText.Contains("UPDATE \"ExternalAuthenticationRegistryVersions\"", StringComparison.Ordinal);
private static bool IsSelectFrom(string commandText, string tableName) =>
commandText.Contains("SELECT", StringComparison.Ordinal) &&
commandText.Contains($"FROM \"{tableName}\"", StringComparison.Ordinal);
}
private static async Task<(MemoryUserStore Users, StoreBasedUserProvider Provider)> CreateUsersAsync()
{
var users = new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a"));
await users.SaveAsync(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" });
await users.SaveAsync(new User { Id = "user-b", Name = "bob", TenantId = "tenant-a" });
return (users, new StoreBasedUserProvider(users));
}
private sealed class TestDbContextFactory(DbContextOptions<ExternalAuthenticationElsaDbContext> options, IServiceProvider serviceProvider) : IDbContextFactory<ExternalAuthenticationElsaDbContext>
{
public ExternalAuthenticationElsaDbContext CreateDbContext() => new(options, serviceProvider);
public Task<ExternalAuthenticationElsaDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) => Task.FromResult(CreateDbContext());
}
}
public sealed class ConformanceClock : ISystemClock
{
public DateTimeOffset UtcNow { get; set; } = new(2026, 9, 13, 12, 0, 0, TimeSpan.Zero);
}