114 lines
6.9 KiB
C#
114 lines
6.9 KiB
C#
using System.Text.Json;
|
|
using Elsa.Common;
|
|
using Elsa.ExternalAuthentication.Contracts;
|
|
using Elsa.ExternalAuthentication.Models;
|
|
using Elsa.ExternalAuthentication.Persistence.EFCore;
|
|
using Elsa.ExternalAuthentication.Persistence.EFCore.Stores;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace Elsa.ExternalAuthentication.IntegrationTests.Distributed;
|
|
|
|
/// <summary>
|
|
/// Exercises durable state through independently constructed stores, which represent requests landing on
|
|
/// different Elsa nodes while sharing the same persistence database.
|
|
/// </summary>
|
|
public sealed class MultiNodeBrokerTests : IAsyncLifetime
|
|
{
|
|
private SqliteConnection _connection = null!;
|
|
private ServiceProvider _services = null!;
|
|
private ExternalAuthenticationDbContextLeaseFactory _contexts = null!;
|
|
private readonly ISystemClock _clock = new FixedClock();
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_connection = new SqliteConnection("Data Source=:memory:");
|
|
await _connection.OpenAsync();
|
|
var options = new DbContextOptionsBuilder<ExternalAuthenticationElsaDbContext>()
|
|
.UseSqlite(_connection, sqlite => sqlite.MigrationsAssembly(typeof(Elsa.ExternalAuthentication.Persistence.EFCore.Sqlite.ExternalAuthenticationDbContextFactory).Assembly.FullName))
|
|
.Options;
|
|
_services = new ServiceCollection()
|
|
.AddSingleton<IDbContextFactory<ExternalAuthenticationElsaDbContext>>(services => new TestDbContextFactory(options, services))
|
|
.BuildServiceProvider();
|
|
_contexts = new ExternalAuthenticationDbContextLeaseFactory(_services.GetRequiredService<IServiceScopeFactory>());
|
|
await using var dbContext = await _contexts.CreateAsync();
|
|
await dbContext.DbContext.Database.EnsureCreatedAsync();
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _services.DisposeAsync();
|
|
await _connection.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InitiationCallbackAndExchangeCanConsumeDurableStateAcrossNodesExactlyOnce()
|
|
{
|
|
var initiatingNode = new EFCoreExternalAuthenticationStateStore(_contexts, _clock);
|
|
var callbackNode = new EFCoreExternalAuthenticationStateStore(_contexts, _clock);
|
|
var exchangeNode = new EFCoreAuthorizationGrantStore(_contexts, _clock);
|
|
var replayNode = new EFCoreAuthorizationGrantStore(_contexts, _clock);
|
|
var transaction = new BrokerTransaction
|
|
{
|
|
HandleHash = "state-hash", Purpose = BrokerTransactionPurpose.ExternalSignIn, ClientId = "studio", CallbackUri = new Uri("https://studio.example/callback"),
|
|
ReturnPath = "/", TenantId = "tenant-a", ConnectionId = "connection-a", ConnectionMaterialRevision = "revision-a", PkceChallenge = "challenge", ExpiresAt = _clock.UtcNow.AddMinutes(1)
|
|
};
|
|
|
|
await initiatingNode.PutAsync("ExternalSignIn", transaction.HandleHash, transaction, transaction.ExpiresAt);
|
|
var taken = Assert.IsType<TakeResult<BrokerTransaction>.Taken>(await callbackNode.TryTakeAsync<BrokerTransaction>("ExternalSignIn", transaction.HandleHash));
|
|
Assert.Equal("connection-a", taken.Value.ConnectionId);
|
|
Assert.IsType<TakeResult<BrokerTransaction>.AlreadyConsumed>(await initiatingNode.TryTakeAsync<BrokerTransaction>("ExternalSignIn", transaction.HandleHash));
|
|
|
|
await exchangeNode.SaveAsync(new AuthorizationGrant { CodeHash = "code-hash", ClientId = "studio", CallbackUri = transaction.CallbackUri, TenantId = "tenant-a", UserId = "user-a", ExternalSessionId = "session-a", PkceChallenge = "challenge", ExpiresAt = transaction.ExpiresAt });
|
|
Assert.IsType<TakeResult<AuthorizationGrant>.Taken>(await replayNode.TryTakeAsync("code-hash"));
|
|
Assert.IsType<TakeResult<AuthorizationGrant>.AlreadyConsumed>(await exchangeNode.TryTakeAsync("code-hash"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RefreshRotationAndRevocationAreAtomicAcrossNodes()
|
|
{
|
|
var firstNode = new EFCoreExternalAuthenticationSessionStore(_contexts, _clock);
|
|
var secondNode = new EFCoreExternalAuthenticationSessionStore(_contexts, _clock);
|
|
await firstNode.SaveAsync(Session());
|
|
|
|
var rotation = await secondNode.TryRotateRefreshTokenAsync("session-a", "refresh-a", 0, "refresh-b", _clock.UtcNow);
|
|
Assert.IsType<ExternalAuthenticationSessionRotationResult.Rotated>(rotation);
|
|
Assert.IsType<ExternalAuthenticationSessionRotationResult.Reused>(await firstNode.TryRotateRefreshTokenAsync("session-a", "refresh-a", 0, "refresh-c", _clock.UtcNow));
|
|
var revoked = await secondNode.FindByIdAsync("session-a");
|
|
Assert.Equal("refresh_token_reuse", revoked?.RevocationReason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MutationVersionAndLatestObservationAreImmediatelyVisibleToAnotherNode()
|
|
{
|
|
var firstVersionStore = new EFCoreConnectionRegistryVersionStore(_contexts);
|
|
var secondVersionStore = new EFCoreConnectionRegistryVersionStore(_contexts);
|
|
var firstObservationStore = new EFCoreConnectionObservationStore(_contexts);
|
|
var secondObservationStore = new EFCoreConnectionObservationStore(_contexts);
|
|
|
|
var initialVersion = await secondVersionStore.GetVersionAsync();
|
|
var committedVersion = await firstVersionStore.AdvanceAsync();
|
|
await firstObservationStore.SaveLatestAsync(new ConnectionObservation("connection-a", "revision-b", _clock.UtcNow, ConnectionObservationStatus.Failed, "temporarily_unavailable", TimeSpan.Zero, "Safe summary", [], "correlation-a"));
|
|
|
|
Assert.True(committedVersion > initialVersion);
|
|
Assert.True(await secondVersionStore.IsCurrentAsync(committedVersion));
|
|
var observation = await secondObservationStore.FindLatestAsync("connection-a");
|
|
Assert.Equal("revision-b", observation?.TestedMaterialRevision);
|
|
Assert.Equal(ConnectionObservationStatus.Failed, observation?.Status);
|
|
}
|
|
|
|
private ExternalAuthenticationSession Session() => new()
|
|
{
|
|
Id = "session-a", AuthenticationClientId = "studio", TenantId = "tenant-a", UserId = "user-a", ConnectionKey = "contoso", ConnectionMaterialRevision = "revision-a",
|
|
Issuer = "https://issuer.example", SubjectHash = "subject-hash", StartedAt = _clock.UtcNow, LastRefreshedAt = _clock.UtcNow, ExpiresAt = _clock.UtcNow.AddHours(1), RefreshExpiresAt = _clock.UtcNow.AddHours(1), CurrentRefreshTokenHash = "refresh-a"
|
|
};
|
|
|
|
private sealed class FixedClock : ISystemClock { public DateTimeOffset UtcNow => new(2026, 7, 24, 0, 0, 0, TimeSpan.Zero); }
|
|
private sealed class TestDbContextFactory(DbContextOptions<ExternalAuthenticationElsaDbContext> options, IServiceProvider services) : IDbContextFactory<ExternalAuthenticationElsaDbContext>
|
|
{
|
|
public ExternalAuthenticationElsaDbContext CreateDbContext() => new(options, services);
|
|
public Task<ExternalAuthenticationElsaDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) => Task.FromResult(CreateDbContext());
|
|
}
|
|
}
|