Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AndreGoepel.Marten.Configuration.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<Project Path="src/AndreGoepel.Marten.Configuration/AndreGoepel.Marten.Configuration.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/AndreGoepel.Marten.Configuration.Tests/AndreGoepel.Marten.Configuration.Tests.csproj" />
<Project Path="tests/AndreGoepel.Marten.Configuration.IntegrationTests/AndreGoepel.Marten.Configuration.IntegrationTests.csproj" />
</Folder>
</Solution>
19 changes: 14 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,25 @@ among others. Plain class library — no ASP.NET Core dependency.
- `SaveAsync` sets `SettingsDocument.Id` from `T.DocumentId` itself —
callers never set it manually.
- Public API surface is only `SettingsDocument`, `ISettingsDocument<T>`,
`ISettingsStore`, `SettingsStoreOptionsExtensions`, `Initialization` —
everything else stays `internal`.
`ISettingsStore`, `SettingsStoreOptionsExtensions`, `Initialization`,
`DataProtectorExtensions` — everything else stays `internal`.
- `DataProtectorExtensions.ProtectOrKeepExisting` is the shared
"protect a new value, or keep the existing ciphertext when the caller
left the field blank" round trip for a secret field inside a settings
document (SMTP password, API token, provider credential, ...). It takes
the caller's own `IDataProtector` — this library never constructs one
itself, so it only depends on `Microsoft.AspNetCore.DataProtection.Abstractions`
(interfaces only), not the full ASP.NET Core shared framework.
- Exception: `MartenSettingsStore` is `public` (not `internal`) despite the
rule above — Wolverine's `NotAllowed` service-location policy constructs
it directly in generated handler code and requires public visibility.

## Testing
- Scope: the Marten store and schema-hierarchy behavior (round-trip,
shared-table mapping); integration tests need Docker for the Postgres
container
- `AndreGoepel.Marten.Configuration.Tests` — pure unit tests, no I/O
(e.g. `DataProtectorExtensions`)
- `AndreGoepel.Marten.Configuration.IntegrationTests` — the Marten store
and schema-hierarchy behavior (round-trip, shared-table mapping); needs
Docker for the Postgres container

## Conventions Audit
- `.editorconfig` is intentionally the 26-line variant (no Razor/CSS/JS
Expand Down
4 changes: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
/>
</ItemGroup>

<ItemGroup Label="DataProtection">
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Abstractions" Version="10.0.10" />
</ItemGroup>

<ItemGroup Label="Testing">
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@

<ItemGroup>
<PackageReference Include="Marten" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="AndreGoepel.Marten.Configuration.Tests" />
<InternalsVisibleTo Include="AndreGoepel.Marten.Configuration.IntegrationTests" />
</ItemGroup>
</Project>
30 changes: 30 additions & 0 deletions src/AndreGoepel.Marten.Configuration/DataProtectorExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.DataProtection;

namespace AndreGoepel.Marten.Configuration;

/// <summary>
/// Shared round trip for a DataProtection-encrypted secret field inside a
/// <see cref="SettingsDocument"/> (an SMTP password, an API token, a provider credential, ...).
/// </summary>
public static class DataProtectorExtensions
{
/// <summary>
/// Protects <paramref name="newPlaintext"/> when the caller supplied one; otherwise keeps
/// <paramref name="existingCiphertext"/> unchanged. This is the "leave the field blank to
/// keep the current secret" idiom used by every settings form that edits a protected value.
/// Returns <c>null</c> when there is neither a new value nor an existing one — callers
/// decide what that means for them (reject the save, or protect an explicit empty string
/// when a blank secret is itself a valid saved state).
/// </summary>
public static string? ProtectOrKeepExisting(
this IDataProtector protector,
string? newPlaintext,
string? existingCiphertext
)
{
ArgumentNullException.ThrowIfNull(protector);
return !string.IsNullOrEmpty(newPlaintext)
? protector.Protect(newPlaintext)
: existingCiphertext;
}
}
6 changes: 6 additions & 0 deletions src/AndreGoepel.Marten.Configuration/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
"Weasel.Storage": "9.17.0"
}
},
"Microsoft.AspNetCore.DataProtection.Abstractions": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "q2RNx0N/qrsU+JgbPE78I9pu5ekwguitAFVoeE1NCgxtkUTguvf7oTzBnn42zSQxuugJ4fmj0RSarob8Rvorrg=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Direct",
"requested": "[10.0.10, )",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,7 @@
"type": "Project",
"dependencies": {
"Marten": "[9.19.0, )",
"Microsoft.AspNetCore.DataProtection.Abstractions": "[10.0.10, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.10, )"
}
},
Expand All @@ -710,6 +711,12 @@
"Weasel.Storage": "9.17.0"
}
},
"Microsoft.AspNetCore.DataProtection.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "q2RNx0N/qrsU+JgbPE78I9pu5ekwguitAFVoeE1NCgxtkUTguvf7oTzBnn42zSQxuugJ4fmj0RSarob8Rvorrg=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<!-- Test-only: gives access to EphemeralDataProtectionProvider / DataProtectionProvider.Create
for in-memory IDataProtector round trips. The packable src project stays on the
Abstractions-only package (see its csproj) so it has no ASP.NET Core dependency. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\AndreGoepel.Marten.Configuration\AndreGoepel.Marten.Configuration.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using Microsoft.AspNetCore.DataProtection;

namespace AndreGoepel.Marten.Configuration.Tests;

public sealed class DataProtectorExtensionsTests
{
private readonly IDataProtector protector =
new EphemeralDataProtectionProvider().CreateProtector(
"AndreGoepel.Marten.Configuration.Tests.DataProtectorExtensions"
);

[Fact]
public void ProtectOrKeepExisting_NewPlaintextGiven_ProtectsTheNewValue()
{
// Act
var ciphertext = protector.ProtectOrKeepExisting("new-secret", "old-ciphertext");

// Assert
Assert.NotNull(ciphertext);
Assert.NotEqual("old-ciphertext", ciphertext);
Assert.Equal("new-secret", protector.Unprotect(ciphertext));
}

[Fact]
public void ProtectOrKeepExisting_NewPlaintextEmpty_KeepsExistingCiphertext()
{
// Act
var ciphertext = protector.ProtectOrKeepExisting(string.Empty, "old-ciphertext");

// Assert
Assert.Equal("old-ciphertext", ciphertext);
}

[Fact]
public void ProtectOrKeepExisting_NewPlaintextNull_KeepsExistingCiphertext()
{
// Act
var ciphertext = protector.ProtectOrKeepExisting(null, "old-ciphertext");

// Assert
Assert.Equal("old-ciphertext", ciphertext);
}

[Fact]
public void ProtectOrKeepExisting_NeitherNewValueNorExistingCiphertextGiven_ReturnsNull()
{
// Act
var ciphertext = protector.ProtectOrKeepExisting(null, null);

// Assert
Assert.Null(ciphertext);
}

[Fact]
public void ProtectOrKeepExisting_RoundTripsThroughUnprotect()
{
// Act
var ciphertext = protector.ProtectOrKeepExisting("correct horse battery staple", null);

// Assert
Assert.NotNull(ciphertext);
Assert.Equal("correct horse battery staple", protector.Unprotect(ciphertext));
}

[Fact]
public void ProtectOrKeepExisting_NullProtector_Throws()
{
// Act / Assert
Assert.Throws<ArgumentNullException>(() =>
DataProtectorExtensions.ProtectOrKeepExisting(null!, "value", null)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using Xunit;
Loading