2026-08-02 21:11:04 +00:00
using System.Data.Common ;
2026-07-24 16:59:17 +00:00
using System.Text.Json ;
using Elsa.Common ;
using Elsa.Common.Services ;
using Elsa.ExternalAuthentication.Contracts ;
using Elsa.ExternalAuthentication.Models ;
2026-07-31 12:31:18 +00:00
using Elsa.ExternalAuthentication.Persistence.EFCore ;
using Elsa.ExternalAuthentication.Persistence.EFCore.Stores ;
2026-07-24 16:59:17 +00:00
using Elsa.ExternalAuthentication.Services ;
2026-07-31 12:31:18 +00:00
using Elsa.Identity.Contracts ;
2026-07-26 21:40:17 +00:00
using Elsa.Identity.Entities ;
2026-07-31 12:31:18 +00:00
using Elsa.Identity.Models ;
using Elsa.Identity.Providers ;
using Elsa.Identity.Services ;
2026-08-02 01:35:39 +00:00
using Elsa.Persistence.EFCore ;
2026-07-24 16:59:17 +00:00
using Elsa.Workflows ;
using Microsoft.Data.Sqlite ;
using Microsoft.EntityFrameworkCore ;
2026-08-02 01:35:39 +00:00
using Microsoft.EntityFrameworkCore.Diagnostics ;
2026-07-24 16:59:17 +00:00
using Microsoft.Extensions.DependencyInjection ;
2026-07-31 12:31:18 +00:00
using Microsoft.Extensions.Logging.Abstractions ;
2026-07-25 00:35:56 +00:00
using NSubstitute ;
2026-07-24 16:59:17 +00:00
namespace Elsa.ExternalAuthentication.IntegrationTests.Persistence ;
public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
{
private SqliteConnection _connection = null ! ;
private ServiceProvider _services = null ! ;
private TestDbContextFactory _dbContextFactory = null ! ;
2026-07-31 12:31:18 +00:00
private ExternalAuthenticationDbContextLeaseFactory _leaseFactory = null ! ;
2026-07-24 16:59:17 +00:00
private ISystemClock _clock = null ! ;
2026-07-31 12:31:18 +00:00
private MemoryUserStore _userStore = null ! ;
private StoreBasedUserProvider _userProvider = null ! ;
2026-07-24 16:59:17 +00:00
public async Task InitializeAsync ( )
{
_connection = new SqliteConnection ( "Data Source=:memory:" ) ;
await _connection . OpenAsync ( ) ;
_clock = new SystemClock ( ) ;
2026-08-02 01:35:39 +00:00
var optionsBuilder = new DbContextOptionsBuilder < ExternalAuthenticationElsaDbContext > ( ) ;
optionsBuilder . UseElsaDbContextOptions ( null ) ;
optionsBuilder . UseSqlite ( _connection , sqlite = > sqlite . MigrationsAssembly ( typeof ( Elsa . ExternalAuthentication . Persistence . EFCore . Sqlite . ExternalAuthenticationDbContextFactory ) . Assembly . FullName ) ) ;
var options = optionsBuilder . Options ;
2026-07-24 16:59:17 +00:00
_services = new ServiceCollection ( )
2026-07-31 12:31:18 +00:00
. AddSingleton < IDbContextFactory < ExternalAuthenticationElsaDbContext > > ( serviceProvider = > new TestDbContextFactory ( options , serviceProvider ) )
2026-07-24 16:59:17 +00:00
. BuildServiceProvider ( ) ;
2026-07-31 12:31:18 +00:00
_dbContextFactory = _services . GetRequiredService < IDbContextFactory < ExternalAuthenticationElsaDbContext > > ( ) as TestDbContextFactory ? ? throw new InvalidOperationException ( ) ;
_leaseFactory = new ExternalAuthenticationDbContextLeaseFactory ( _services . GetRequiredService < IServiceScopeFactory > ( ) ) ;
_userStore = new MemoryUserStore ( new MemoryStore < User > ( ) ) ;
_userProvider = new StoreBasedUserProvider ( _userStore ) ;
2026-07-24 16:59:17 +00:00
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
await dbContext . Database . EnsureCreatedAsync ( ) ;
}
public async Task DisposeAsync ( )
{
await _services . DisposeAsync ( ) ;
await _connection . DisposeAsync ( ) ;
}
2026-08-02 01:35:39 +00:00
private EFCoreExternalIdentityProvisioner CreateProvisioner (
IExternalAuthenticationHandleHasher hasher ,
IDbContextFactory < ExternalAuthenticationElsaDbContext > ? dbContextFactory = null ,
IUserStore ? userStore = null ,
IUserProvider ? userProvider = null ) = >
2026-07-31 12:31:18 +00:00
new ( dbContextFactory ? ? _dbContextFactory ,
userStore ? ? _userStore ,
2026-08-02 01:35:39 +00:00
userProvider ? ? new StoreBasedUserProvider ( userStore ? ? _userStore ) ,
2026-07-31 12:31:18 +00:00
Substitute . For < IRoleProvider > ( ) ,
hasher ,
new GuidIdentityGenerator ( ) ,
_clock ,
NullLogger < EFCoreExternalIdentityProvisioner > . Instance ) ;
2026-07-24 16:59:17 +00:00
[Fact]
public async Task PersistsEveryDurableExternalAuthenticationAggregateWithTheRequiredIndexes ( )
{
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
var model = dbContext . Model ;
2026-07-31 12:31:18 +00:00
Assert . Contains ( dbContext . Database . GetMigrations ( ) , x = > x . EndsWith ( "_Initial" , StringComparison . Ordinal ) ) ;
2026-07-24 16:59:17 +00:00
Assert . Contains ( model . GetEntityTypes ( ) , x = > x . ClrType = = typeof ( PersistedIdentityProviderConnection ) ) ;
Assert . Contains ( model . GetEntityTypes ( ) , x = > x . ClrType = = typeof ( PersistedExternalIdentityLink ) ) ;
Assert . Contains ( model . GetEntityTypes ( ) , x = > x . ClrType = = typeof ( PersistedBrokerTransaction ) ) ;
Assert . Contains ( model . GetEntityTypes ( ) , x = > x . ClrType = = typeof ( PersistedAuthorizationGrant ) ) ;
Assert . Contains ( model . GetEntityTypes ( ) , x = > x . ClrType = = typeof ( PersistedExternalAuthenticationSession ) ) ;
2026-08-02 01:35:39 +00:00
Assert . Contains ( model . GetEntityTypes ( ) , x = > x . ClrType = = typeof ( PersistedExternalAuthenticationRefreshToken ) ) ;
2026-07-24 16:59:17 +00:00
Assert . Contains ( model . GetEntityTypes ( ) , x = > x . ClrType = = typeof ( PersistedConnectionObservation ) ) ;
Assert . Contains ( model . GetEntityTypes ( ) , x = > x . ClrType = = typeof ( PersistedPreviewResult ) ) ;
Assert . Contains ( model . GetEntityTypes ( ) , x = > x . ClrType = = typeof ( ExternalAuthenticationRegistryVersion ) ) ;
var connection = model . FindEntityType ( typeof ( PersistedIdentityProviderConnection ) ) ! ;
Assert . True ( connection . FindProperty ( nameof ( PersistedIdentityProviderConnection . Revision ) ) ! . IsConcurrencyToken ) ;
Assert . Contains ( connection . GetIndexes ( ) , x = > x . IsUnique & & x . Properties . Select ( p = > p . Name ) . SequenceEqual ( [ nameof ( PersistedIdentityProviderConnection . TenantId ) , nameof ( PersistedIdentityProviderConnection . Key ) ] ) ) ;
var link = model . FindEntityType ( typeof ( PersistedExternalIdentityLink ) ) ! ;
2026-07-25 00:35:56 +00:00
Assert . Contains ( link . GetIndexes ( ) , x = > x . IsUnique & & x . Properties . Select ( p = > p . Name ) . SequenceEqual ( [ nameof ( PersistedExternalIdentityLink . TenantId ) , nameof ( PersistedExternalIdentityLink . ConnectionKey ) , nameof ( PersistedExternalIdentityLink . Issuer ) , nameof ( PersistedExternalIdentityLink . SubjectHash ) ] ) ) ;
2026-08-02 01:35:39 +00:00
var refreshToken = model . FindEntityType ( typeof ( PersistedExternalAuthenticationRefreshToken ) ) ! ;
Assert . Contains ( refreshToken . GetIndexes ( ) , x = > x . IsUnique & & x . Properties . Select ( p = > p . Name ) . SequenceEqual ( [ nameof ( PersistedExternalAuthenticationRefreshToken . Hash ) ] ) ) ;
}
[Fact]
public async Task SqliteInitialMigrationCreatesTheOptionalRefreshTokenTable ( )
{
await using var connection = new SqliteConnection ( "Data Source=:memory:" ) ;
await connection . OpenAsync ( ) ;
var optionsBuilder = new DbContextOptionsBuilder < ExternalAuthenticationElsaDbContext > ( ) ;
optionsBuilder . UseElsaDbContextOptions ( null ) ;
optionsBuilder . UseSqlite ( connection , sqlite = > sqlite . MigrationsAssembly ( typeof ( Elsa . ExternalAuthentication . Persistence . EFCore . Sqlite . ExternalAuthenticationDbContextFactory ) . Assembly . FullName ) ) ;
var options = optionsBuilder . Options ;
await using var services = new ServiceCollection ( ) . BuildServiceProvider ( ) ;
await using var dbContext = new ExternalAuthenticationElsaDbContext ( options , services ) ;
await dbContext . Database . MigrateAsync ( ) ;
Assert . Single ( await dbContext . Database . GetAppliedMigrationsAsync ( ) ) ;
await using var command = connection . CreateCommand ( ) ;
command . CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'ExternalAuthenticationSessionRefreshTokens'" ;
Assert . Equal ( 1L , ( long ) ( await command . ExecuteScalarAsync ( ) ) ! ) ;
command . CommandText = "SELECT COUNT(*) FROM pragma_table_info('ExternalAuthenticationSessions') WHERE name = 'CurrentRefreshTokenHash'" ;
Assert . Equal ( 0L , ( long ) ( await command . ExecuteScalarAsync ( ) ) ! ) ;
2026-07-24 16:59:17 +00:00
}
2026-07-31 12:31:18 +00:00
[Fact]
public async Task ExternalAuthenticationModelDoesNotReachIntoTheIdentityAggregate ( )
{
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
var model = dbContext . Model ;
// External authentication owns its own database context, so the identity aggregate must not leak into it.
// ExternalIdentityLink.UserId is resolved through IUserProvider/IUserStore instead of a foreign key.
Assert . DoesNotContain ( model . GetEntityTypes ( ) , x = > x . ClrType = = typeof ( User ) ) ;
Assert . Empty ( model . FindEntityType ( typeof ( PersistedExternalIdentityLink ) ) ! . GetForeignKeys ( ) ) ;
}
2026-07-24 16:59:17 +00:00
[Fact]
public async Task ConnectionStoreEnforcesUniqueScopeKeysAndOptimisticConcurrency ( )
{
2026-07-31 12:31:18 +00:00
var store = new EFCoreIdentityProviderConnectionStore ( _leaseFactory ) ;
2026-07-24 16:59:17 +00:00
var created = Assert . IsType < ConnectionMutationResult . Created > ( await store . CreateAsync ( CreateConnection ( ) ) ) ;
Assert . Equal ( 1 , created . Connection . Revision ) ;
Assert . IsType < ConnectionMutationResult . DuplicateKey > ( await store . CreateAsync ( CreateConnection ( "connection-b" ) ) ) ;
created . Connection . DisplayName = "Updated" ;
var updated = Assert . IsType < ConnectionMutationResult . Updated > ( await store . UpdateAsync ( created . Connection , 1 ) ) ;
Assert . Equal ( 2 , updated . Connection . Revision ) ;
Assert . Equal ( "Updated" , updated . Connection . DisplayName ) ;
Assert . Equal ( 2 , Assert . IsType < ConnectionMutationResult . RevisionConflict > ( await store . UpdateAsync ( created . Connection , 1 ) ) . CurrentRevision ) ;
}
[Fact]
public async Task DurableStateGrantSessionAndRegistryVersionOperationsAreSingleUseOrCompareAndSwap ( )
{
2026-07-31 12:31:18 +00:00
var durableDbContexts = _leaseFactory ;
2026-07-24 16:59:17 +00:00
var stateStore = new EFCoreExternalAuthenticationStateStore ( durableDbContexts , _clock ) ;
var transaction = new BrokerTransaction { HandleHash = "state" , Purpose = BrokerTransactionPurpose . ExternalSignIn , ClientId = "studio" , CallbackUri = new Uri ( "https://studio.example/callback" ) , ReturnPath = "/" , TenantId = "tenant-a" , PkceChallenge = "challenge" , ExpiresAt = _clock . UtcNow . AddMinutes ( 1 ) } ;
await stateStore . PutAsync ( "ExternalSignIn" , "state" , transaction , transaction . ExpiresAt ) ;
Assert . IsType < TakeResult < BrokerTransaction > . Taken > ( await stateStore . TryTakeAsync < BrokerTransaction > ( "ExternalSignIn" , "state" ) ) ;
Assert . IsType < TakeResult < BrokerTransaction > . AlreadyConsumed > ( await stateStore . TryTakeAsync < BrokerTransaction > ( "ExternalSignIn" , "state" ) ) ;
var grantStore = new EFCoreAuthorizationGrantStore ( durableDbContexts , _clock ) ;
await grantStore . SaveAsync ( new AuthorizationGrant { CodeHash = "code" , ClientId = "studio" , CallbackUri = new Uri ( "https://studio.example/callback" ) , TenantId = "tenant-a" , UserId = "user-a" , PkceChallenge = "challenge" , ExpiresAt = _clock . UtcNow . AddMinutes ( 1 ) } ) ;
Assert . IsType < TakeResult < AuthorizationGrant > . Taken > ( await grantStore . TryTakeAsync ( "code" ) ) ;
Assert . IsType < TakeResult < AuthorizationGrant > . AlreadyConsumed > ( await grantStore . TryTakeAsync ( "code" ) ) ;
var sessionStore = new EFCoreExternalAuthenticationSessionStore ( durableDbContexts , _clock ) ;
await sessionStore . SaveAsync ( CreateSession ( ) ) ;
Assert . IsType < ExternalAuthenticationSessionRotationResult . Rotated > ( await sessionStore . TryRotateRefreshTokenAsync ( "session-a" , "refresh-a" , 0 , "refresh-b" , _clock . UtcNow ) ) ;
2026-08-02 01:35:39 +00:00
Assert . Null ( await sessionStore . FindByRefreshTokenHashAsync ( "refresh-a" ) ) ;
Assert . Equal ( "session-a" , ( await sessionStore . FindByRefreshTokenHashAsync ( "refresh-b" ) ) ! . Id ) ;
2026-07-24 16:59:17 +00:00
Assert . IsType < ExternalAuthenticationSessionRotationResult . Reused > ( await sessionStore . TryRotateRefreshTokenAsync ( "session-a" , "refresh-a" , 0 , "refresh-c" , _clock . UtcNow ) ) ;
var firstNode = new EFCoreConnectionRegistryVersionStore ( durableDbContexts ) ;
var secondNode = new EFCoreConnectionRegistryVersionStore ( durableDbContexts ) ;
Assert . Equal ( 1 , await firstNode . GetVersionAsync ( ) ) ;
var version = await firstNode . AdvanceAsync ( ) ;
Assert . True ( await secondNode . IsCurrentAsync ( version ) ) ;
}
2026-07-29 23:27:26 +00:00
[Fact]
public async Task DurableStateStoreRoundTripsRelativePreviewCallbackUris ( )
{
2026-07-31 12:31:18 +00:00
var stateStore = new EFCoreExternalAuthenticationStateStore ( _leaseFactory , _clock ) ;
2026-07-29 23:27:26 +00:00
var transaction = new BrokerTransaction
{
HandleHash = "preview-state" ,
Purpose = BrokerTransactionPurpose . Preview ,
ClientId = "administrator" ,
CallbackUri = new Uri ( "/external-authentication/previews/preview-handle/authorize" , UriKind . Relative ) ,
ReturnPath = "/" ,
TenantId = "tenant-a" ,
ConnectionId = "connection-a" ,
ConnectionMaterialRevision = "revision-a" ,
PkceChallenge = string . Empty ,
ExpiresAt = _clock . UtcNow . AddMinutes ( 1 )
} ;
await stateStore . PutAsync ( "PreviewStart" , transaction . HandleHash , transaction , transaction . ExpiresAt ) ;
var stored = Assert . IsType < TakeResult < BrokerTransaction > . Taken > (
await stateStore . TryTakeAsync < BrokerTransaction > ( "PreviewStart" , transaction . HandleHash ) ) ;
Assert . False ( stored . Value . CallbackUri . IsAbsoluteUri ) ;
Assert . Equal ( transaction . CallbackUri , stored . Value . CallbackUri ) ;
}
2026-07-25 01:48:50 +00:00
[Fact]
public async Task DurableSingleUseStoresRejectExpiredEntriesInTheAtomicConsumePredicate ( )
{
2026-07-31 12:31:18 +00:00
var durableDbContexts = _leaseFactory ;
2026-07-25 01:48:50 +00:00
var beforeExpiry = new DateTimeOffset ( 2026 , 1 , 1 , 0 , 0 , 0 , TimeSpan . Zero ) ;
var afterExpiry = beforeExpiry . AddMinutes ( 2 ) ;
var expiresAt = beforeExpiry . AddMinutes ( 1 ) ;
var stateStore = new EFCoreExternalAuthenticationStateStore ( durableDbContexts , new SteppingSystemClock ( afterExpiry ) ) ;
await stateStore . PutAsync ( "state" , "state" , new BrokerTransaction { HandleHash = "state" , Purpose = BrokerTransactionPurpose . ExternalSignIn , ClientId = "studio" , CallbackUri = new Uri ( "https://studio.example/callback" ) , ReturnPath = "/" , TenantId = "tenant-a" , PkceChallenge = "challenge" , ExpiresAt = expiresAt } , expiresAt ) ;
Assert . IsType < TakeResult < BrokerTransaction > . Expired > ( await stateStore . TryTakeAsync < BrokerTransaction > ( "state" , "state" ) ) ;
var grantStore = new EFCoreAuthorizationGrantStore ( durableDbContexts , new SteppingSystemClock ( afterExpiry ) ) ;
await grantStore . SaveAsync ( new AuthorizationGrant { CodeHash = "grant" , ClientId = "studio" , CallbackUri = new Uri ( "https://studio.example/callback" ) , TenantId = "tenant-a" , UserId = "user-a" , PkceChallenge = "challenge" , ExpiresAt = expiresAt } ) ;
Assert . IsType < TakeResult < AuthorizationGrant > . Expired > ( await grantStore . TryTakeAsync ( "grant" ) ) ;
var previewStore = new EFCorePreviewResultStore ( durableDbContexts , new SteppingSystemClock ( afterExpiry ) ) ;
await previewStore . SaveAsync ( new PreviewResult ( "preview" , "admin-a" , "tenant-a" , "connection-a" , "revision-a" , "https://issuer.example" , "subject" , new Dictionary < string , IReadOnlyCollection < string > > ( ) , "allowed" , [ ] , [ ] , expiresAt , null ) ) ;
Assert . IsType < TakeResult < PreviewResult > . Expired > ( await previewStore . TryTakeAsync ( "preview" , "admin-a" ) ) ;
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
Assert . Null ( ( await dbContext . ExternalAuthenticationBrokerTransactions . SingleAsync ( x = > x . HandleHash = = "state" ) ) . ConsumedAt ) ;
Assert . Null ( ( await dbContext . ExternalAuthenticationAuthorizationGrants . SingleAsync ( x = > x . CodeHash = = "grant" ) ) . ConsumedAt ) ;
Assert . Null ( ( await dbContext . ExternalAuthenticationPreviewResults . SingleAsync ( x = > x . HandleHash = = "preview" ) ) . ConsumedAt ) ;
}
2026-07-24 16:59:17 +00:00
[Fact]
public async Task ProvisionerCreatesCredentiallessUserAndOneDurableLinkPerIdentityTuple ( )
{
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
2026-07-31 12:31:18 +00:00
var provisioner = CreateProvisioner ( hasher ) ;
2026-07-24 16:59:17 +00:00
var request = new ProvisioningRequest ( "tenant-a" , "connection-a" , new ExternalIdentity ( "https://issuer.example" , "subject-a" , new Dictionary < string , IReadOnlyCollection < string > > ( ) ) , new UserCreationProposal ( "external" ) ) ;
var created = await provisioner . CreateLinkOrGetExistingAsync ( request ) ;
var converged = await provisioner . CreateLinkOrGetExistingAsync ( request ) ;
Assert . True ( created . WasCreated ) ;
Assert . False ( converged . WasCreated ) ;
Assert . Equal ( created . Link . Id , converged . Link . Id ) ;
2026-07-31 12:31:18 +00:00
var user = Assert . Single ( await _userStore . FindManyAsync ( new UserFilter ( ) ) ) ;
2026-07-24 16:59:17 +00:00
Assert . Null ( user . HashedPassword ) ;
Assert . Null ( user . HashedPasswordSalt ) ;
2026-07-31 12:31:18 +00:00
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
Assert . Single ( await dbContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
}
2026-08-03 21:46:38 +00:00
[Fact]
public async Task ProvisionerPersistsTheLatestSuccessfulSignInTimestamp ( )
{
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
var provisioner = CreateProvisioner ( hasher ) ;
var identity = new ExternalIdentity ( "https://issuer.example" , "subject-a" , EmptyClaims ) ;
var request = new ProvisioningRequest ( "tenant-a" , "connection-a" , identity , new UserCreationProposal ( "external" ) ) ;
var created = await provisioner . CreateLinkOrGetExistingAsync ( request ) ;
var firstSignInAt = new DateTimeOffset ( 2026 , 7 , 26 , 10 , 0 , 0 , TimeSpan . Zero ) ;
var latestSignInAt = firstSignInAt . AddMinutes ( 1 ) ;
Assert . True ( await provisioner . RecordSuccessfulSignInAsync ( "tenant-a" , "connection-a" , identity , created . UserId , firstSignInAt ) ) ;
Assert . True ( await provisioner . RecordSuccessfulSignInAsync ( "tenant-a" , "connection-a" , identity , created . UserId , latestSignInAt ) ) ;
Assert . True ( await provisioner . RecordSuccessfulSignInAsync ( "tenant-a" , "connection-a" , identity , created . UserId , firstSignInAt ) ) ;
var persisted = await CreateProvisioner ( hasher ) . FindLinkAsync ( "tenant-a" , "connection-a" , identity ) ;
Assert . Equal ( latestSignInAt , persisted ! . LastSignedInAt ) ;
}
[Fact]
public async Task ConcurrentSignInsPreserveTheLatestTimestamp ( )
{
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
var provisioner = CreateProvisioner ( hasher ) ;
var identity = new ExternalIdentity ( "https://issuer.example" , "subject-a" , EmptyClaims ) ;
var created = await provisioner . CreateLinkOrGetExistingAsync (
new ProvisioningRequest ( "tenant-a" , "connection-a" , identity , new UserCreationProposal ( "external" ) ) ) ;
var signInTimes = Enumerable . Range ( 0 , 8 )
. Select ( minutes = > new DateTimeOffset ( 2026 , 7 , 26 , 10 , minutes , 0 , TimeSpan . Zero ) )
. ToArray ( ) ;
var results = await Task . WhenAll ( signInTimes . Select ( async signedInAt = >
await CreateProvisioner ( hasher ) . RecordSuccessfulSignInAsync ( "tenant-a" , "connection-a" , identity , created . UserId , signedInAt ) ) ) ;
Assert . All ( results , Assert . True ) ;
var persisted = await CreateProvisioner ( hasher ) . FindLinkAsync ( "tenant-a" , "connection-a" , identity ) ;
Assert . Equal ( signInTimes . Max ( ) , persisted ! . LastSignedInAt ) ;
}
2026-07-31 12:31:18 +00:00
[Fact]
public async Task ProvisionerRemovesTheJustInTimeUserThatLosesTheLinkRace ( )
{
2026-08-02 01:35:39 +00:00
var databasePath = Path . Combine ( Path . GetTempPath ( ) , $"elsa-external-identity-provisioning-{Guid.NewGuid():N}.db" ) ;
await using var services = new ServiceCollection ( ) . BuildServiceProvider ( ) ;
try
{
var options = new DbContextOptionsBuilder < ExternalAuthenticationElsaDbContext > ( )
. UseSqlite ( $"Data Source={databasePath};Default Timeout=30" )
. Options ;
var factory = new TestDbContextFactory ( options , services ) ;
await using ( var dbContext = await factory . CreateDbContextAsync ( ) )
await dbContext . Database . EnsureCreatedAsync ( ) ;
var durableUsers = new MemoryUserStore ( new MemoryStore < User > ( ) ) ;
var coordinatedUsers = new CoordinatedUserStore ( durableUsers , 2 ) ;
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
var firstNode = CreateProvisioner ( hasher , factory , coordinatedUsers ) ;
var secondNode = CreateProvisioner ( hasher , factory , coordinatedUsers ) ;
var request = new ProvisioningRequest ( "tenant-a" , "connection-a" , new ExternalIdentity ( "https://issuer.example" , "subject-race" , EmptyClaims ) , new UserCreationProposal ( "external" ) ) ;
var results = await Task . WhenAll (
firstNode . CreateLinkOrGetExistingAsync ( request ) . AsTask ( ) ,
secondNode . CreateLinkOrGetExistingAsync ( request ) . AsTask ( ) ) ;
Assert . Single ( results , x = > x . WasCreated ) ;
Assert . Single ( results , x = > ! x . WasCreated ) ;
Assert . Single ( results . Select ( x = > x . Link . Id ) . Distinct ( StringComparer . Ordinal ) ) ;
var user = Assert . Single ( await durableUsers . FindManyAsync ( new UserFilter ( ) ) ) ;
Assert . Equal ( results [ 0 ] . UserId , user . Id ) ;
Assert . Equal ( results [ 1 ] . UserId , user . Id ) ;
await using var verificationContext = await factory . CreateDbContextAsync ( ) ;
Assert . Single ( await verificationContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
}
finally
{
SqliteConnection . ClearAllPools ( ) ;
File . Delete ( databasePath ) ;
}
}
[Fact]
public async Task ProvisionerRemovesTheJustInTimeUserWhenLinkPersistenceFails ( )
{
var options = new DbContextOptionsBuilder < ExternalAuthenticationElsaDbContext > ( )
. UseSqlite ( _connection )
. AddInterceptors ( new FailingLinkSaveInterceptor ( ) )
. Options ;
var provisioner = CreateProvisioner (
new HmacExternalAuthenticationHandleHasher ( ) ,
new TestDbContextFactory ( options , _services ) ) ;
var request = new ProvisioningRequest (
"tenant-a" ,
"connection-a" ,
new ExternalIdentity ( "https://issuer.example" , "subject-link-failure" , EmptyClaims ) ,
new UserCreationProposal ( "external" ) ) ;
await Assert . ThrowsAsync < DbUpdateException > ( ( ) = > provisioner . CreateLinkOrGetExistingAsync ( request ) . AsTask ( ) ) ;
Assert . Empty ( await _userStore . FindManyAsync ( new UserFilter ( ) ) ) ;
}
2026-08-02 21:11:04 +00:00
[Fact]
public async Task ProvisionerRemovesTheJustInTimeUserWhenPublicationIsCancelled ( )
{
using var cancellationTokenSource = new CancellationTokenSource ( ) ;
var users = new CancelAfterSaveUserStore ( new MemoryUserStore ( new MemoryStore < User > ( ) ) , cancellationTokenSource ) ;
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
var provisioner = CreateProvisioner ( hasher , userStore : users ) ;
var request = new ProvisioningRequest (
"tenant-a" ,
"connection-a" ,
new ExternalIdentity ( "https://issuer.example" , "subject-cancelled-publication" , EmptyClaims ) ,
new UserCreationProposal ( "external" ) ) ;
await Assert . ThrowsAnyAsync < OperationCanceledException > ( ( ) = >
provisioner . CreateLinkOrGetExistingAsync ( request , cancellationTokenSource . Token ) . AsTask ( ) ) ;
Assert . Empty ( await users . FindManyAsync ( new UserFilter ( ) ) ) ;
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
Assert . Empty ( await dbContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
}
2026-08-02 01:35:39 +00:00
[Fact]
public async Task ProvisionerFailsWhenAJustInTimeUserCannotBeCompensated ( )
{
var options = new DbContextOptionsBuilder < ExternalAuthenticationElsaDbContext > ( )
. UseSqlite ( _connection )
. AddInterceptors ( new FailingLinkSaveInterceptor ( ) )
. Options ;
var userStore = new DeleteFailingUserStore ( new MemoryUserStore ( new MemoryStore < User > ( ) ) ) ;
var provisioner = CreateProvisioner (
new HmacExternalAuthenticationHandleHasher ( ) ,
new TestDbContextFactory ( options , _services ) ,
userStore ) ;
var request = new ProvisioningRequest (
"tenant-a" ,
"connection-a" ,
new ExternalIdentity ( "https://issuer.example" , "subject-compensation-failure" , EmptyClaims ) ,
new UserCreationProposal ( "external" ) ) ;
var exception = await Assert . ThrowsAsync < AggregateException > ( ( ) = > provisioner . CreateLinkOrGetExistingAsync ( request ) . AsTask ( ) ) ;
Assert . Contains ( "No credentials were issued" , exception . Message , StringComparison . Ordinal ) ;
Assert . Single ( await userStore . FindManyAsync ( new UserFilter ( ) ) ) ;
}
[Fact]
public async Task ProvisionerRemovesTheLinkWhenUserDeletionWinsTheRace ( )
{
var users = new MemoryUserStore ( new MemoryStore < User > ( ) ) ;
var options = new DbContextOptionsBuilder < ExternalAuthenticationElsaDbContext > ( )
. UseSqlite ( _connection )
. AddInterceptors ( new DeleteLinkedUserBeforeCommitInterceptor ( users ) )
. Options ;
var provisioner = CreateProvisioner (
new HmacExternalAuthenticationHandleHasher ( ) ,
new TestDbContextFactory ( options , _services ) ,
users ) ;
var request = new ProvisioningRequest (
"tenant-a" ,
"connection-a" ,
new ExternalIdentity ( "https://issuer.example" , "subject-user-deletion-race" , EmptyClaims ) ,
new UserCreationProposal ( "external" ) ) ;
await Assert . ThrowsAsync < InvalidOperationException > ( ( ) = > provisioner . CreateLinkOrGetExistingAsync ( request ) . AsTask ( ) ) ;
Assert . Empty ( await users . FindManyAsync ( new UserFilter ( ) ) ) ;
2026-07-31 12:31:18 +00:00
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
2026-08-02 01:35:39 +00:00
Assert . Empty ( await dbContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
2026-07-24 16:59:17 +00:00
}
2026-08-02 21:11:04 +00:00
[Fact]
public async Task ProvisionerReconcilesAmbiguousPublicationForExistingUser ( )
{
await _userStore . SaveAsync ( new User { Id = "user-a" , Name = "alice" , TenantId = "tenant-a" } ) ;
var options = new DbContextOptionsBuilder < ExternalAuthenticationElsaDbContext > ( )
. UseSqlite ( _connection )
. AddInterceptors ( new DeleteLinkedUserAfterSaveAndThrowInterceptor ( _userStore ) )
. Options ;
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
var provisioner = CreateProvisioner ( hasher , new TestDbContextFactory ( options , _services ) ) ;
var request = new ProvisioningRequest (
"tenant-a" ,
"connection-a" ,
new ExternalIdentity ( "https://issuer.example" , "subject-ambiguous-existing-user" , EmptyClaims ) ,
null ,
"user-a" ) ;
await Assert . ThrowsAsync < InvalidOperationException > ( ( ) = > provisioner . CreateLinkOrGetExistingAsync ( request ) . AsTask ( ) ) ;
Assert . Null ( await _userStore . FindAsync ( new UserFilter { Id = "user-a" } ) ) ;
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
Assert . Empty ( await dbContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
}
2026-07-26 21:40:17 +00:00
[Fact]
public async Task ProvisionerAtomicallyReplacesLinksAndPreservesTheOldLinkOnConflict ( )
{
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
2026-07-31 12:31:18 +00:00
var provisioner = CreateProvisioner ( hasher ) ;
await _userStore . SaveAsync ( new User { Id = "user-a" , Name = "alice" , TenantId = "tenant-a" } ) ;
await _userStore . SaveAsync ( new User { Id = "user-b" , Name = "bob" , TenantId = "tenant-a" } ) ;
2026-07-26 21:40:17 +00:00
var old = ( await provisioner . CreateLinkOrGetExistingAsync ( new ProvisioningRequest ( "tenant-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-old" , EmptyClaims ) , null , "user-a" ) ) ) . Link ;
var conflicting = ( await provisioner . CreateLinkOrGetExistingAsync ( new ProvisioningRequest ( "tenant-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-conflict" , EmptyClaims ) , null , "user-b" ) ) ) . Link ;
var conflict = Assert . IsType < ExternalIdentityLinkReplaceResult . Conflict > ( await provisioner . ReplaceAsync ( new ExternalIdentityLinkReplaceRequest ( "tenant-a" , old . Id , "user-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-conflict" , EmptyClaims ) ) ) ) ;
Assert . Equal ( conflicting . Id , conflict . ConflictingLink . Id ) ;
Assert . IsType < ExternalIdentityLinkReplaceResult . NotFound > ( await provisioner . ReplaceAsync ( new ExternalIdentityLinkReplaceRequest ( "tenant-b" , old . Id , "user-b" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "cross-tenant" , EmptyClaims ) ) ) ) ;
await using ( var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) )
{
Assert . Contains ( await dbContext . ExternalIdentityLinks . ToListAsync ( ) , x = > x . Id = = old . Id ) ;
}
var sameTupleReplacement = Assert . IsType < ExternalIdentityLinkReplaceResult . Success > (
await provisioner . ReplaceAsync ( new ExternalIdentityLinkReplaceRequest ( "tenant-a" , old . Id , "user-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-old" , EmptyClaims ) ) ) ) ;
Assert . NotEqual ( old . Id , sameTupleReplacement . NewLink . Id ) ;
var replaced = Assert . IsType < ExternalIdentityLinkReplaceResult . Success > ( await provisioner . ReplaceAsync ( new ExternalIdentityLinkReplaceRequest ( "tenant-a" , sameTupleReplacement . NewLink . Id , "user-b" , "fabrikam" , new ExternalIdentity ( "https://replacement.example" , "subject-new" , EmptyClaims ) ) ) ) ;
Assert . NotEqual ( sameTupleReplacement . NewLink . Id , replaced . NewLink . Id ) ;
Assert . Equal ( "user-b" , replaced . NewLink . UserId ) ;
Assert . Equal ( "fabrikam" , replaced . NewLink . ConnectionKey ) ;
Assert . Null ( replaced . NewLink . LastSignedInAt ) ;
await using ( var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) )
{
var links = await dbContext . ExternalIdentityLinks . ToListAsync ( ) ;
Assert . DoesNotContain ( links , x = > x . Id = = old . Id ) ;
Assert . DoesNotContain ( links , x = > x . Id = = sameTupleReplacement . NewLink . Id ) ;
Assert . Contains ( links , x = > x . Id = = replaced . NewLink . Id ) ;
Assert . Contains ( links , x = > x . Id = = conflicting . Id ) ;
}
}
2026-08-02 01:35:39 +00:00
[Fact]
public async Task ProvisionerPreservesTheOldLinkWhenTargetUserDeletionWinsReplacementRace ( )
{
await _userStore . SaveAsync ( new User { Id = "user-a" , Name = "alice" , TenantId = "tenant-a" } ) ;
await _userStore . SaveAsync ( new User { Id = "user-b" , Name = "bob" , TenantId = "tenant-a" } ) ;
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
var originalProvisioner = CreateProvisioner ( hasher ) ;
var old = ( await originalProvisioner . CreateLinkOrGetExistingAsync (
new ProvisioningRequest ( "tenant-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-old" , EmptyClaims ) , null , "user-a" ) ) ) . Link ;
var racingProvider = new DeleteOnSelectedFindUserProvider ( new StoreBasedUserProvider ( _userStore ) , _userStore , 2 ) ;
var racingProvisioner = CreateProvisioner ( hasher , userProvider : racingProvider ) ;
await Assert . ThrowsAsync < InvalidOperationException > ( ( ) = > racingProvisioner . ReplaceAsync (
new ExternalIdentityLinkReplaceRequest (
"tenant-a" ,
old . Id ,
"user-b" ,
"contoso" ,
new ExternalIdentity ( "https://issuer.example" , "subject-new" , EmptyClaims ) ) ) . AsTask ( ) ) ;
Assert . Null ( await _userStore . FindAsync ( new UserFilter { Id = "user-b" } ) ) ;
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
var durableLink = Assert . Single ( await dbContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
Assert . Equal ( old . Id , durableLink . Id ) ;
Assert . Equal ( "user-a" , durableLink . UserId ) ;
}
[Fact]
public async Task ProvisionerLeavesNoLinkWhenBothReplacementUsersAreDeletedDuringCompensation ( )
{
await _userStore . SaveAsync ( new User { Id = "user-a" , Name = "alice" , TenantId = "tenant-a" } ) ;
await _userStore . SaveAsync ( new User { Id = "user-b" , Name = "bob" , TenantId = "tenant-a" } ) ;
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
var originalProvisioner = CreateProvisioner ( hasher ) ;
var old = ( await originalProvisioner . CreateLinkOrGetExistingAsync (
new ProvisioningRequest ( "tenant-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-old" , EmptyClaims ) , null , "user-a" ) ) ) . Link ;
var racingProvider = new DeleteOnSelectedFindUserProvider ( new StoreBasedUserProvider ( _userStore ) , _userStore , 2 , 3 ) ;
var racingProvisioner = CreateProvisioner ( hasher , userProvider : racingProvider ) ;
await Assert . ThrowsAsync < InvalidOperationException > ( ( ) = > racingProvisioner . ReplaceAsync (
new ExternalIdentityLinkReplaceRequest (
"tenant-a" ,
old . Id ,
"user-b" ,
"contoso" ,
new ExternalIdentity ( "https://issuer.example" , "subject-new" , EmptyClaims ) ) ) . AsTask ( ) ) ;
Assert . Null ( await _userStore . FindAsync ( new UserFilter { Id = "user-a" } ) ) ;
Assert . Null ( await _userStore . FindAsync ( new UserFilter { Id = "user-b" } ) ) ;
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
Assert . Empty ( await dbContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
}
2026-08-02 21:11:04 +00:00
[Fact]
public async Task ProvisionerPreservesRestoredLinkWhenPreviousUserLookupFails ( )
{
await _userStore . SaveAsync ( new User { Id = "user-a" , Name = "alice" , TenantId = "tenant-a" } ) ;
await _userStore . SaveAsync ( new User { Id = "user-b" , Name = "bob" , TenantId = "tenant-a" } ) ;
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
var originalProvisioner = CreateProvisioner ( hasher ) ;
var old = ( await originalProvisioner . CreateLinkOrGetExistingAsync (
new ProvisioningRequest ( "tenant-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-old" , EmptyClaims ) , null , "user-a" ) ) ) . Link ;
var racingProvider = new DeleteThenThrowUserProvider ( new StoreBasedUserProvider ( _userStore ) , _userStore ) ;
var racingProvisioner = CreateProvisioner ( hasher , userProvider : racingProvider ) ;
var exception = await Assert . ThrowsAsync < InvalidOperationException > ( ( ) = > racingProvisioner . ReplaceAsync (
new ExternalIdentityLinkReplaceRequest (
"tenant-a" ,
old . Id ,
"user-b" ,
"contoso" ,
new ExternalIdentity ( "https://issuer.example" , "subject-new" , EmptyClaims ) ) ) . AsTask ( ) ) ;
Assert . Contains ( "lookup failure" , exception . Message , StringComparison . Ordinal ) ;
Assert . NotNull ( await _userStore . FindAsync ( new UserFilter { Id = "user-a" } ) ) ;
Assert . Null ( await _userStore . FindAsync ( new UserFilter { Id = "user-b" } ) ) ;
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
var durableLink = Assert . Single ( await dbContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
Assert . Equal ( old . Id , durableLink . Id ) ;
Assert . Equal ( "user-a" , durableLink . UserId ) ;
}
[Fact]
public async Task ProvisionerFallsBackWhenInvalidRestoredLinkCleanupFailsOnce ( )
{
await _userStore . SaveAsync ( new User { Id = "user-a" , Name = "alice" , TenantId = "tenant-a" } ) ;
await _userStore . SaveAsync ( new User { Id = "user-b" , Name = "bob" , TenantId = "tenant-a" } ) ;
var options = new DbContextOptionsBuilder < ExternalAuthenticationElsaDbContext > ( )
. UseSqlite ( _connection )
. AddInterceptors ( new FailSelectedLinkDeleteInterceptor ( 3 ) )
. Options ;
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
var factory = new TestDbContextFactory ( options , _services ) ;
var originalProvisioner = CreateProvisioner ( hasher , factory ) ;
var old = ( await originalProvisioner . CreateLinkOrGetExistingAsync (
new ProvisioningRequest ( "tenant-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-old" , EmptyClaims ) , null , "user-a" ) ) ) . Link ;
var racingProvider = new DeleteOnSelectedFindUserProvider ( new StoreBasedUserProvider ( _userStore ) , _userStore , 2 , 3 ) ;
var racingProvisioner = CreateProvisioner ( hasher , factory , userProvider : racingProvider ) ;
await Assert . ThrowsAsync < InvalidOperationException > ( ( ) = > racingProvisioner . ReplaceAsync (
new ExternalIdentityLinkReplaceRequest (
"tenant-a" ,
old . Id ,
"user-b" ,
"contoso" ,
new ExternalIdentity ( "https://issuer.example" , "subject-new" , EmptyClaims ) ) ) . AsTask ( ) ) ;
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
Assert . Empty ( await dbContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
}
[Fact]
public async Task ProvisionerDoesNotMisclassifyPostCommitUserLookupFailureAsConflict ( )
{
await _userStore . SaveAsync ( new User { Id = "user-a" , Name = "alice" , TenantId = "tenant-a" } ) ;
await _userStore . SaveAsync ( new User { Id = "user-b" , Name = "bob" , TenantId = "tenant-a" } ) ;
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
var originalProvisioner = CreateProvisioner ( hasher ) ;
var old = ( await originalProvisioner . CreateLinkOrGetExistingAsync (
new ProvisioningRequest ( "tenant-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-old" , EmptyClaims ) , null , "user-a" ) ) ) . Link ;
var failingProvider = new ThrowOnSelectedFindUserProvider ( new StoreBasedUserProvider ( _userStore ) , 2 ) ;
var failingProvisioner = CreateProvisioner ( hasher , userProvider : failingProvider ) ;
await Assert . ThrowsAsync < DbUpdateException > ( ( ) = > failingProvisioner . ReplaceAsync (
new ExternalIdentityLinkReplaceRequest (
"tenant-a" ,
old . Id ,
"user-b" ,
"contoso" ,
new ExternalIdentity ( "https://issuer.example" , "subject-new" , EmptyClaims ) ) ) . AsTask ( ) ) ;
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
var durableLink = Assert . Single ( await dbContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
Assert . NotEqual ( old . Id , durableLink . Id ) ;
Assert . Equal ( "user-b" , durableLink . UserId ) ;
}
[Fact]
public async Task ProvisionerReconcilesReplacementWhenCommitAcknowledgementIsLost ( )
{
await _userStore . SaveAsync ( new User { Id = "user-a" , Name = "alice" , TenantId = "tenant-a" } ) ;
await _userStore . SaveAsync ( new User { Id = "user-b" , Name = "bob" , TenantId = "tenant-a" } ) ;
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
var originalProvisioner = CreateProvisioner ( hasher ) ;
var old = ( await originalProvisioner . CreateLinkOrGetExistingAsync (
new ProvisioningRequest ( "tenant-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-old" , EmptyClaims ) , null , "user-a" ) ) ) . Link ;
var options = new DbContextOptionsBuilder < ExternalAuthenticationElsaDbContext > ( )
. UseSqlite ( _connection )
. AddInterceptors ( new ThrowAfterSelectedCommitInterceptor ( 1 ) )
. Options ;
var provisioner = CreateProvisioner ( hasher , new TestDbContextFactory ( options , _services ) ) ;
var result = Assert . IsType < ExternalIdentityLinkReplaceResult . Success > ( await provisioner . ReplaceAsync (
new ExternalIdentityLinkReplaceRequest (
"tenant-a" ,
old . Id ,
"user-b" ,
"contoso" ,
new ExternalIdentity ( "https://issuer.example" , "subject-new" , EmptyClaims ) ) ) ) ;
Assert . NotEqual ( old . Id , result . NewLink . Id ) ;
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
var durableLink = Assert . Single ( await dbContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
Assert . Equal ( result . NewLink . Id , durableLink . Id ) ;
Assert . Equal ( "user-b" , durableLink . UserId ) ;
}
[Fact]
public async Task ProvisionerPreservesRestoredLinkWhenCompensationCommitAcknowledgementIsLost ( )
{
await _userStore . SaveAsync ( new User { Id = "user-a" , Name = "alice" , TenantId = "tenant-a" } ) ;
await _userStore . SaveAsync ( new User { Id = "user-b" , Name = "bob" , TenantId = "tenant-a" } ) ;
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
var originalProvisioner = CreateProvisioner ( hasher ) ;
var old = ( await originalProvisioner . CreateLinkOrGetExistingAsync (
new ProvisioningRequest ( "tenant-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-old" , EmptyClaims ) , null , "user-a" ) ) ) . Link ;
var options = new DbContextOptionsBuilder < ExternalAuthenticationElsaDbContext > ( )
. UseSqlite ( _connection )
. AddInterceptors ( new ThrowAfterSelectedCommitInterceptor ( 2 ) )
. Options ;
var racingProvider = new DeleteOnSelectedFindUserProvider ( new StoreBasedUserProvider ( _userStore ) , _userStore , 2 ) ;
var provisioner = CreateProvisioner ( hasher , new TestDbContextFactory ( options , _services ) , userProvider : racingProvider ) ;
await Assert . ThrowsAsync < InvalidOperationException > ( ( ) = > provisioner . ReplaceAsync (
new ExternalIdentityLinkReplaceRequest (
"tenant-a" ,
old . Id ,
"user-b" ,
"contoso" ,
new ExternalIdentity ( "https://issuer.example" , "subject-new" , EmptyClaims ) ) ) . AsTask ( ) ) ;
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
var durableLink = Assert . Single ( await dbContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
Assert . Equal ( old . Id , durableLink . Id ) ;
Assert . Equal ( "user-a" , durableLink . UserId ) ;
}
2026-07-26 21:40:17 +00:00
[Fact]
public async Task DurableConcurrentReplacementUsesTheOldLinkIdAsAnAtomicGuard ( )
{
var databasePath = Path . Combine ( Path . GetTempPath ( ) , $"elsa-external-identity-links-{Guid.NewGuid():N}.db" ) ;
await using var services = new ServiceCollection ( ) . BuildServiceProvider ( ) ;
try
{
2026-07-31 12:31:18 +00:00
var options = new DbContextOptionsBuilder < ExternalAuthenticationElsaDbContext > ( )
2026-07-26 21:40:17 +00:00
. UseSqlite ( $"Data Source={databasePath};Default Timeout=30" )
. Options ;
var factory = new TestDbContextFactory ( options , services ) ;
await using ( var dbContext = await factory . CreateDbContextAsync ( ) )
{
await dbContext . Database . EnsureCreatedAsync ( ) ;
}
2026-07-31 12:31:18 +00:00
// Both nodes share one user directory, which is what a multi-node deployment actually looks like.
var sharedUserStore = new MemoryUserStore ( new MemoryStore < User > ( ) ) ;
await sharedUserStore . SaveAsync ( new User { Id = "user-a" , Name = "alice-concurrent" , TenantId = "tenant-a" } ) ;
2026-07-26 21:40:17 +00:00
using var hasher = new HmacExternalAuthenticationHandleHasher ( ) ;
2026-07-31 12:31:18 +00:00
var firstNode = CreateProvisioner ( hasher , factory , sharedUserStore ) ;
var secondNode = CreateProvisioner ( hasher , factory , sharedUserStore ) ;
2026-07-26 21:40:17 +00:00
var old = ( await firstNode . CreateLinkOrGetExistingAsync (
new ProvisioningRequest ( "tenant-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-old" , EmptyClaims ) , null , "user-a" ) ) ) . Link ;
var results = await Task . WhenAll (
firstNode . ReplaceAsync ( new ExternalIdentityLinkReplaceRequest ( "tenant-a" , old . Id , "user-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-a" , EmptyClaims ) ) ) . AsTask ( ) ,
secondNode . ReplaceAsync ( new ExternalIdentityLinkReplaceRequest ( "tenant-a" , old . Id , "user-a" , "contoso" , new ExternalIdentity ( "https://issuer.example" , "subject-b" , EmptyClaims ) ) ) . AsTask ( ) ) ;
Assert . Single ( results . OfType < ExternalIdentityLinkReplaceResult . Success > ( ) ) ;
Assert . Single ( results . OfType < ExternalIdentityLinkReplaceResult . NotFound > ( ) ) ;
await using var verificationContext = await factory . CreateDbContextAsync ( ) ;
Assert . Single ( await verificationContext . ExternalIdentityLinks . ToListAsync ( ) ) ;
}
finally
{
SqliteConnection . ClearAllPools ( ) ;
File . Delete ( databasePath ) ;
}
}
2026-07-29 11:59:24 +00:00
[Fact]
public async Task CallbackCompletionPersistsTheSessionBeforeAnyRefreshTokenIsIssued ( )
{
var identityResolver = Substitute . For < IExternalIdentityResolver > ( ) ;
identityResolver . ResolveAsync ( Arg . Any < ExternalIdentityResolutionContext > ( ) , Arg . Any < CancellationToken > ( ) )
. Returns ( ValueTask . FromResult ( new ExternalIdentityResolution ( "user-a" , false ) ) ) ;
var permissionGrantResolver = Substitute . For < IPermissionGrantResolver > ( ) ;
permissionGrantResolver . ResolveAsync ( Arg . Any < PermissionGrantResolutionContext > ( ) , Arg . Any < CancellationToken > ( ) )
. Returns ( ValueTask . FromResult ( new PermissionGrantResult ( [ ] , [ ] ) ) ) ;
var adapter = new Broker . BrokerSecurityTests . RecordingAdapter
{
AuthenticationResult = new ExternalAuthenticationResult ( new ExternalIdentity ( "https://issuer.example" , "subject-a" , EmptyClaims ) , EmptyClaims , [ ] )
} ;
2026-07-31 12:31:18 +00:00
var broker = Broker . BrokerSecurityTests . CreateBroker ( adapter , identityResolver : identityResolver , permissionGrantResolver : permissionGrantResolver , sessionStore : new EFCoreExternalAuthenticationSessionStore ( _leaseFactory , _clock ) ) ;
2026-07-29 11:59:24 +00:00
await broker . InitiateExternalAsync ( new BrokerAuthorizationRequest ( "studio" , new Uri ( "https://studio.example/authentication/external/callback" ) , "code" , "challenge" , "S256" , "/workflows" , "contoso" ) , "tenant-a" ) ;
var result = await broker . CompleteCallbackAsync ( "contoso" , adapter . CorrelationState ! , new Dictionary < string , IReadOnlyCollection < string > > { [ "state" ] = [ adapter . CorrelationState ! ] } ) ;
Assert . Null ( result . Error ) ;
await using var dbContext = await _dbContextFactory . CreateDbContextAsync ( ) ;
var session = Assert . Single ( await dbContext . ExternalAuthenticationSessions . ToListAsync ( ) ) ;
Assert . Equal ( "user-a" , session . UserId ) ;
2026-08-02 01:35:39 +00:00
Assert . Empty ( await dbContext . ExternalAuthenticationRefreshTokens . ToListAsync ( ) ) ;
Assert . Null ( ( await new EFCoreExternalAuthenticationSessionStore ( _leaseFactory , _clock ) . FindByIdAsync ( session . Id ) ) ! . CurrentRefreshTokenHash ) ;
Assert . Null ( await new EFCoreExternalAuthenticationSessionStore ( _leaseFactory , _clock ) . FindByRefreshTokenHashAsync ( null ! ) ) ;
2026-07-29 11:59:24 +00:00
}
2026-07-26 21:40:17 +00:00
private static IReadOnlyDictionary < string , IReadOnlyCollection < string > > EmptyClaims { get ; } = new Dictionary < string , IReadOnlyCollection < string > > ( ) ;
2026-07-24 16:59:17 +00:00
private static IdentityProviderConnection CreateConnection ( string id = "connection-a" ) = > new ( )
{
Id = id ,
TenantId = "tenant-a" ,
Key = "contoso" ,
AdapterType = "openid-connect" ,
AdapterSettingsVersion = 1 ,
AdapterSettings = JsonDocument . Parse ( "{}" ) . RootElement . Clone ( ) ,
DisplayName = "Contoso" ,
MaterialRevision = "revision-a" ,
Revision = 1 ,
CreatedAt = DateTimeOffset . UtcNow ,
UpdatedAt = DateTimeOffset . UtcNow
} ;
private ExternalAuthenticationSession CreateSession ( ) = > new ( )
{
2026-07-25 00:35:56 +00:00
Id = "session-a" , AuthenticationClientId = "studio" , TenantId = "tenant-a" , UserId = "user-a" , ConnectionKey = "contoso" , ConnectionMaterialRevision = "revision-a" , Issuer = "https://issuer.example" , SubjectHash = "subject" , ExternalGrants = [ ] , StartedAt = _clock . UtcNow , LastRefreshedAt = _clock . UtcNow , ExpiresAt = _clock . UtcNow . AddHours ( 1 ) , RefreshExpiresAt = _clock . UtcNow . AddHours ( 1 ) , CurrentRefreshTokenHash = "refresh-a"
2026-07-24 16:59:17 +00:00
} ;
2026-07-31 12:31:18 +00:00
private sealed class TestDbContextFactory ( DbContextOptions < ExternalAuthenticationElsaDbContext > options , IServiceProvider serviceProvider ) : IDbContextFactory < ExternalAuthenticationElsaDbContext >
2026-07-24 16:59:17 +00:00
{
2026-07-31 12:31:18 +00:00
public ExternalAuthenticationElsaDbContext CreateDbContext ( ) = > new ( options , serviceProvider ) ;
public Task < ExternalAuthenticationElsaDbContext > CreateDbContextAsync ( CancellationToken cancellationToken = default ) = > Task . FromResult ( CreateDbContext ( ) ) ;
2026-07-24 16:59:17 +00:00
}
2026-07-25 01:48:50 +00:00
private sealed class SteppingSystemClock ( params DateTimeOffset [ ] instants ) : ISystemClock
{
private int _index ;
public DateTimeOffset UtcNow = > instants [ Math . Min ( _index + + , instants . Length - 1 ) ] ;
}
2026-08-02 01:35:39 +00:00
private sealed class CoordinatedUserStore ( IUserStore inner , int participantCount ) : IUserStore
{
private readonly TaskCompletionSource _participantsReady = new ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
private int _participants ;
public async Task SaveAsync ( User user , CancellationToken cancellationToken = default )
{
if ( Interlocked . Increment ( ref _participants ) = = participantCount )
_participantsReady . TrySetResult ( ) ;
await _participantsReady . Task . WaitAsync ( cancellationToken ) ;
await inner . SaveAsync ( user , cancellationToken ) ;
}
public Task DeleteAsync ( UserFilter filter , CancellationToken cancellationToken = default ) = > inner . DeleteAsync ( filter , cancellationToken ) ;
public Task < IEnumerable < User > > FindManyAsync ( UserFilter filter , CancellationToken cancellationToken = default ) = > inner . FindManyAsync ( filter , cancellationToken ) ;
public Task < User ? > FindAsync ( UserFilter filter , CancellationToken cancellationToken = default ) = > inner . FindAsync ( filter , cancellationToken ) ;
}
private sealed class FailingLinkSaveInterceptor : SaveChangesInterceptor
{
public override ValueTask < InterceptionResult < int > > SavingChangesAsync (
DbContextEventData eventData ,
InterceptionResult < int > result ,
CancellationToken cancellationToken = default ) = >
throw new DbUpdateException ( "Simulated external identity link persistence failure." ) ;
}
private sealed class DeleteLinkedUserBeforeCommitInterceptor ( IUserStore users ) : SaveChangesInterceptor
{
public override async ValueTask < InterceptionResult < int > > SavingChangesAsync (
DbContextEventData eventData ,
InterceptionResult < int > result ,
CancellationToken cancellationToken = default )
{
var userId = eventData . Context ! . ChangeTracker . Entries < PersistedExternalIdentityLink > ( )
. Single ( x = > x . State = = EntityState . Added ) . Entity . UserId ;
await users . DeleteAsync ( new UserFilter { Id = userId } , cancellationToken ) ;
return result ;
}
}
2026-08-02 21:11:04 +00:00
private sealed class DeleteLinkedUserAfterSaveAndThrowInterceptor ( IUserStore users ) : SaveChangesInterceptor
{
public override async ValueTask < int > SavedChangesAsync (
SaveChangesCompletedEventData eventData ,
int result ,
CancellationToken cancellationToken = default )
{
var userId = eventData . Context ! . ChangeTracker . Entries < PersistedExternalIdentityLink > ( )
. Single ( ) . Entity . UserId ;
await users . DeleteAsync ( new UserFilter { Id = userId } , CancellationToken . None ) ;
throw new InvalidOperationException ( "Simulated ambiguous post-save failure." ) ;
}
}
2026-08-02 01:35:39 +00:00
private sealed class DeleteFailingUserStore ( IUserStore inner ) : IUserStore
{
public Task SaveAsync ( User user , CancellationToken cancellationToken = default ) = > inner . SaveAsync ( user , cancellationToken ) ;
public Task DeleteAsync ( UserFilter filter , CancellationToken cancellationToken = default ) = > throw new InvalidOperationException ( "Simulated user cleanup failure." ) ;
public Task < IEnumerable < User > > FindManyAsync ( UserFilter filter , CancellationToken cancellationToken = default ) = > inner . FindManyAsync ( filter , cancellationToken ) ;
public Task < User ? > FindAsync ( UserFilter filter , CancellationToken cancellationToken = default ) = > inner . FindAsync ( filter , cancellationToken ) ;
}
2026-08-02 21:11:04 +00:00
private sealed class CancelAfterSaveUserStore ( IUserStore inner , CancellationTokenSource cancellationTokenSource ) : IUserStore
{
public async Task SaveAsync ( User user , CancellationToken cancellationToken = default )
{
await inner . SaveAsync ( user , cancellationToken ) ;
cancellationTokenSource . Cancel ( ) ;
}
public Task DeleteAsync ( UserFilter filter , CancellationToken cancellationToken = default ) = > inner . DeleteAsync ( filter , cancellationToken ) ;
public Task < IEnumerable < User > > FindManyAsync ( UserFilter filter , CancellationToken cancellationToken = default ) = > inner . FindManyAsync ( filter , cancellationToken ) ;
public Task < User ? > FindAsync ( UserFilter filter , CancellationToken cancellationToken = default ) = > inner . FindAsync ( filter , cancellationToken ) ;
}
private sealed class FailSelectedLinkDeleteInterceptor ( int failureCount ) : DbCommandInterceptor
{
private int _deleteCount ;
public override ValueTask < InterceptionResult < int > > NonQueryExecutingAsync (
DbCommand command ,
CommandEventData eventData ,
InterceptionResult < int > result ,
CancellationToken cancellationToken = default )
{
if ( command . CommandText . Contains ( "DELETE FROM \"ExternalIdentityLinks\"" , StringComparison . Ordinal ) & &
Interlocked . Increment ( ref _deleteCount ) = = failureCount )
throw new InvalidOperationException ( "Simulated external identity link cleanup failure." ) ;
return ValueTask . FromResult ( result ) ;
}
}
private sealed class ThrowAfterSelectedCommitInterceptor ( int failureCount ) : DbTransactionInterceptor
{
private int _commitCount ;
public override Task TransactionCommittedAsync (
DbTransaction transaction ,
TransactionEndEventData eventData ,
CancellationToken cancellationToken = default )
{
if ( Interlocked . Increment ( ref _commitCount ) = = failureCount )
throw new InvalidOperationException ( "Simulated lost transaction commit acknowledgement." ) ;
return Task . CompletedTask ;
}
}
2026-08-02 01:35:39 +00:00
private sealed class DeleteOnSelectedFindUserProvider ( IUserProvider inner , IUserStore users , params int [ ] deletionCounts ) : IUserProvider
{
private readonly HashSet < int > _deletionCounts = deletionCounts . ToHashSet ( ) ;
private int _findCount ;
public async Task < User ? > FindAsync ( UserFilter filter , CancellationToken cancellationToken = default )
{
var user = await inner . FindAsync ( filter , cancellationToken ) ;
if ( user is not null & & _deletionCounts . Contains ( Interlocked . Increment ( ref _findCount ) ) )
{
await users . DeleteAsync ( new UserFilter { Id = user . Id } , cancellationToken ) ;
return null ;
}
return user ;
}
}
2026-08-02 21:11:04 +00:00
private sealed class DeleteThenThrowUserProvider ( IUserProvider inner , IUserStore users ) : IUserProvider
{
private int _findCount ;
public async Task < User ? > FindAsync ( UserFilter filter , CancellationToken cancellationToken = default )
{
var user = await inner . FindAsync ( filter , cancellationToken ) ;
var findCount = Interlocked . Increment ( ref _findCount ) ;
if ( user is not null & & findCount = = 2 )
{
await users . DeleteAsync ( new UserFilter { Id = user . Id } , cancellationToken ) ;
return null ;
}
if ( findCount = = 3 )
throw new InvalidOperationException ( "Simulated user-directory lookup failure." ) ;
return user ;
}
}
private sealed class ThrowOnSelectedFindUserProvider ( IUserProvider inner , int failureCount ) : IUserProvider
{
private int _findCount ;
public async Task < User ? > FindAsync ( UserFilter filter , CancellationToken cancellationToken = default )
{
var user = await inner . FindAsync ( filter , cancellationToken ) ;
if ( Interlocked . Increment ( ref _findCount ) = = failureCount )
throw new DbUpdateException ( "Simulated post-commit user-directory failure." ) ;
return user ;
}
}
2026-07-24 16:59:17 +00:00
}