using System.Text.Json; using Elsa.ExternalAuthentication.Contracts; using Elsa.ExternalAuthentication.Models; using Elsa.ExternalAuthentication.Options; using Elsa.ExternalAuthentication.Policies; using Elsa.ExternalAuthentication.Services; namespace Elsa.ExternalAuthentication.UnitTests.Extensibility; public class ExtensionConformanceTests { [Fact] public void AdapterRegistryIsDeterministicDeploymentBoundedAndRejectsDuplicateIds() { var validator = new ExtensionDescriptorValidator(); var options = OptionsFor(allowedAdapters: ["second"]); var first = new ConformanceAdapter("first"); var second = new ConformanceAdapter("second"); var registry = new DefaultExternalAuthenticationAdapterRegistry([second, first], validator, Microsoft.Extensions.Options.Options.Create(options)); Assert.Equal(["second"], registry.ListDescriptors().Select(x => x.Type)); Assert.False(registry.TryGet("first", out _)); Assert.True(registry.TryGet("second", out var selected)); Assert.Same(second, selected); Assert.Throws(() => new DefaultExternalAuthenticationAdapterRegistry( [first, new ConformanceAdapter("first")], validator, Microsoft.Extensions.Options.Options.Create(OptionsFor()))); } [Fact] public void PolicyAndGrantSourceRegistriesExposeOnlyDeploymentAllowedExtensions() { var validator = new ExtensionDescriptorValidator(); var options = OptionsFor(); options.AllowedUnlinkedIdentityPolicyTypes = ["custom-policy"]; options.AllowedPermissionGrantSourceTypes = ["custom-grants"]; var policies = new DefaultUnlinkedIdentityPolicyRegistry( [new ConformancePolicy("hidden-policy"), new ConformancePolicy("custom-policy")], [], validator, Microsoft.Extensions.Options.Options.Create(options)); var sources = new DefaultPermissionGrantSourceRegistry( [new ConformanceGrantSource("hidden-grants"), new ConformanceGrantSource("custom-grants")], validator, Microsoft.Extensions.Options.Options.Create(options)); Assert.Equal(["custom-policy"], policies.ListDescriptors().Select(x => x.Type)); Assert.Equal(["custom-grants"], sources.ListDescriptors().Select(x => x.Type)); Assert.False(policies.TryGet("hidden-policy", out _)); Assert.False(sources.TryGet("hidden-grants", out _)); } [Fact] public void DescriptorValidatorRejectsMismatchedUnsafeAndIncompleteMetadata() { var invalidField = Field("secret", valueType: "string", secret: true, redacted: false) with { VisibleWhen = new SettingFieldVisibilityCondition("secret", "yes") }; var adapter = new ConformanceAdapter( "valid-type", new ExternalAuthenticationAdapterDescriptor( "different-type", "", "", 0, [invalidField], new ExternalAuthenticationAdapterCapabilities(true, true, true), new CustomEditorContract("", 0))); var exception = Assert.Throws(() => new ExtensionDescriptorValidator().Validate(adapter)); Assert.Contains("does not match", exception.Message); Assert.Contains("positive settings version", exception.Message); Assert.Contains("Secret-binding field", exception.Message); Assert.Contains("visibility condition", exception.Message); Assert.Contains("custom-editor", exception.Message); } [Fact] public void DescriptorValidatorAcceptsLowerCamelCaseSettingFieldNames() { var descriptor = new ExtensionDescriptorValidator().Validate(new CreateUserUnlinkedIdentityPolicy()); Assert.Contains(descriptor.Fields, field => field.Name == "defaultRoleIds"); } [Fact] public void DescriptorValidatorKeepsExtensionTypesLowercaseAndStable() { var exception = Assert.Throws(() => new ExtensionDescriptorValidator().Validate(new ConformanceAdapter("InvalidType"))); Assert.Contains("stable identifier", exception.Message); } [Fact] public async Task SettingsMigrationRunsAdapterOwnedForwardStepsAndRejectsUnsupportedVersions() { var adapter = new ConformanceAdapter("conformance", settingsVersion: 3); var registry = new DefaultExternalAuthenticationAdapterRegistry( [adapter], new ExtensionDescriptorValidator(), Microsoft.Extensions.Options.Options.Create(OptionsFor())); var service = new AdapterSettingsMigrationService( registry, [ new MigrationStep("conformance", 1, 2, settings => new { value = settings.GetProperty("value").GetString(), migratedTo = 2 }), new MigrationStep("conformance", 2, 3, settings => new { value = settings.GetProperty("value").GetString(), migratedTo = 3 }) ]); var migrated = await service.MigrateAsync( "conformance", 1, JsonSerializer.SerializeToElement(new { value = "opaque" })); var current = await service.MigrateAsync("conformance", 3, migrated.Settings); Assert.True(migrated.WasMigrated); Assert.Equal(3, migrated.SettingsVersion); Assert.Equal("opaque", migrated.Settings.GetProperty("value").GetString()); Assert.Equal(3, migrated.Settings.GetProperty("migratedTo").GetInt32()); Assert.False(current.WasMigrated); await Assert.ThrowsAsync(() => service.MigrateAsync("unsupported", 1, JsonSerializer.SerializeToElement(new { })).AsTask()); await Assert.ThrowsAsync(() => service.MigrateAsync("conformance", 4, JsonSerializer.SerializeToElement(new { })).AsTask()); } private static ExternalAuthenticationOptions OptionsFor(ICollection? allowedAdapters = null) => new() { AllowedAdapterTypes = allowedAdapters ?? [], AllowedUnlinkedIdentityPolicyTypes = [], AllowedPermissionGrantSourceTypes = [] }; private static SettingFieldDescriptor Field( string name, string valueType = "string", bool secret = false, bool redacted = false) => new( name, "Field", "A conformance field.", valueType, false, "text", null, [], new SettingFieldValidation(), secret, false, null, null, redacted); private sealed class ConformanceAdapter : IExternalAuthenticationAdapter { private readonly ExternalAuthenticationAdapterDescriptor _descriptor; public ConformanceAdapter(string type, int settingsVersion = 1) : this( type, new ExternalAuthenticationAdapterDescriptor( type, $"Adapter {type}", "A conformance adapter.", settingsVersion, [Field("endpoint", "uri")], new ExternalAuthenticationAdapterCapabilities(true, true, true), new CustomEditorContract("conformance-editor", 1))) { } public ConformanceAdapter(string type, ExternalAuthenticationAdapterDescriptor descriptor) { Type = type; _descriptor = descriptor; } public string Type { get; } public ExternalAuthenticationAdapterDescriptor Describe() => _descriptor; public ValueTask ValidateAsync(ConnectionValidationContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public ValueTask CreateAuthorizationRequestAsync(ExternalAuthorizationContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public ValueTask AuthenticateCallbackAsync(ExternalCallbackContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public ValueTask TestAsync(ConnectionTestContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public ValueTask CreateLogoutRequestAsync(ExternalLogoutContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class ConformancePolicy(string type) : IUnlinkedIdentityPolicy { public string Type => type; public UnlinkedIdentityPolicyDescriptor Describe() => new(type, $"Policy {type}", "A conformance policy.", 1, [], null); public ValueTask EvaluateAsync(UnlinkedIdentityContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class ConformanceGrantSource(string type) : IPermissionGrantSource { public string Type => type; public PermissionGrantSourceDescriptor Describe() => new(type, $"Source {type}", "A conformance grant source.", 1, [], null); public ValueTask GetGrantsAsync(PermissionGrantContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class MigrationStep( string adapterType, int fromVersion, int toVersion, Func migrate) : IAdapterSettingsMigration { public string AdapterType => adapterType; public int FromVersion => fromVersion; public int ToVersion => toVersion; public ValueTask MigrateAsync(JsonElement settings, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); return ValueTask.FromResult(JsonSerializer.SerializeToElement(migrate(settings))); } } }