using System.Text.Json; using Elsa.Expressions.Contracts; using Elsa.Expressions.Models; using Elsa.Expressions.Options; using Elsa.Expressions.Services; using Elsa.Secrets.Contracts; using Elsa.Secrets.Expressions; using Elsa.Secrets.Extensions; using Elsa.Secrets.Models; using Elsa.Secrets.Providers; using Elsa.Workflows.Models; using Elsa.Workflows.Serialization.Converters; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Elsa.Secrets.UnitTests; public class SecretExpressionTests { private readonly SecretTestFixture _fixture = new(); private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry = new WellKnownTypeRegistry(Microsoft.Extensions.Options.Options.Create(new ExpressionOptions())); private readonly SecretExpressionHandler _handler; public SecretExpressionTests() { _handler = new(_fixture.Resolver, _wellKnownTypeRegistry); } [Fact] public async Task EvaluateAsync_ResolvesSecretReference() { await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "api:key", Value = "top-secret" }); var result = await EvaluateAsync(new("api:key")); Assert.Equal("top-secret", result); } [Fact] public async Task EvaluateAsync_Throws_WhenSecretIsMissing() { var exception = await Assert.ThrowsAsync(() => EvaluateAsync(new("api:key"))); Assert.Equal("Secret 'api:key' was not found.", exception.Message); } [Fact] public async Task EvaluateAsync_Throws_WhenSecretTypeDoesNotMatchReference() { await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "api:key", TypeName = SecretTypeNames.Text, Value = "top-secret" }); var exception = await Assert.ThrowsAsync(() => EvaluateAsync(new("api:key", SecretTypeNames.RsaKey))); Assert.Equal("Secret 'api:key' is not compatible with required type 'rsa-key'.", exception.Message); } [Fact] public async Task EvaluateAsync_Throws_WhenSecretScopeDoesNotMatchReference() { await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "api:key", Scope = "production", Value = "top-secret" }); var exception = await Assert.ThrowsAsync(() => EvaluateAsync(new("api:key", Scope: "development"))); Assert.Equal("Secret 'api:key' is not compatible with required scope 'development'.", exception.Message); } [Fact] public async Task EvaluateAsync_PassesCancellationTokenToResolver() { using var cancellationTokenSource = new CancellationTokenSource(); var resolver = new CapturingSecretResolver("top-secret"); var handler = new SecretExpressionHandler(resolver, _wellKnownTypeRegistry); var context = CreateContext(cancellationTokenSource.Token); await handler.EvaluateAsync(SecretExpression.Create(new("api:key")), typeof(string), context, ExpressionEvaluatorOptions.Empty); Assert.Equal(cancellationTokenSource.Token, resolver.CancellationToken); } [Fact] public void SecretExpression_RoundTripsAsSecretReference() { var options = CreateSerializerOptions(); var expression = SecretExpression.Create(new("api:key", SecretTypeNames.Text, "production")); var json = JsonSerializer.Serialize(expression, options); var deserializedExpression = JsonSerializer.Deserialize(json, options)!; var deserializedReference = Assert.IsType(deserializedExpression.Value); Assert.Contains("\"type\":\"Secret\"", json); Assert.Contains("\"name\":\"api:key\"", json); Assert.Contains("\"typeName\":\"text\"", json); Assert.Contains("\"scope\":\"production\"", json); Assert.DoesNotContain("top-secret", json); Assert.Equal(SecretExpression.TypeName, deserializedExpression.Type); Assert.Equal(new SecretReference("api:key", SecretTypeNames.Text, "production"), deserializedReference); } [Fact] public void SecretExpression_DeserializesEmptyStringAsNullReference() { var options = CreateSerializerOptions(); const string json = """{"type":"Secret","value":""}"""; var expression = JsonSerializer.Deserialize(json, options)!; Assert.Equal(SecretExpression.TypeName, expression.Type); Assert.Null(expression.Value); } [Fact] public void SecretExpression_DeserializesStringAsSecretName() { var options = CreateSerializerOptions(); const string json = """{"type":"Secret","value":"api:key"}"""; var expression = JsonSerializer.Deserialize(json, options)!; var reference = Assert.IsType(expression.Value); Assert.Equal(new SecretReference("api:key"), reference); } [Fact] public void SecretExpression_DeserializesStringifiedSecretReference() { var options = CreateSerializerOptions(); const string json = """{"type":"Secret","value":"{\"name\":\"api:key\",\"typeName\":\"text\",\"scope\":\"production\"}"}"""; var expression = JsonSerializer.Deserialize(json, options)!; var reference = Assert.IsType(expression.Value); Assert.Equal(new SecretReference("api:key", SecretTypeNames.Text, "production"), reference); } [Fact] public void WorkflowInputJson_StoresSecretReferenceNotSecretValue() { var options = CreateSerializerOptions(); var input = new Input(SecretExpression.Create(new("api:key", SecretTypeNames.Text, "production"))); var json = JsonSerializer.Serialize(input, options); var deserializedInput = JsonSerializer.Deserialize>(json, options)!; var deserializedReference = Assert.IsType(deserializedInput.Expression!.Value); Assert.Contains("\"expression\":{\"type\":\"Secret\"", json); Assert.Contains("\"value\":{\"name\":\"api:key\"", json); Assert.DoesNotContain("top-secret", json); Assert.Equal(new SecretReference("api:key", SecretTypeNames.Text, "production"), deserializedReference); } [Fact] public void AddSecretsServices_RegistersSecretExpressionDescriptorProvider() { var services = new ServiceCollection(); services.AddSecretsServices(); var serviceProvider = services.BuildServiceProvider(); var provider = serviceProvider.GetServices().Single(x => x is SecretExpressionDescriptorProvider); var descriptor = provider.GetDescriptors().Single(); Assert.Equal(SecretExpression.TypeName, descriptor.Type); Assert.Equal("secret-picker", descriptor.Properties["UIHint"]); Assert.Equal("/secrets/picker", descriptor.Properties["PickerEndpoint"]); } private async Task EvaluateAsync(SecretReference reference) { var expression = SecretExpression.Create(reference); var context = CreateContext(); return (T?)await _handler.EvaluateAsync(expression, typeof(T), context, ExpressionEvaluatorOptions.Empty); } private static ExpressionExecutionContext CreateContext(CancellationToken cancellationToken = default) { return new(new ServiceCollection().BuildServiceProvider(), new MemoryRegister(), cancellationToken: cancellationToken); } private static JsonSerializerOptions CreateSerializerOptions() { var registry = new TestExpressionDescriptorRegistry(new SecretExpressionDescriptorProvider().GetDescriptors()); var serviceProvider = new ServiceCollection() .AddSingleton(registry) .BuildServiceProvider(); return new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new TypeJsonConverter(), new ExpressionJsonConverterFactory(registry), new InputJsonConverterFactory(serviceProvider) } }; } private class CapturingSecretResolver(string value) : ISecretResolver { public CancellationToken CancellationToken { get; private set; } public Task ResolveAsync(string name, CancellationToken cancellationToken = default) => ResolveAsync(new SecretReference(name), cancellationToken); public Task ResolveAsync(SecretReference reference, CancellationToken cancellationToken = default) { CancellationToken = cancellationToken; return Task.FromResult(value); } } private class TestExpressionDescriptorRegistry(IEnumerable descriptors) : IExpressionDescriptorRegistry { private readonly Dictionary _descriptors = descriptors.ToDictionary(x => x.Type); public void Add(ExpressionDescriptor descriptor) => _descriptors[descriptor.Type] = descriptor; public void AddRange(IEnumerable descriptors) { foreach (var descriptor in descriptors) Add(descriptor); } public IEnumerable ListAll() => _descriptors.Values; public ExpressionDescriptor? Find(Func predicate) => _descriptors.Values.FirstOrDefault(predicate); public ExpressionDescriptor? Find(string type) => _descriptors.GetValueOrDefault(type); } }