Implement secret update functionality, add related tests, and update project references.

This commit is contained in:
Sipke Schoorstra 2026-06-01 23:47:05 +02:00
parent aa55274bcf
commit 87ee19f5b3
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
8 changed files with 115 additions and 7 deletions

View file

@ -1652,17 +1652,17 @@ Global
{09B4B78B-FE02-44E2-8667-E182AF921C54} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89} {09B4B78B-FE02-44E2-8667-E182AF921C54} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9} = {18453B51-25EB-4317-A4B3-B10518252E92} {7D905CEC-B30B-4C99-B5F7-3052D33EC8E9} = {18453B51-25EB-4317-A4B3-B10518252E92}
{81CFD2E0-2E5E-4810-ADB8-A08301199166} = {18453B51-25EB-4317-A4B3-B10518252E92} {81CFD2E0-2E5E-4810-ADB8-A08301199166} = {18453B51-25EB-4317-A4B3-B10518252E92}
{8F4AD54E-8586-4D8C-82E6-69218DD4280F} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{60F844CC-6DA6-45EB-BA5E-B480B4E77624} = {18453B51-25EB-4317-A4B3-B10518252E92} {60F844CC-6DA6-45EB-BA5E-B480B4E77624} = {18453B51-25EB-4317-A4B3-B10518252E92}
{B6A62D42-D9AA-47BE-BCBC-FCB866D346F0} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} {B6A62D42-D9AA-47BE-BCBC-FCB866D346F0} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
{D3E5E9EF-DE26-41BF-A239-3241B5B5FD89} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
{80F7ED38-15BA-4C82-88C8-12135B33F7EF} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89} {80F7ED38-15BA-4C82-88C8-12135B33F7EF} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
{0C6F614C-A698-426B-8D11-0132C2FFF400} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {0C6F614C-A698-426B-8D11-0132C2FFF400} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
{4DEE0574-C5B3-4765-9C48-89D384A17780} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {E18931B2-82EE-4398-B38A-7B8B449957CD} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
{151C46EF-FE3D-400B-8CBE-F86886B3EB96} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {729FD075-2863-48AB-A3C0-A4F7A9277263} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
{E4B1546A-3F35-4B5C-815C-07E58B00163F} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {E4B1546A-3F35-4B5C-815C-07E58B00163F} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
{E18931B2-82EE-4398-B38A-7B8B449957CD} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {4DEE0574-C5B3-4765-9C48-89D384A17780} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
{729FD075-2863-48AB-A3C0-A4F7A9277263} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {151C46EF-FE3D-400B-8CBE-F86886B3EB96} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
{8F4AD54E-8586-4D8C-82E6-69218DD4280F} = {78FD90A4-90A5-445F-97F2-74BA835AFA5D}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -19,6 +19,7 @@
<ProjectReference Include="..\..\modules\Elsa.Identity\Elsa.Identity.csproj" /> <ProjectReference Include="..\..\modules\Elsa.Identity\Elsa.Identity.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Persistence.EFCore.Sqlite\Elsa.Persistence.EFCore.Sqlite.csproj" /> <ProjectReference Include="..\..\modules\Elsa.Persistence.EFCore.Sqlite\Elsa.Persistence.EFCore.Sqlite.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Resilience\Elsa.Resilience.csproj" /> <ProjectReference Include="..\..\modules\Elsa.Resilience\Elsa.Resilience.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Secrets.JavaScript\Elsa.Secrets.JavaScript.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Secrets\Elsa.Secrets.csproj" /> <ProjectReference Include="..\..\modules\Elsa.Secrets\Elsa.Secrets.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Shells.Api\Elsa.Shells.Api.csproj" /> <ProjectReference Include="..\..\modules\Elsa.Shells.Api\Elsa.Shells.Api.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Workflows.Api\Elsa.Workflows.Api.csproj" /> <ProjectReference Include="..\..\modules\Elsa.Workflows.Api\Elsa.Workflows.Api.csproj" />

View file

@ -58,6 +58,7 @@
"Secrets": { "Secrets": {
"EncryptionKey": "Q0hBTkdFX01FX1RPX0FfU0VDVVJFX1JBTkRPTV9LRVk=" "EncryptionKey": "Q0hBTkdFX01FX1RPX0FfU0VDVVJFX1JBTkRPTV9LRVk="
}, },
"SecretsJavaScript": {},
"SqliteAlterationsPersistence": { "SqliteAlterationsPersistence": {
"ConnectionString": "Data Source=elsa_workflows.db;Cache=Shared" "ConnectionString": "Data Source=elsa_workflows.db;Cache=Shared"
}, },

View file

