From b23a10c2a4b92d6c20075c564cdccb9bd5164aa6 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 13 Sep 2026 15:58:40 +0200 Subject: [PATCH] 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 --- .../Stores/EFCoreAtomicStores.cs | 40 +- .../EFCoreExternalIdentityProvisioner.cs | 4 +- ...rnalAuthenticationStoreConformanceTests.cs | 688 ++++++++++++++++++ 3 files changed, 723 insertions(+), 9 deletions(-) create mode 100644 test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationStoreConformanceTests.cs diff --git a/src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreAtomicStores.cs b/src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreAtomicStores.cs index 7b35e1380..8b950cb00 100644 --- a/src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreAtomicStores.cs +++ b/src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreAtomicStores.cs @@ -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 IsCurrentAsync(long version, CancellationToken cancellationToken = default) => await GetVersionAsync(cancellationToken) == version; diff --git a/src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreExternalIdentityProvisioner.cs b/src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreExternalIdentityProvisioner.cs index 922d6b525..408380601 100644 --- a/src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreExternalIdentityProvisioner.cs +++ b/src/modules/Elsa.ExternalAuthentication.Persistence.EFCore/Stores/EFCoreExternalIdentityProvisioner.cs @@ -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); diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationStoreConformanceTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationStoreConformanceTests.cs new file mode 100644 index 000000000..5f5e1cb83 --- /dev/null +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationStoreConformanceTests.cs @@ -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 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.Taken>(await scenario.StateStore.TryTakeAsync("ExternalSignIn", transaction.HandleHash)); + Assert.Equal(transaction.HandleHash, stateTaken.Value.HandleHash); + Assert.IsType.AlreadyConsumed>(await scenario.StateStore.TryTakeAsync("ExternalSignIn", transaction.HandleHash)); + + var grant = CreateGrant("grant-hash", expiresAt); + await scenario.GrantStore.SaveAsync(grant); + var grantTaken = Assert.IsType.Taken>(await scenario.GrantStore.TryTakeAsync(grant.CodeHash)); + Assert.Equal(grant.CodeHash, grantTaken.Value.CodeHash); + Assert.IsType.AlreadyConsumed>(await scenario.GrantStore.TryTakeAsync(grant.CodeHash)); + + var preview = CreatePreview("preview-hash", "administrator-a", expiresAt); + await scenario.PreviewStore.SaveAsync(preview); + var previewTaken = Assert.IsType.Taken>(await scenario.PreviewStore.TryTakeAsync(preview.HandleHash, preview.AdministratorId)); + Assert.Equal(preview.HandleHash, previewTaken.Value.HandleHash); + Assert.IsType.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.Expired>(await scenario.StateStore.TryTakeAsync("ExternalSignIn", expiredTransaction.HandleHash)); + Assert.IsType.Expired>(await scenario.StateStore.TryTakeAsync("ExternalSignIn", expiredTransaction.HandleHash)); + + var expiredGrant = CreateGrant("expired-grant", expiresAt); + await scenario.GrantStore.SaveAsync(expiredGrant); + Assert.IsType.Expired>(await scenario.GrantStore.TryTakeAsync(expiredGrant.CodeHash)); + Assert.IsType.Expired>(await scenario.GrantStore.TryTakeAsync(expiredGrant.CodeHash)); + + var expiredPreview = CreatePreview("expired-preview", "administrator-a", expiresAt); + await scenario.PreviewStore.SaveAsync(expiredPreview); + Assert.IsType.Expired>(await scenario.PreviewStore.TryTakeAsync(expiredPreview.HandleHash, expiredPreview.AdministratorId)); + Assert.IsType.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("ExternalSignIn", transaction.HandleHash), + () => scenario.StateStore.TryTakeAsync("ExternalSignIn", transaction.HandleHash)); + Assert.Single(stateResults.OfType.Taken>()); + Assert.Single(stateResults.OfType.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.Taken>()); + Assert.Single(grantResults.OfType.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.Taken>()); + Assert.Single(previewResults.OfType.AlreadyConsumed>()); + } + + [Fact] + public async Task ConnectionStoreEnforcesScopedDuplicateKeysAndRevisionCas() + { + await using var scenario = await CreateScenarioAsync(); + var created = Assert.IsType(await scenario.ConnectionStore.CreateAsync(CreateConnection("connection-a", "tenant-a", "contoso"))); + Assert.Equal(1, created.Connection.Revision); + Assert.IsType(await scenario.ConnectionStore.CreateAsync(CreateConnection("connection-b", "tenant-a", "contoso"))); + Assert.IsType(await scenario.ConnectionStore.CreateAsync(CreateConnection("connection-c", "tenant-b", "contoso"))); + + created.Connection.DisplayName = "Updated"; + var updated = Assert.IsType(await scenario.ConnectionStore.UpdateAsync(created.Connection, 1)); + Assert.Equal(2, updated.Connection.Revision); + Assert.Equal("Updated", updated.Connection.DisplayName); + var conflict = Assert.IsType(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(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(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()); + Assert.Single(results.OfType()); + 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 RunConcurrentlyAsync(Func> first, Func> 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 RunConcurrentlyAsync( + ExternalAuthenticationStoreScenario scenario, + ConformanceRacePoint racePoint, + Func> first, + Func> 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, so cast only for the await configuration. + await ((Task)operations).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + + throw; + } + finally + { + scenario.Races.Disarm(race); + } + } + + private static async Task RunAfterStartAsync(Task start, Func> 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>(), + "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>()); +} + +[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 CreateScenarioAsync() => ExternalAuthenticationStoreScenario.CreateInMemoryAsync(); +} + +[Collection(ExternalAuthenticationSqliteConformanceCollection.Name)] +public sealed class SqliteExternalAuthenticationStoreConformanceTests : ExternalAuthenticationStoreConformanceTests +{ + protected override Task 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, Task> assertRefreshTokenConflictAsync, + ExternalAuthenticationStoreScenario.ConformanceRaceCoordinator races, + Func 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 operation) => assertRefreshTokenConflictAsync(operation); + + public ValueTask DisposeAsync() => disposeAsync(); + + public static async Task 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(), + 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 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(); + 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>(serviceProvider => new TestDbContextFactory(options, serviceProvider)) + .BuildServiceProvider(); + var dbContextFactory = services.GetRequiredService>(); + var leaseFactory = new ExternalAuthenticationDbContextLeaseFactory(services.GetRequiredService()); + 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(), + hasher, + new GuidIdentityGenerator(), + clock, + NullLogger.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 operation) => + Assert.ThrowsAsync(operation); + + private static async Task AssertSqliteRefreshTokenConflictAsync(Func 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 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(), 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 options, IServiceProvider serviceProvider) : IDbContextFactory + { + public ExternalAuthenticationElsaDbContext CreateDbContext() => new(options, serviceProvider); + public Task 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); +}