elsa-core/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Links/ExternalIdentityLinkTests.cs
2026-07-25 02:35:56 +02:00

216 lines
11 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Elsa.Common;
using Elsa.Common.Multitenancy;
using Elsa.Common.Services;
using Elsa.ExternalAuthentication.Contracts;
using Elsa.ExternalAuthentication.Features;
using Elsa.ExternalAuthentication.Models;
using Elsa.ExternalAuthentication.Policies;
using Elsa.ExternalAuthentication.Services;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Providers;
using Elsa.Identity.Services;
using Elsa.Workflows;
using FastEndpoints;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
namespace Elsa.ExternalAuthentication.IntegrationTests.Links;
public partial class ExternalIdentityLinkTests : IAsyncLifetime
{
private WebApplication? _app;
private HttpClient? _client;
private ITenantAccessor _tenant = null!;
private TestConnectionRegistry _connections = null!;
private bool _wasSecurityEnabled;
protected HttpClient Client => _client!;
public async Task InitializeAsync()
{
_wasSecurityEnabled = EndpointSecurityOptions.SecurityIsEnabled;
EndpointSecurityOptions.SecurityIsEnabled = false;
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddFastEndpoints(options =>
{
options.Assemblies = [typeof(ExternalAuthenticationFeature).Assembly];
options.Filter = endpoint => endpoint.Namespace == "Elsa.ExternalAuthentication.Endpoints.IdentityLinks";
});
builder.Services.AddAuthorization();
builder.Services.AddSingleton<MemoryStore<User>>();
builder.Services.AddSingleton<IIdentityGenerator, GuidIdentityGenerator>();
builder.Services.AddSingleton<ISystemClock, SystemClock>();
builder.Services.AddSingleton<IExternalAuthenticationHandleHasher, HmacExternalAuthenticationHandleHasher>();
builder.Services.AddSingleton<InMemoryExternalIdentityProvisionerState>();
builder.Services.AddScoped<IUserStore, MemoryUserStore>();
builder.Services.AddScoped<IUserProvider, StoreBasedUserProvider>();
builder.Services.AddSingleton<IRoleProvider>(Substitute.For<IRoleProvider>());
builder.Services.AddScoped<InMemoryExternalIdentityProvisioner>();
builder.Services.AddScoped<IExternalIdentityProvisioner>(services => services.GetRequiredService<InMemoryExternalIdentityProvisioner>());
builder.Services.AddScoped<IExternalIdentityLinkManagementStore>(services => services.GetRequiredService<InMemoryExternalIdentityProvisioner>());
_connections = new TestConnectionRegistry();
builder.Services.AddSingleton<IIdentityProviderConnectionRegistry>(_connections);
_tenant = Substitute.For<ITenantAccessor>();
_tenant.TenantId.Returns("tenant-a");
builder.Services.AddSingleton(_tenant);
builder.Services.AddScoped<ExternalIdentityLinkManagementService>();
_app = builder.Build();
_app.Use(async (context, next) =>
{
context.User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(PermissionNames.ClaimType, PermissionNames.All), new Claim("sub", "admin")], "test"));
await next(context);
});
_app.UseAuthorization();
_app.UseFastEndpoints();
await _app.StartAsync();
_client = _app.GetTestClient();
await SeedUserAsync("user-a", "alice", "tenant-a");
await SeedUserAsync("user-b", "bob", "tenant-b");
await SeedUserAsync("user-c", "charlie", "tenant-a");
}
public async Task DisposeAsync()
{
EndpointSecurityOptions.SecurityIsEnabled = _wasSecurityEnabled;
_client?.Dispose();
if (_app is not null)
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}
[Fact]
public async Task PrelinkListAndUnlinkAreTenantBoundCursorPagedAndPolicyFallsBackAfterRemoval()
{
var first = await PrelinkAsync("subject-a");
var second = await PrelinkAsync("subject-b");
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
Assert.Equal(HttpStatusCode.Created, second.StatusCode);
var firstPage = await _client!.GetFromJsonAsync<LinkList>($"/external-authentication/identity-links?userId=user-a&pageSize=1");
Assert.NotNull(firstPage);
Assert.Single(firstPage!.Items);
Assert.NotNull(firstPage.NextCursor);
var serializedPage = JsonSerializer.Serialize(firstPage);
Assert.DoesNotContain("subject-a", serializedPage);
Assert.DoesNotContain("subjecthash", serializedPage, StringComparison.OrdinalIgnoreCase);
var secondPage = await Client.GetFromJsonAsync<LinkList>($"/external-authentication/identity-links?userId=user-a&pageSize=1&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
Assert.NotNull(secondPage);
Assert.Single(secondPage!.Items);
var link = await first.Content.ReadFromJsonAsync<LinkDocument>();
var conflict = await PrelinkAsync("subject-a", "user-c");
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
_connections.Archived = true;
var archived = await Client.GetFromJsonAsync<LinkList>("/external-authentication/identity-links?connectionKey=contoso");
Assert.NotNull(archived);
var archivedLinks = Assert.IsType<LinkList>(archived);
Assert.Equal(2, archivedLinks.Items.Count);
Assert.NotNull(link);
Assert.Equal(HttpStatusCode.NoContent, (await Client.DeleteAsync($"/external-authentication/identity-links/{link.Id}")).StatusCode);
await using var scope = _app!.Services.CreateAsyncScope();
var resolver = new DefaultExternalIdentityResolver(
scope.ServiceProvider.GetRequiredService<IExternalIdentityProvisioner>(),
[new RejectUnlinkedIdentityPolicy()],
Microsoft.Extensions.Options.Options.Create(new Elsa.ExternalAuthentication.Options.ExternalAuthenticationOptions()));
var identity = new ExternalIdentity("https://issuer.example", "subject-a", new Dictionary<string, IReadOnlyCollection<string>>());
var connection = await _connections.FindByKeyAsync("tenant-a", "contoso");
await Assert.ThrowsAsync<ExternalIdentityUnlinkedException>(() => resolver.ResolveAsync(new ExternalIdentityResolutionContext("tenant-a", connection!, identity, identity.Claims)).AsTask());
}
[Fact]
public async Task TenantIsolationRejectsCrossTenantUsersAndDoesNotRevealTheirLinks()
{
Assert.Equal(HttpStatusCode.Created, (await PrelinkAsync("subject-a")).StatusCode);
_tenant.TenantId.Returns("tenant-b");
var crossTenantPrelink = await PrelinkAsync("subject-b", "user-a");
Assert.Equal(HttpStatusCode.NotFound, crossTenantPrelink.StatusCode);
var links = await _client!.GetFromJsonAsync<LinkList>("/external-authentication/identity-links");
Assert.NotNull(links);
Assert.Empty(links!.Items);
}
[Fact]
public async Task ConcurrentPrelinksForTheSameTupleConvergeOnOneLinkAndUser()
{
var responses = await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => PrelinkAsync("concurrent-subject")));
Assert.All(responses, response => Assert.True(response.StatusCode is HttpStatusCode.Created or HttpStatusCode.OK));
var links = await _client!.GetFromJsonAsync<LinkList>("/external-authentication/identity-links?connectionKey=contoso");
Assert.NotNull(links);
Assert.Single(links!.Items);
Assert.Equal("user-a", Assert.Single(links.Items).UserId);
}
[Fact]
public async Task RejectsMalformedOrOversizedCursorsAndPageSizes()
{
Assert.Equal(HttpStatusCode.BadRequest, (await _client!.GetAsync("/external-authentication/identity-links?pageSize=101")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await _client.GetAsync($"/external-authentication/identity-links?cursor={new string('x', 513)}")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await Client.GetAsync("/external-authentication/user-options?pageSize=51")).StatusCode);
}
[Fact]
public async Task AllowsInheritedHostConnectionsButRejectsOtherTenantConnections()
{
_connections.UseHostConnection = true;
Assert.Equal(HttpStatusCode.Created, (await PrelinkAsync("host-subject")).StatusCode);
_connections.UseHostConnection = false;
_tenant.TenantId.Returns("tenant-b");
Assert.Equal(HttpStatusCode.NotFound, (await PrelinkAsync("other-tenant-subject", "user-b")).StatusCode);
}
private async Task<HttpResponseMessage> PrelinkAsync(string subject, string userId = "user-a") => await _client!.PostAsJsonAsync("/external-authentication/identity-links", new { userId, connectionKey = "contoso", issuer = "https://issuer.example/", subject });
protected async Task SeedUserAsync(string id, string name, string tenantId)
{
await using var scope = _app!.Services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<IUserStore>().SaveAsync(new User { Id = id, Name = name, TenantId = tenantId });
}
private sealed record LinkDocument(string Id, string UserId);
private sealed record LinkList(IReadOnlyCollection<LinkDocument> Items, string? NextCursor);
private sealed class TestConnectionRegistry : IIdentityProviderConnectionRegistry
{
public bool Archived { get; set; }
public bool UseHostConnection { get; set; }
public ValueTask<EffectiveConnectionRegistry> GetAsync(string targetTenantId, CancellationToken cancellationToken = default)
{
var connection = CreateConnection();
return ValueTask.FromResult(new EffectiveConnectionRegistry([connection], [], "test"));
}
public ValueTask<EffectiveIdentityProviderConnection?> FindByKeyAsync(string targetTenantId, string key, CancellationToken cancellationToken = default) => ValueTask.FromResult<EffectiveIdentityProviderConnection?>(string.Equals(targetTenantId, "tenant-a", StringComparison.Ordinal) && string.Equals(key, "contoso", StringComparison.Ordinal) ? CreateConnection() : null);
public ValueTask<EffectiveIdentityProviderConnection?> FindByIdAsync(string targetTenantId, string connectionId, CancellationToken cancellationToken = default) => ValueTask.FromResult<EffectiveIdentityProviderConnection?>(string.Equals(targetTenantId, "tenant-a", StringComparison.Ordinal) && string.Equals(connectionId, "connection-a", StringComparison.Ordinal) ? CreateConnection() : null);
private EffectiveIdentityProviderConnection CreateConnection() => new(new IdentityProviderConnection
{
Id = "connection-a",
TenantId = UseHostConnection ? ConnectionScope.HostTenantId : "tenant-a",
Key = "contoso",
AdapterType = "test",
AdapterSettingsVersion = 1,
DisplayName = "Contoso",
ArchivedAt = Archived ? DateTimeOffset.UtcNow : null,
IsEnabled = !Archived,
ClaimProjection = ClaimProjection.Empty
}, ConnectionSourceOwnership.Database, new ConnectionScope(ConnectionScopeKind.Tenant, "tenant-a"), ConnectionValidity.Valid, false, "test");
}
}