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
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// <copyright file="IdempotencyKeyEntityTests.cs" company="https://drunkcoding.net">
// Copyright (c) 2025 Steven Hoang. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
// </copyright>

namespace AspCore.Idempotency.MsSqlStore.Tests.Unit;

/// <summary>
/// Unit tests for <see cref="IdempotencyKeyEntity.SanitizeKey" />.
/// </summary>
public sealed class IdempotencyKeyEntityTests
{
#region Methods

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void SanitizeKey_NullOrWhiteSpaceKey_ThrowsArgumentException(string? key)
{
// Act
var act = () => IdempotencyKeyEntity.SanitizeKey(key!);

// Assert
Should.Throw<ArgumentException>(act);
}

[Fact]
public void SanitizeKey_ExactCollisionFromFinding_ProducesDifferentResults()
{
// Arrange
const string keyA = "POST:/ab:cd";
const string keyB = "POST:/a:bcd";

// Act
var sanitizedA = IdempotencyKeyEntity.SanitizeKey(keyA);
var sanitizedB = IdempotencyKeyEntity.SanitizeKey(keyB);

// Assert
sanitizedA.ShouldNotBe(sanitizedB);
}

[Fact]
public void SanitizeKey_SameInputTwice_IsDeterministicBoundedAndNonEmpty()
{
// Arrange
const string key = "GET:/api/orders:idem-key-123";

// Act
var first = IdempotencyKeyEntity.SanitizeKey(key);
var second = IdempotencyKeyEntity.SanitizeKey(key);

// Assert
first.ShouldBe(second);
first.ShouldNotBeNullOrEmpty();
first.Length.ShouldBeLessThanOrEqualTo(128);
}

[Fact]
public void SanitizeKey_StructurallySimilarKeys_AreAllPairwiseDistinct()
{
// Arrange
string[] keys =
[
"GET:/a/b:x",
"GET:/a:b/x",
"GET:/ab:x"
];

// Act
var sanitized = keys.Select(IdempotencyKeyEntity.SanitizeKey).ToArray();

// Assert
sanitized.Distinct().Count().ShouldBe(sanitized.Length);
}

[Fact]
public void SanitizeKey_VeryLongCompositeKey_HashesSuccessfully()
{
// Arrange
var key = $"{new string('M', 20)}:{new string('E', 250)}:{new string('K', 150)}";

// Act
var sanitized = IdempotencyKeyEntity.SanitizeKey(key);

// Assert
sanitized.ShouldNotBeNullOrEmpty();
sanitized.Length.ShouldBeLessThanOrEqualTo(128);
}

#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// <copyright file="IdempotencyKeyEntityTests.cs" company="https://drunkcoding.net">
// Copyright (c) 2025 Steven Hoang. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
// </copyright>

namespace AspCore.Idempotency.NpgsqlStore.Tests.Unit;

/// <summary>
/// Unit tests for <see cref="IdempotencyKeyEntity.SanitizeKey" />.
/// </summary>
public sealed class IdempotencyKeyEntityTests
{
#region Methods

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void SanitizeKey_NullOrWhiteSpaceKey_ThrowsArgumentException(string? key)
{
// Act
var act = () => IdempotencyKeyEntity.SanitizeKey(key!);

// Assert
Should.Throw<ArgumentException>(act);
}

[Fact]
public void SanitizeKey_ExactCollisionFromFinding_ProducesDifferentResults()
{
// Arrange
const string keyA = "POST:/ab:cd";
const string keyB = "POST:/a:bcd";

// Act
var sanitizedA = IdempotencyKeyEntity.SanitizeKey(keyA);
var sanitizedB = IdempotencyKeyEntity.SanitizeKey(keyB);

// Assert
sanitizedA.ShouldNotBe(sanitizedB);
}

[Fact]
public void SanitizeKey_SameInputTwice_IsDeterministicBoundedAndNonEmpty()
{
// Arrange
const string key = "GET:/api/orders:idem-key-123";

// Act
var first = IdempotencyKeyEntity.SanitizeKey(key);
var second = IdempotencyKeyEntity.SanitizeKey(key);

// Assert
first.ShouldBe(second);
first.ShouldNotBeNullOrEmpty();
first.Length.ShouldBeLessThanOrEqualTo(128);
}

[Fact]
public void SanitizeKey_StructurallySimilarKeys_AreAllPairwiseDistinct()
{
// Arrange
string[] keys =
[
"GET:/a/b:x",
"GET:/a:b/x",
"GET:/ab:x"
];

// Act
var sanitized = keys.Select(IdempotencyKeyEntity.SanitizeKey).ToArray();

// Assert
sanitized.Distinct().Count().ShouldBe(sanitized.Length);
}

[Fact]
public void SanitizeKey_VeryLongCompositeKey_HashesSuccessfully()
{
// Arrange
var key = $"{new string('M', 20)}:{new string('E', 250)}:{new string('K', 150)}";

// Act
var sanitized = IdempotencyKeyEntity.SanitizeKey(key);

// Assert
sanitized.ShouldNotBeNullOrEmpty();
sanitized.Length.ShouldBeLessThanOrEqualTo(128);
}

#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using DKNet.AspCore.Idempotency;
using DKNet.AspCore.Idempotency.Store;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace AspCore.Idempotency.Tests.Unit;

/// <summary>
/// Tests for <see cref="IdempotencyDistributedCacheStore" /> edge cases not covered
/// by the main repository tests, specifically for collision detection and deterministic behavior.
/// </summary>
public class IdempotencyDistributedCacheStoreTests
{
#region Fields

private readonly IDistributedCache _cache;
private readonly ILogger<IdempotencyEndpointFilter> _logger;

#endregion

#region Constructors

public IdempotencyDistributedCacheStoreTests()
{
_cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
_logger = LoggerFactory.Create(b => b.AddConsole()).CreateLogger<IdempotencyEndpointFilter>();
}

#endregion

#region Methods

private IdempotencyDistributedCacheStore CreateStore(IdempotencyOptions? options = null) =>
new(_cache, Options.Create(options ?? new IdempotencyOptions()), _logger);

private static IdempotentKeyInfo MakeKey(string key) =>
new() { IdempotentKey = key, Endpoint = "/api/test", Method = "POST" };

[Fact]
public async Task SanitizeKey_ExactCollisionFromFinding_ProducesDifferentResults()
{
// Arrange
const string keyA = "POST:/ab:cd";
const string keyB = "POST:/a:bcd";
var store = CreateStore();

// Act
var responseA = new CachedResponse
{
StatusCode = 200,
Body = "{\"key\": \"A\"}",
ContentType = "application/json",
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};

var responseB = new CachedResponse
{
StatusCode = 200,
Body = "{\"key\": \"B\"}",
ContentType = "application/json",
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};

await store.MarkKeyAsProcessedAsync(MakeKey(keyA), responseA);
await store.MarkKeyAsProcessedAsync(MakeKey(keyB), responseB);

var resultA = await store.IsKeyProcessedAsync(MakeKey(keyA));
var resultB = await store.IsKeyProcessedAsync(MakeKey(keyB));

// Assert
resultA.processed.ShouldBeTrue();
resultB.processed.ShouldBeTrue();
resultA.response!.Body.ShouldBe("{\"key\": \"A\"}");
resultB.response!.Body.ShouldBe("{\"key\": \"B\"}");
}

[Fact]
public async Task SanitizeKey_StructurallySimilarKeys_AreAllPairwiseDistinct()
{
// Arrange
string[] keys = ["GET:/a/b:x", "GET:/a:b/x", "GET:/ab:x"];
var store = CreateStore();
var responses = new List<CachedResponse>();

// Act
for (var i = 0; i < keys.Length; i++)
{
var response = new CachedResponse
{
StatusCode = 200,
Body = $"{{\"key\": {i}}}",
ContentType = "application/json",
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};

responses.Add(response);
await store.MarkKeyAsProcessedAsync(MakeKey(keys[i]), response);
}

// Assert
for (var i = 0; i < keys.Length; i++)
{
var result = await store.IsKeyProcessedAsync(MakeKey(keys[i]));
result.processed.ShouldBeTrue();
result.response!.Body.ShouldBe($"{{\"key\": {i}}}");
}
}

[Fact]
public async Task SanitizeKey_SameInputTwice_IsDeterministic()
{
// Arrange
const string key = "GET:/api/orders:idem-key-123";
var store = CreateStore();
var response = new CachedResponse
{
StatusCode = 200,
Body = "{\"id\": 123}",
ContentType = "application/json",
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
};

// Act
await store.MarkKeyAsProcessedAsync(MakeKey(key), response);
var result1 = await store.IsKeyProcessedAsync(MakeKey(key));
var result2 = await store.IsKeyProcessedAsync(MakeKey(key));

// Assert
result1.processed.ShouldBeTrue();
result2.processed.ShouldBeTrue();
result1.response!.Body.ShouldBe("{\"id\": 123}");
result2.response!.Body.ShouldBe("{\"id\": 123}");
}

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,11 @@ public async Task IsKeyProcessedAsync_CacheKeyNormalization_CaseSensitive()
await repository.MarkKeyAsProcessedAsync(CreateKeyInfo(idempotencyKey1), cachedResponse);
var result = await repository.IsKeyProcessedAsync(CreateKeyInfo(idempotencyKey2));

// Assert
result.processed.ShouldBeTrue();
result.response.ShouldNotBeNull();
result.response!.Body.ShouldBe(cachedResponse.Body);
result.response.StatusCode.ShouldBe(cachedResponse.StatusCode);
// Assert — SanitizeKey now hashes the raw key, so keys differing only by case
// must be treated as distinct entries (DRK-146); marking one processed must not
// make the other appear processed.
result.processed.ShouldBeFalse();
result.response.ShouldBeNull();
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.RegularExpressions;
using System.Security.Cryptography;
using System.Text;

namespace DKNet.AspCore.Idempotency.MsSqlStore.Data;

Expand Down Expand Up @@ -101,25 +102,18 @@ private IdempotencyKeyEntity()
#region Methods

/// <summary>
/// Sanitizes an idempotency key for use as a database key.
/// Removes invalid characters to prevent injection attacks.
/// Sanitizes an idempotency key for use as a database key by hashing it.
/// Hashing (rather than stripping characters) guarantees structurally distinct
/// composite keys never collapse onto the same database key.
/// </summary>
/// <param name="key">The idempotency key to sanitize.</param>
/// <returns>A sanitized key with only alphanumeric characters and hyphens.</returns>
/// <returns>A deterministic, fixed-length (64-character) uppercase hex SHA-256 hash of the key.</returns>
internal static string SanitizeKey(string key)
{
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("Idempotency key cannot be null or empty.", nameof(key));

// Remove non-alphanumeric characters except hyphens
var sanitized = Regex.Replace(key, @"[^a-zA-Z0-9\-]", string.Empty);

if (string.IsNullOrEmpty(sanitized))
throw new ArgumentException("Idempotency key contains no valid characters.", nameof(key));

if (sanitized.Length > 128) sanitized = sanitized[..128];

return sanitized.ToUpperInvariant();
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key)));
}

#endregion
Expand Down
Loading
Loading