stabilizaiton work - projects rework for maintenanceability and ui livening

This commit is contained in:
master
2026-02-03 23:40:04 +02:00
parent 074ce117ba
commit 557feefdc3
3305 changed files with 186813 additions and 107843 deletions

View File

@@ -0,0 +1,50 @@
using StellaOps.Auth.Security.Dpop;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Auth.Security.Tests;
public sealed partial class DpopNonceStoreTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InMemoryNonceStore_IssuesAndConsumesNonceAsync()
{
var timeProvider = CreateTimeProvider();
var store = new InMemoryDpopNonceStore(timeProvider);
var issue = await store.IssueAsync(
"audience",
"client",
"thumb",
TimeSpan.FromMinutes(5),
maxIssuancePerMinute: 5);
Assert.Equal(DpopNonceIssueStatus.Success, issue.Status);
Assert.False(string.IsNullOrWhiteSpace(issue.Nonce));
var consume = await store.TryConsumeAsync(issue.Nonce!, "audience", "client", "thumb");
Assert.Equal(DpopNonceConsumeStatus.Success, consume.Status);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InMemoryNonceStore_ReturnsExpiredAfterTtlAsync()
{
var timeProvider = CreateTimeProvider();
var store = new InMemoryDpopNonceStore(timeProvider);
var issue = await store.IssueAsync(
"audience",
"client",
"thumb",
TimeSpan.FromMinutes(1),
maxIssuancePerMinute: 5);
timeProvider.Advance(TimeSpan.FromMinutes(2));
var consume = await store.TryConsumeAsync(issue.Nonce!, "audience", "client", "thumb");
Assert.Equal(DpopNonceConsumeStatus.Expired, consume.Status);
}
}

View File

@@ -0,0 +1,11 @@
using System;
using Microsoft.Extensions.Time.Testing;
namespace StellaOps.Auth.Security.Tests;
public sealed partial class DpopNonceStoreTests
{
private static readonly DateTimeOffset FixedUtcNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static FakeTimeProvider CreateTimeProvider() => new(FixedUtcNow);
}

View File

