stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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). |
|
||||
|
||||
Reference in New Issue
Block a user