@ -7,6 +7,7 @@ public interface ISecretManager
Task<IReadOnlyCollection<Secret>> ListAsync(ListSecretsRequest request, CancellationToken cancellationToken = default); Task<IReadOnlyCollection<Secret>> ListAsync(ListSecretsRequest request, CancellationToken cancellationToken = default);
Task<ListSecretsResult> ListPageAsync(ListSecretsRequest request, CancellationToken cancellationToken = default); Task<ListSecretsResult> ListPageAsync(ListSecretsRequest request, CancellationToken cancellationToken = default);
Task<long> CountAsync(ListSecretsRequest request, CancellationToken cancellationToken = default); Task<long> CountAsync(ListSecretsRequest request, CancellationToken cancellationToken = default);
Task<Secret> UpdateAsync(string name, UpdateSecretRequest request, CancellationToken cancellationToken = default);
Task<Secret> RotateAsync(string name, RotateSecretRequest request, CancellationToken cancellationToken = default); Task<Secret> RotateAsync(string name, RotateSecretRequest request, CancellationToken cancellationToken = default);
Task<Secret?> RevokeAsync(string name, CancellationToken cancellationToken = default); Task<Secret?> RevokeAsync(string name, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string name, CancellationToken cancellationToken = default); Task<bool> DeleteAsync(string name, CancellationToken cancellationToken = default);

View file

@ -0,0 +1,27 @@
using Elsa.Abstractions;
using Elsa.Secrets.Permissions;
using Elsa.Secrets.Services;
namespace Elsa.Secrets.Endpoints.Secrets.Update;
internal class Endpoint(ISecretManager manager) : ElsaEndpoint<UpdateSecretRequest, SecretModel>
{
public override void Configure()
{
Post("/secrets/{name}");
ConfigurePermissions(SecretsPermissions.Write);
}
public override async Task HandleAsync(UpdateSecretRequest request, CancellationToken cancellationToken)
{
try
{
var secret = await manager.UpdateAsync(Route<string>("name")!, request, cancellationToken);
await Send.OkAsync(secret.ToModel(), cancellationToken);
}
catch (KeyNotFoundException)
{
await Send.NotFoundAsync(cancellationToken);
}
}
}

View file

@ -32,6 +32,12 @@ public class CreateSecretRequest
public IDictionary<string, string> Metadata { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); public IDictionary<string, string> Metadata { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
} }
public class UpdateSecretRequest
{
public string? DisplayName { get; set; }
public string? Description { get; set; }
}
public class RotateSecretRequest public class RotateSecretRequest
{ {
public string? Value { get; set; } public string? Value { get; set; }

View file

@ -47,6 +47,18 @@ public class DefaultSecretManager(ISecretNameValidator nameValidator, ISecretSto
return ApplyFilters(secrets, request).LongCount(); return ApplyFilters(secrets, request).LongCount();
} }
public async Task<Secret> UpdateAsync(string name, UpdateSecretRequest request, CancellationToken cancellationToken = default)
{
var secret = await GetExistingAsync(name, cancellationToken);
secret.DisplayName = string.IsNullOrWhiteSpace(request.DisplayName) ? secret.Name : request.DisplayName.Trim();
secret.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
secret.UpdatedAt = DateTimeOffset.UtcNow;
await repository.SaveAsync(secret, cancellationToken);
return secret;
}
public async Task<Secret> RotateAsync(string name, RotateSecretRequest request, CancellationToken cancellationToken = default) public async Task<Secret> RotateAsync(string name, RotateSecretRequest request, CancellationToken cancellationToken = default)
{ {
var secret = await GetExistingAsync(name, cancellationToken); var secret = await GetExistingAsync(name, cancellationToken);

View file

@ -173,6 +173,66 @@ public class SecretManagerTests
Assert.Equal(3, count); Assert.Equal(3, count);
} }
[Fact]
public async Task UpdateAsync_UpdatesMetadataWithoutChangingIdentityOrVersion()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest
{
Name = "smtp:password",
DisplayName = "SMTP password",
Description = "Old description",
Scope = "production",
Value = "one"
});
var updated = await _fixture.Manager.UpdateAsync("smtp:password", new UpdateSecretRequest
{
DisplayName = " SMTP credential ",
Description = " Rotated manually "
});
Assert.Equal("smtp:password", updated.Name);
Assert.Equal("SMTP credential", updated.DisplayName);
Assert.Equal("Rotated manually", updated.Description);
Assert.Equal("production", updated.Scope);
Assert.Equal(SecretTypeNames.Text, updated.TypeName);
Assert.Equal(SecretStoreNames.Encrypted, updated.StoreName);
Assert.Single(updated.Versions);
Assert.NotNull(updated.UpdatedAt);
Assert.Equal("one", await _fixture.Resolver.ResolveAsync("smtp:password"));
}
[Fact]
public async Task UpdateAsync_UsesTechnicalNameAsFallbackDisplayName_AndClearsBlankDescription()
{
await _fixture.Manager.CreateAsync(new CreateSecretRequest
{
Name = "smtp:password",
DisplayName = "SMTP password",
Description = "Old description",
Value = "one"
});
var updated = await _fixture.Manager.UpdateAsync("smtp:password", new UpdateSecretRequest
{
DisplayName = " ",
Description = " "
});
Assert.Equal("smtp:password", updated.DisplayName);
Assert.Null(updated.Description);
}
[Fact]
public async Task UpdateAsync_Throws_WhenSecretDoesNotExist()
{
await Assert.ThrowsAsync<KeyNotFoundException>(() => _fixture.Manager.UpdateAsync("missing:secret", new UpdateSecretRequest
{
DisplayName = "Missing",
Description = "Missing"
}));
}
[Fact] [Fact]
public async Task ListPageAsync_AppliesFiltersBeforePaging() public async Task ListPageAsync_AppliesFiltersBeforePaging()
{ {