@@ -1,7 +1,7 @@
using System;
using StellaOps.Auth.Security.Dpop;
using Xunit;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Auth.Security.Tests;
public class DpopNonceUtilitiesTests
@@ -11,7 +11,18 @@ public class DpopNonceUtilitiesTests
public void ComputeStorageKey_NormalizesToLowerInvariant()
{
var key = DpopNonceUtilities.ComputeStorageKey("API", "Client-Id", "ThumbPrint");
Assert.Equal("dpop-nonce:api:client-id:thumbprint", key);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GenerateNonce_ReturnsUrlSafeValue()
{
var nonce = DpopNonceUtilities.GenerateNonce();
Assert.False(string.IsNullOrWhiteSpace(nonce));
Assert.DoesNotContain("+", nonce, StringComparison.Ordinal);
Assert.DoesNotContain("/", nonce, StringComparison.Ordinal);
Assert.DoesNotContain("=", nonce, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,37 @@
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Auth.Security.Tests;
public sealed partial class DpopProofValidatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Validate_ReturnsFailure_ForNonStringTypAsync()
{
var proof = BuildUnsignedToken(
new { typ = 123, alg = "ES256" },
new { htm = "GET", htu = DefaultUri.ToString(), iat = 0, jti = "1" });
var validator = CreateValidator(FixedUtcNow);
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
Assert.False(result.IsValid);
Assert.Equal("invalid_header", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Validate_ReturnsFailure_ForNonStringAlgAsync()
{
var proof = BuildUnsignedToken(
new { typ = "dpop+jwt", alg = 55 },
new { htm = "GET", htu = DefaultUri.ToString(), iat = 0, jti = "1" });
var validator = CreateValidator(FixedUtcNow);
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
Assert.False(result.IsValid);
Assert.Equal("invalid_header", result.ErrorCode);
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Security.Dpop;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Auth.Security.Tests;
public sealed partial class DpopProofValidatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Validate_UsesSnapshotOfOptionsAsync()
{
var (proof, _) = CreateSignedProof(FixedUtcNow);
var options = new DpopValidationOptions();
var timeProvider = new FakeTimeProvider(FixedUtcNow);
var validator = new DpopProofValidator(Options.Create(options), new InMemoryDpopReplayCache(timeProvider), timeProvider);
options.AllowedAlgorithms.Clear();
options.AllowedAlgorithms.Add("ES512");
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
Assert.True(result.IsValid);
}
}

View File

@@ -0,0 +1,46 @@
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Auth.Security.Tests;
public sealed partial class DpopProofValidatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Validate_ReturnsFailure_ForNonStringHtmAsync()
{
var (proof, _) = CreateSignedProof(FixedUtcNow, payloadMutator: payload => payload["htm"] = 123);
var validator = CreateValidator(FixedUtcNow);
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
Assert.False(result.IsValid);
Assert.Equal("invalid_payload", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Validate_ReturnsFailure_ForNonStringHtuAsync()
{
var (proof, _) = CreateSignedProof(FixedUtcNow, payloadMutator: payload => payload["htu"] = 123);
var validator = CreateValidator(FixedUtcNow);
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
Assert.False(result.IsValid);
Assert.Equal("invalid_payload", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Validate_ReturnsFailure_ForNonStringNonceAsync()
{
var (proof, _) = CreateSignedProof(FixedUtcNow, payloadMutator: payload => payload["nonce"] = 999);
var validator = CreateValidator(FixedUtcNow);
var result = await validator.ValidateAsync(proof, "GET", DefaultUri, nonce: "nonce-1");
Assert.False(result.IsValid);
Assert.Equal("invalid_token", result.ErrorCode);
}
}

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Security.Dpop;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Auth.Security.Tests;
public sealed partial class DpopProofValidatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Validate_RejectsReplayTokensAsync()
{
var jwtId = "jwt-1";
var (proof, _) = CreateSignedProof(FixedUtcNow, jti: jwtId);
var timeProvider = new FakeTimeProvider(FixedUtcNow);
var replayCache = new InMemoryDpopReplayCache(timeProvider);
var validator = CreateValidator(timeProvider, replayCache);
var first = await validator.ValidateAsync(proof, "GET", DefaultUri);
var second = await validator.ValidateAsync(proof, "GET", DefaultUri);
Assert.True(first.IsValid);
Assert.False(second.IsValid);
Assert.Equal("replay", second.ErrorCode);
}
}

View File

@@ -0,0 +1,41 @@
using System;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Auth.Security.Tests;
public sealed partial class DpopProofValidatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Validate_RejectsProofIssuedInFutureAsync()
{
var issuedAt = FixedUtcNow.AddMinutes(2);
var (proof, _) = CreateSignedProof(issuedAt);
var validator = CreateValidator(FixedUtcNow, options => options.AllowedClockSkew = TimeSpan.FromSeconds(5));
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
Assert.False(result.IsValid);
Assert.Equal("invalid_token", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Validate_RejectsExpiredProofsAsync()
{
var issuedAt = FixedUtcNow.AddMinutes(-10);
var (proof, _) = CreateSignedProof(issuedAt);
var validator = CreateValidator(FixedUtcNow, options =>
{
options.ProofLifetime = TimeSpan.FromMinutes(1);
options.AllowedClockSkew = TimeSpan.FromSeconds(5);
});
var result = await validator.ValidateAsync(proof, "GET", DefaultUri);
Assert.False(result.IsValid);
Assert.Equal("invalid_token", result.ErrorCode);
}
}

View File

@@ -8,160 +8,15 @@ using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Security.Dpop;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Auth.Security.Tests;
public class DpopProofValidatorTests
public sealed partial class DpopProofValidatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_ReturnsFailure_ForNonStringTyp()
{
var proof = BuildUnsignedToken(
new { typ = 123, alg = "ES256" },
new { htm = "GET", htu = "https://api.test/resource", iat = 0, jti = "1" });
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var validator = CreateValidator(now);
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
Assert.False(result.IsValid);
Assert.Equal("invalid_header", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_ReturnsFailure_ForNonStringAlg()
{
var proof = BuildUnsignedToken(
new { typ = "dpop+jwt", alg = 55 },
new { htm = "GET", htu = "https://api.test/resource", iat = 0, jti = "1" });
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var validator = CreateValidator(now);
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
Assert.False(result.IsValid);
Assert.Equal("invalid_header", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_ReturnsFailure_ForNonStringHtm()
{
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var (proof, _) = CreateSignedProof(now, payloadMutator: payload => payload["htm"] = 123);
var validator = CreateValidator(now);
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
Assert.False(result.IsValid);
Assert.Equal("invalid_payload", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_ReturnsFailure_ForNonStringHtu()
{
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var (proof, _) = CreateSignedProof(now, payloadMutator: payload => payload["htu"] = 123);
var validator = CreateValidator(now);
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
Assert.False(result.IsValid);
Assert.Equal("invalid_payload", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_ReturnsFailure_ForNonStringNonce()
{
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var (proof, _) = CreateSignedProof(now, payloadMutator: payload => payload["nonce"] = 999);
var validator = CreateValidator(now);
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"), nonce: "nonce-1");
Assert.False(result.IsValid);
Assert.Equal("invalid_token", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_RejectsProofIssuedInFuture()
{
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var issuedAt = now.AddMinutes(2);
var (proof, _) = CreateSignedProof(issuedAt);
var validator = CreateValidator(now, options => options.AllowedClockSkew = TimeSpan.FromSeconds(5));
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
Assert.False(result.IsValid);
Assert.Equal("invalid_token", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_RejectsExpiredProofs()
{
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var issuedAt = now.AddMinutes(-10);
var (proof, _) = CreateSignedProof(issuedAt);
var validator = CreateValidator(now, options =>
{
options.ProofLifetime = TimeSpan.FromMinutes(1);
options.AllowedClockSkew = TimeSpan.FromSeconds(5);
});
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
Assert.False(result.IsValid);
Assert.Equal("invalid_token", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_RejectsReplayTokens()
{
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var jwtId = "jwt-1";
var (proof, _) = CreateSignedProof(now, jti: jwtId);
var timeProvider = new FakeTimeProvider(now);
var replayCache = new InMemoryDpopReplayCache(timeProvider);
var validator = CreateValidator(timeProvider, replayCache);
var first = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
var second = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
Assert.True(first.IsValid);
Assert.False(second.IsValid);
Assert.Equal("replay", second.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_UsesSnapshotOfOptions()
{
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var (proof, _) = CreateSignedProof(now);
var options = new DpopValidationOptions();
var timeProvider = new FakeTimeProvider(now);
var validator = new DpopProofValidator(Options.Create(options), new InMemoryDpopReplayCache(timeProvider), timeProvider);
options.AllowedAlgorithms.Clear();
options.AllowedAlgorithms.Add("ES512");
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
Assert.True(result.IsValid);
}
private static readonly DateTimeOffset FixedUtcNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly Uri DefaultUri = new("https://api.test/resource");
private const string DefaultKeyId = "test-key-001";
private const string DefaultJti = "jwt-0001";
private static DpopProofValidator CreateValidator(DateTimeOffset now, Action<DpopValidationOptions>? configure = null)
{
@@ -169,7 +24,10 @@ public class DpopProofValidatorTests
return CreateValidator(timeProvider, null, configure);
}
private static DpopProofValidator CreateValidator(TimeProvider timeProvider, IDpopReplayCache? replayCache = null, Action<DpopValidationOptions>? configure = null)
private static DpopProofValidator CreateValidator(
TimeProvider timeProvider,
IDpopReplayCache? replayCache = null,
Action<DpopValidationOptions>? configure = null)
{
var options = new DpopValidationOptions();
configure?.Invoke(options);
@@ -185,10 +43,10 @@ public class DpopProofValidatorTests
Action<JwtHeader>? headerMutator = null,
Action<JwtPayload>? payloadMutator = null)
{
httpUri ??= new Uri("https://api.test/resource");
httpUri ??= DefaultUri;
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var securityKey = new ECDsaSecurityKey(ecdsa) { KeyId = Guid.NewGuid().ToString("N") };
var securityKey = new ECDsaSecurityKey(ecdsa) { KeyId = DefaultKeyId };
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
var jwkHeader = new Dictionary<string, object>
@@ -215,7 +73,7 @@ public class DpopProofValidatorTests
{ "htm", method ?? "GET" },
{ "htu", httpUri.ToString() },
{ "iat", issuedAt.ToUnixTimeSeconds() },
{ "jti", jti ?? Guid.NewGuid().ToString("N") }
{ "jti", jti ?? DefaultJti }
};
if (nonce is not null)
@@ -230,7 +88,6 @@ public class DpopProofValidatorTests
return (handler.WriteToken(token), jwk);
}
private static string BuildUnsignedToken(object header, object payload)
{
var headerJson = JsonSerializer.Serialize(header);

View File

@@ -0,0 +1,23 @@
using StellaOps.Auth.Security.Dpop;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Auth.Security.Tests;
public sealed partial class DpopReplayCacheTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InMemoryReplayCache_RejectsDuplicatesUntilExpiryAsync()
{
var timeProvider = CreateTimeProvider();
var cache = new InMemoryDpopReplayCache(timeProvider);
var expiresAt = timeProvider.GetUtcNow().AddMinutes(1);
Assert.True(await cache.TryStoreAsync("jti-1", expiresAt));
Assert.False(await cache.TryStoreAsync("jti-1", expiresAt));
timeProvider.Advance(TimeSpan.FromMinutes(2));
Assert.True(await cache.TryStoreAsync("jti-1", timeProvider.GetUtcNow().AddMinutes(1)));
}
}

View File

@@ -0,0 +1,24 @@
using StellaOps.Auth.Security.Dpop;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Auth.Security.Tests;
public sealed partial class DpopReplayCacheTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task MessagingReplayCache_RejectsDuplicatesUntilExpiryAsync()
{
var timeProvider = CreateTimeProvider();
var factory = new FakeIdempotencyStoreFactory(timeProvider);
var cache = new MessagingDpopReplayCache(factory, timeProvider);
var expiresAt = timeProvider.GetUtcNow().AddMinutes(1);
Assert.True(await cache.TryStoreAsync("jti-1", expiresAt));
Assert.False(await cache.TryStoreAsync("jti-1", expiresAt));
timeProvider.Advance(TimeSpan.FromMinutes(2));
Assert.True(await cache.TryStoreAsync("jti-1", timeProvider.GetUtcNow().AddMinutes(1)));
}
}

View File

@@ -1,110 +1,11 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Security.Dpop;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Auth.Security.Tests;
public class DpopReplayCacheTests
public sealed partial class DpopReplayCacheTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InMemoryReplayCache_RejectsDuplicatesUntilExpiry()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var cache = new InMemoryDpopReplayCache(timeProvider);
private static readonly DateTimeOffset FixedUtcNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var expiresAt = timeProvider.GetUtcNow().AddMinutes(1);
Assert.True(await cache.TryStoreAsync("jti-1", expiresAt));
Assert.False(await cache.TryStoreAsync("jti-1", expiresAt));
timeProvider.Advance(TimeSpan.FromMinutes(2));
Assert.True(await cache.TryStoreAsync("jti-1", timeProvider.GetUtcNow().AddMinutes(1)));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task MessagingReplayCache_RejectsDuplicatesUntilExpiry()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
var factory = new FakeIdempotencyStoreFactory(timeProvider);
var cache = new MessagingDpopReplayCache(factory, timeProvider);
var expiresAt = timeProvider.GetUtcNow().AddMinutes(1);
Assert.True(await cache.TryStoreAsync("jti-1", expiresAt));
Assert.False(await cache.TryStoreAsync("jti-1", expiresAt));
timeProvider.Advance(TimeSpan.FromMinutes(2));
Assert.True(await cache.TryStoreAsync("jti-1", timeProvider.GetUtcNow().AddMinutes(1)));
}
private sealed class FakeIdempotencyStoreFactory : IIdempotencyStoreFactory
{
private readonly FakeIdempotencyStore store;
public FakeIdempotencyStoreFactory(TimeProvider timeProvider)
{
store = new FakeIdempotencyStore(timeProvider);
}
public string ProviderName => "fake";
public IIdempotencyStore Create(string name) => store;
}
private sealed class FakeIdempotencyStore : IIdempotencyStore
{
private readonly Dictionary<string, Entry> entries = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
public FakeIdempotencyStore(TimeProvider timeProvider)
{
this.timeProvider = timeProvider;
}
public string ProviderName => "fake";
public ValueTask<IdempotencyResult> TryClaimAsync(string key, string value, TimeSpan window, CancellationToken cancellationToken = default)
{
var now = timeProvider.GetUtcNow();
if (entries.TryGetValue(key, out var entry) && entry.ExpiresAt > now)
{
return ValueTask.FromResult(IdempotencyResult.Duplicate(entry.Value));
}
entries[key] = new Entry(value, now.Add(window));
return ValueTask.FromResult(IdempotencyResult.Claimed());
}
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(entries.TryGetValue(key, out var entry) && entry.ExpiresAt > timeProvider.GetUtcNow());
public ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(entries.TryGetValue(key, out var entry) && entry.ExpiresAt > timeProvider.GetUtcNow() ? entry.Value : null);
public ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(entries.Remove(key));
public ValueTask<bool> ExtendAsync(string key, TimeSpan extension, CancellationToken cancellationToken = default)
{
if (!entries.TryGetValue(key, out var entry))
{
return ValueTask.FromResult(false);
}
entries[key] = entry with { ExpiresAt = entry.ExpiresAt.Add(extension) };
return ValueTask.FromResult(true);
}
private readonly record struct Entry(string Value, DateTimeOffset ExpiresAt);
}
private static FakeTimeProvider CreateTimeProvider() => new(FixedUtcNow);
}

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Auth.Security.Tests;
internal sealed class FakeIdempotencyStore : IIdempotencyStore
{
private readonly Dictionary<string, Entry> _entries = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
public FakeIdempotencyStore(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public string ProviderName => "fake";
public ValueTask<IdempotencyResult> TryClaimAsync(
string key,
string value,
TimeSpan window,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
if (_entries.TryGetValue(key, out var entry) && entry.ExpiresAt > now)
{
return ValueTask.FromResult(IdempotencyResult.Duplicate(entry.Value));
}
_entries[key] = new Entry(value, now.Add(window));
return ValueTask.FromResult(IdempotencyResult.Claimed());
}
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(_entries.TryGetValue(key, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow());
public ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(_entries.TryGetValue(key, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow() ? entry.Value : null);
public ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(_entries.Remove(key));
public ValueTask<bool> ExtendAsync(string key, TimeSpan extension, CancellationToken cancellationToken = default)
{
if (!_entries.TryGetValue(key, out var entry))
{
return ValueTask.FromResult(false);
}
_entries[key] = entry with { ExpiresAt = entry.ExpiresAt.Add(extension) };
return ValueTask.FromResult(true);
}
private readonly record struct Entry(string Value, DateTimeOffset ExpiresAt);
}

View File

@@ -0,0 +1,19 @@
using System;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Auth.Security.Tests;
internal sealed class FakeIdempotencyStoreFactory : IIdempotencyStoreFactory
{
private readonly FakeIdempotencyStore _store;
public FakeIdempotencyStoreFactory(TimeProvider timeProvider)
{
_store = new FakeIdempotencyStore(timeProvider);
}
public string ProviderName => "fake";
public IIdempotencyStore Create(string name) => _store;
}

View File

@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0785-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0785-A | DONE | Waived (test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-08 | DONE | Split tests <= 100 lines; deterministic time/IDs; async naming; helper types separated; ConfigureAwait(false) skipped per xUnit1030; dotnet test passed 2026-02-02 (12 tests). |