From 27e6b81a6e34a7435d9ccf94d53cdc55aa86cfb1 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 23 May 2026 15:47:56 +0200 Subject: [PATCH] Increase Elsa Secrets test coverage (#7530) --- .../SecretManagerTests.cs | 111 ++++++++++++++++++ .../SecretStoreTests.cs | 86 +++++++++++--- 2 files changed, 180 insertions(+), 17 deletions(-) diff --git a/test/unit/Elsa.Secrets.UnitTests/SecretManagerTests.cs b/test/unit/Elsa.Secrets.UnitTests/SecretManagerTests.cs index 9f7805cbc..9aa78ee45 100644 --- a/test/unit/Elsa.Secrets.UnitTests/SecretManagerTests.cs +++ b/test/unit/Elsa.Secrets.UnitTests/SecretManagerTests.cs @@ -7,6 +7,16 @@ public class SecretManagerTests { private readonly SecretTestFixture _fixture = new(); + [Theory] + [InlineData("")] + [InlineData("a")] + [InlineData("1secret")] + [InlineData("secret name")] + public async Task CreateAsync_RejectsInvalidTechnicalNames(string name) + { + await Assert.ThrowsAsync(() => _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = name, Value = "one" })); + } + [Fact] public async Task CreateAsync_NormalizesTechnicalName_AndDoesNotExposeValueInModel() { @@ -79,6 +89,47 @@ public class SecretManagerTests Assert.Equal("two", await _fixture.Resolver.ResolveAsync("smtp:password")); } + [Fact] + public async Task CreateAsync_AllowsEncryptedCertificateWithThumbprintMetadata() + { + var secret = await _fixture.Manager.CreateAsync(new CreateSecretRequest + { + Name = "tls:certificate", + TypeName = SecretTypeNames.X509Certificate, + Value = " ", + Metadata = new Dictionary { ["thumbprint"] = "ABC123" } + }); + + Assert.Equal(SecretTypeNames.X509Certificate, secret.TypeName); + Assert.Equal("ABC123", secret.Versions.Single().Payload.Metadata["thumbprint"]); + } + + [Theory] + [InlineData(SecretTypeNames.Text, SecretStoreNames.Encrypted, null, null)] + [InlineData(SecretTypeNames.RsaKey, SecretStoreNames.Encrypted, " ", null)] + [InlineData(SecretTypeNames.RsaKey, SecretStoreNames.Configuration, null, " ")] + [InlineData(SecretTypeNames.X509Certificate, SecretStoreNames.Encrypted, " ", null)] + [InlineData(SecretTypeNames.X509Certificate, SecretStoreNames.Configuration, null, " ")] + public async Task CreateAsync_RejectsInvalidPayloadForTypeAndStore(string typeName, string storeName, string? value, string? configurationKey) + { + await Assert.ThrowsAsync(() => _fixture.Manager.CreateAsync(new CreateSecretRequest + { + Name = $"secret:{Guid.NewGuid():N}", + TypeName = typeName, + StoreName = storeName, + Value = value, + ConfigurationKey = configurationKey + })); + } + + [Fact] + public async Task RotateAsync_RejectsInvalidReplacementPayload() + { + await _fixture.Manager.CreateAsync(new CreateSecretRequest { Name = "smtp:password", Value = "one" }); + + await Assert.ThrowsAsync(() => _fixture.Manager.RotateAsync("smtp:password", new RotateSecretRequest())); + } + [Fact] public async Task CreateAsync_AllowsOnlyOneConcurrentReuseOfDeletedSecretName() { @@ -122,6 +173,66 @@ public class SecretManagerTests Assert.Equal(3, count); } + [Fact] + public async Task ListPageAsync_AppliesFiltersBeforePaging() + { + await _fixture.Manager.CreateAsync(new CreateSecretRequest + { + Name = "smtp:password", + DisplayName = "SMTP Password", + Description = "Production credential", + Scope = "Production", + Value = "one" + }); + await _fixture.Manager.CreateAsync(new CreateSecretRequest + { + Name = "api:key", + DisplayName = "API Key", + Description = "Production credential", + Scope = "production", + Value = "two" + }); + await _fixture.Manager.CreateAsync(new CreateSecretRequest + { + Name = "dev:token", + Description = "Development credential", + Scope = "development", + Value = "three" + }); + await _fixture.Manager.CreateAsync(new CreateSecretRequest + { + Name = "old:credential", + Description = "Production credential", + Scope = "production", + Value = "four" + }); + await _fixture.Manager.RevokeAsync("old:credential"); + + var page = await _fixture.Manager.ListPageAsync(new ListSecretsRequest + { + Search = "credential", + TypeNames = [SecretTypeNames.Text], + StoreNames = [SecretStoreNames.Encrypted], + Scope = "PRODUCTION", + Status = SecretStatus.Active, + Page = 1, + PageSize = 1 + }); + + Assert.Equal(2, page.TotalCount); + Assert.Single(page.Items); + Assert.Equal("smtp:password", page.Items.Single().Name); + } + + [Fact] + public async Task TestAsync_ReturnsFailedResult_WhenSecretDoesNotExist() + { + var result = await _fixture.Manager.TestAsync("missing:secret"); + + Assert.False(result.Succeeded); + Assert.Contains("missing:secret", result.Error); + } + private async Task TryCreateAsync(CreateSecretRequest request) { try diff --git a/test/unit/Elsa.Secrets.UnitTests/SecretStoreTests.cs b/test/unit/Elsa.Secrets.UnitTests/SecretStoreTests.cs index 77654dff9..fbcf9b67f 100644 --- a/test/unit/Elsa.Secrets.UnitTests/SecretStoreTests.cs +++ b/test/unit/Elsa.Secrets.UnitTests/SecretStoreTests.cs @@ -40,13 +40,28 @@ public class SecretStoreTests Assert.Equal("configured-secret", value); } + [Fact] + public async Task ConfigurationStore_TestAsync_ReturnsFalseWhenConfiguredValueIsMissing() + { + var fixture = new SecretTestFixture(); + await fixture.Manager.CreateAsync(new CreateSecretRequest + { + Name = "smtp:password", + StoreName = SecretStoreNames.Configuration, + ConfigurationKey = "MissingPassword" + }); + + var result = await fixture.Manager.TestAsync("smtp:password"); + + Assert.False(result.Succeeded); + Assert.Equal("Secret value is unavailable.", result.Error); + } + [Fact] public async Task FileRepository_PersistsSecretAggregate() { - var path = Path.Join(Path.GetTempPath(), $"elsa-secrets-{Guid.NewGuid():N}.json"); - try + await WithFileRepositoryAsync(async (repository, path) => { - var repository = new FileSecretRepository(Microsoft.Extensions.Options.Options.Create(new SecretsOptions { RepositoryFilePath = path })); var secret = new Secret { Name = "smtp:password", @@ -65,22 +80,49 @@ public class SecretStoreTests Assert.Contains("api-key", reloaded.Tags); Assert.True(reloaded.Versions.Single().Payload.Metadata.ContainsKey("protectedvalue")); Assert.Equal(1, reloaded.Versions.Single().Version); - } - finally + }); + } + + [Fact] + public async Task FileRepository_SaveAsync_AddsAndUpdatesSecret() + { + await WithFileRepositoryAsync(async (repository, _) => { - if (File.Exists(path)) - File.Delete(path); - } + await repository.SaveAsync(new Secret { Name = "smtp:password", DisplayName = "SMTP password" }); + await repository.SaveAsync(new Secret { Name = "smtp:password", DisplayName = "Updated password" }); + var reloaded = await repository.GetAsync("smtp:password"); + + Assert.NotNull(reloaded); + Assert.Equal("Updated password", reloaded.DisplayName); + }); + } + + [Fact] + public async Task FileRepository_TryAddOrReplaceDeletedAsync_ReplacesOnlyDeletedSecret() + { + await WithFileRepositoryAsync(async (repository, _) => + { + await repository.AddAsync(new Secret { Name = "smtp:password", DisplayName = "SMTP password" }); + + var activeReplacementResult = await repository.TryAddOrReplaceDeletedAsync(new Secret { Name = "SMTP:PASSWORD", DisplayName = "Active replacement" }); + await repository.SaveAsync(new Secret { Name = "smtp:password", DisplayName = "Deleted password", Status = SecretStatus.Deleted }); + var deletedReplacementResult = await repository.TryAddOrReplaceDeletedAsync(new Secret { Name = "SMTP:PASSWORD", DisplayName = "Replacement password" }); + var reloaded = await repository.GetAsync("smtp:password"); + + Assert.False(activeReplacementResult); + Assert.True(deletedReplacementResult); + Assert.NotNull(reloaded); + Assert.Equal("Replacement password", reloaded.DisplayName); + Assert.Equal(SecretStatus.Active, reloaded.Status); + }); } [Fact] public async Task FileRepository_RecoversFromCorruptJson() { - var path = Path.Join(Path.GetTempPath(), $"elsa-secrets-{Guid.NewGuid():N}.json"); - try + await WithFileRepositoryAsync(async (repository, path) => { await File.WriteAllTextAsync(path, "{not-valid-json"); - var repository = new FileSecretRepository(Microsoft.Extensions.Options.Options.Create(new SecretsOptions { RepositoryFilePath = path })); var secrets = await repository.ListAsync(); await repository.AddAsync(new Secret { Name = "smtp:password", DisplayName = "SMTP password" }); @@ -88,12 +130,7 @@ public class SecretStoreTests Assert.Empty(secrets); Assert.NotNull(reloaded); - } - finally - { - if (File.Exists(path)) - File.Delete(path); - } + }); } [Fact] @@ -117,4 +154,19 @@ public class SecretStoreTests Assert.Single(reloaded.Versions); Assert.True(reloaded.Versions.Single().Payload.Metadata.ContainsKey("protectedValue")); } + + private static async Task WithFileRepositoryAsync(Func test) + { + var path = Path.Join(Path.GetTempPath(), $"elsa-secrets-{Guid.NewGuid():N}.json"); + try + { + var repository = new FileSecretRepository(Microsoft.Extensions.Options.Options.Create(new SecretsOptions { RepositoryFilePath = path })); + await test(repository, path); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } }