Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
709fcc2
test(efcore-extensions): add Tier-2 architecture rule banning System.…
baoduy Aug 4, 2026
b85e224
fix(idempotency): close check-then-act race with atomic key reservation
baoduy Aug 4, 2026
20be9fc
fix(encryption): make _disposed volatile and correct ignoreCase docs
baoduy Aug 5, 2026
efbafdd
fix(blobstorage): wire S3 test fixture to Minio, remove hardcoded R2 …
baoduy Aug 5, 2026
42c68f5
test(encryption): add dispose and ignoreCase no-op tests for HmacHash…
baoduy Aug 5, 2026
edac864
Merge pull request #338 from baoduy/feature/drk-114-hashing-hardening
baoduy Aug 5, 2026
080b396
test(blob-storage): cover S3BlobService.Dispose and multi-key/empty f…
baoduy Aug 5, 2026
d529b69
test(idempotency): add coverlet.collector to enable coverage collection
baoduy Aug 5, 2026
4e9c6de
Merge pull request #339 from baoduy/feature/drk-135-minio-s3-fixture
baoduy Aug 5, 2026
bbc332b
fix: resolve merge conflicts with dev (SanitizeKey hash algorithm)
baoduy Aug 5, 2026
ef960f8
test(idempotency): restore live concurrency check via file-based SQLite
baoduy Aug 5, 2026
4802a1b
fix(idempotency): close check-then-act race in Postgres store
baoduy Aug 5, 2026
cf8e293
test(idempotency): prove residual race when reservation collides with…
baoduy Aug 5, 2026
f7438f3
fix(idempotency): atomically reclaim expired reservation rows to clos…
baoduy Aug 5, 2026
a3448e6
[DRK-175] Atomic idempotency key reservation (PostgreSQL store) (#341)
baoduy Aug 5, 2026
7face42
test(idempotency): cover reservation lifecycle and collision paths fo…
baoduy Aug 5, 2026
57e3194
Merge remote-tracking branch 'origin/dev' into HEAD
baoduy Aug 5, 2026
d318a58
fix: resolve duplicate InFlightReservationTimeout after merging dev
baoduy Aug 5, 2026
31fcfbc
Merge pull request #340 from baoduy/feature/drk-75-idempotency-atomic…
baoduy Aug 5, 2026
d17936b
Merge pull request #323 from baoduy/arch/dknet-a73-console-enforcement
baoduy Aug 5, 2026
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
Expand Up @@ -20,6 +20,9 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite"/>
<!-- Force fixed SQLitePCLRaw (EF Sqlite pulls vulnerable 2.1.11, GHSA-2m69-gcr7-jv3q) -->
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Testcontainers.MsSql"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ public async Task CreateItem_ConcurrentRequestsWithSameKey_OnlyOneProcessed()
var idempotencyKey = Guid.NewGuid().ToString();
var request = new CreateItemRequest { Name = "Concurrent Item" };

// Act - Send 5 concurrent requests with the same idempotency key
// multiple requests may process simultaneously when they all arrive before any is cached.
// This is a known limitation of stateless filters without distributed locking.
// Act - Send 5 concurrent requests with the same idempotency key.
// The atomic reserve-then-check in IsKeyProcessedAsync guarantees exactly one request reaches the
// handler; the other four are turned away as conflicts before they ever call next(context).
var tasks = Enumerable.Range(0, 5).Select(_ =>
{
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/items")
Expand All @@ -70,19 +70,12 @@ public async Task CreateItem_ConcurrentRequestsWithSameKey_OnlyOneProcessed()

var responses = await Task.WhenAll(tasks);

// Assert - With concurrent requests, some or all may succeed (201 or 409 Conflict)
// The first request wins, subsequent ones get 409 Conflict (if ConflictHandling = ConflictResponse)
// or the cached response (if ConflictHandling = CachedResult)
// Assert - Exactly one request succeeds, the remaining four are conflicts.
var successCount = responses.Count(r => r.StatusCode == HttpStatusCode.Created);
var conflictCount = responses.Count(r => r.StatusCode == HttpStatusCode.Conflict);

// At least one should succeed (201)
successCount.ShouldBeGreaterThanOrEqualTo(1);

// The rest should be conflicts or cached responses
var otherCount = responses.Count(r => r.StatusCode != HttpStatusCode.Created &&
r.StatusCode != HttpStatusCode.Conflict);
(successCount + conflictCount + otherCount).ShouldBe(5);
successCount.ShouldBe(1);
conflictCount.ShouldBe(4);

// Verify only ONE entry in database (unique constraint ensures this)
await using var dbContext = fixture.GetDbContext();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// <copyright file="IdempotencySqlServerStoreConcurrencyTests.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>

using DKNet.AspCore.Idempotency;
using DKNet.AspCore.Idempotency.MsSqlStore.Store;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;

namespace AspCore.Idempotency.MsSqlStore.Tests.Unit;

/// <summary>
/// Proves the atomic reservation in <see cref="IdempotencySqlServerStore.IsKeyProcessedAsync" /> under real
/// concurrent connections, without Docker/SQL Server. Points the store at a file-based SQLite database — not
/// the EF Core InMemory provider, which does not enforce <c>UX_CompositeKey</c> across concurrent contexts the
/// same way a real relational database does — using the exact same <see cref="IdempotencyKeyConfiguration" />
/// the SQL Server store ships with. Mirrors the retired HTTP-level
/// <c>CreateItem_ConcurrentRequestsWithSameKey_OnlyOneProcessed</c> test (see DRK-174), but directly at the
/// store layer so it doesn't depend on SQL Server at all.
/// </summary>
public sealed class IdempotencySqlServerStoreConcurrencyTests : IAsyncLifetime
{
#region Fields

private readonly string _dbFilePath =
Path.Combine(Path.GetTempPath(), $"idempotency-concurrency-{Guid.NewGuid():N}.db");

private ServiceProvider _serviceProvider = null!;
private IdempotencySqlServerStore _store = null!;

#endregion

#region Methods

public async Task DisposeAsync()
{
await _store.DisposeAsync();
await _serviceProvider.DisposeAsync();
File.Delete(_dbFilePath);
}

public async Task InitializeAsync()
{
var services = new ServiceCollection();
services.AddDbContextFactory<IdempotencyDbContext>(o => o.UseSqlite(
$"Data Source={_dbFilePath}",
// The real "Initial" migration is authored for SQL Server, so point migrations at this test
// assembly - which has none - instead. That makes the store's own migration-check path
// (EnsureDatabaseCreatedAsync) see nothing pending and skip straight past it; EnsureCreatedAsync
// below builds the schema from the live model instead.
sqlite => sqlite.MigrationsAssembly(
typeof(IdempotencySqlServerStoreConcurrencyTests).Assembly.GetName().Name))
// IdempotencyKeyConfiguration hardcodes the Body column's raw type as "nvarchar(max)" (SQL Server's
// way of saying "unbounded"); that literal string isn't valid SQLite syntax. Strip just that one
// provider-specific override so the rest of the same configuration - crucially UX_CompositeKey -
// builds unmodified.
.ReplaceService<IModelCustomizer, SqliteCompatibleModelCustomizer>());
_serviceProvider = services.BuildServiceProvider();

var factory = _serviceProvider.GetRequiredService<IDbContextFactory<IdempotencyDbContext>>();
await using var setupContext = await factory.CreateDbContextAsync();
await setupContext.Database.EnsureCreatedAsync();

_store = new IdempotencySqlServerStore(
_serviceProvider,
Options.Create(new IdempotencyOptions()),
NullLogger<IdempotencySqlServerStore>.Instance);
}

[Fact]
public async Task IsKeyProcessedAsync_ConcurrentRequestsWithSameKey_OnlyOneReservationWins()
{
// Arrange
var keyInfo = new IdempotentKeyInfo
{
Endpoint = "/api/items",
Method = "POST",
IdempotentKey = Guid.NewGuid().ToString()
};

// Act - fire 5 concurrent reservation attempts for the identical composite key against the same
// SQLite file, exactly as 5 concurrent HTTP requests would hit the same SQL Server row.
var tasks = Enumerable.Range(0, 5)
.Select(_ => _store.IsKeyProcessedAsync(keyInfo).AsTask())
.ToArray();
var results = await Task.WhenAll(tasks);

// Assert - exactly one caller wins the reservation (false, null); the unique index forces every
// other concurrent caller onto the collision path, which reports back as already-processed/in-flight.
results.Count(r => !r.processed).ShouldBe(1);
results.Count(r => r.processed).ShouldBe(4);
}

#endregion
}

/// <summary>
/// Applies <see cref="IdempotencyKeyConfiguration" /> as-is, then drops the one column-type override in it
/// that is SQL-Server-specific raw SQL, so <c>EnsureCreatedAsync</c> can generate valid SQLite DDL.
/// </summary>
internal sealed class SqliteCompatibleModelCustomizer(ModelCustomizerDependencies dependencies)
: ModelCustomizer(dependencies)
{
public override void Customize(ModelBuilder modelBuilder, DbContext context)
{
base.Customize(modelBuilder, context);

var key = modelBuilder.Entity<IdempotencyKeyEntity>();
key.Property(e => e.Body).HasColumnType(null);

// The Sqlite provider cannot translate "> "/"<" comparisons on a DateTimeOffset column (only equality) -
// IsKeyProcessedAsync's expiry check relies on exactly that. Store ExpiresAt as UTC ticks instead so the
// comparison becomes an ordinary numeric one; the property's C# type/behaviour is unaffected.
key.Property(e => e.ExpiresAt).HasConversion(
v => v.HasValue ? v.Value.UtcTicks : (long?)null,
v => v.HasValue ? new DateTimeOffset(v.Value, TimeSpan.Zero) : null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// <copyright file="IdempotencySqlServerStoreLifecycleTests.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>

using DKNet.AspCore.Idempotency;
using DKNet.AspCore.Idempotency.MsSqlStore.Store;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;

namespace AspCore.Idempotency.MsSqlStore.Tests.Unit;

/// <summary>
/// Covers the reserve/complete lifecycle and the collision-handler branches of
/// <see cref="IdempotencySqlServerStore" /> that <see cref="IdempotencySqlServerStoreConcurrencyTests" />
/// doesn't exercise. Uses the same file-based SQLite setup so it runs without Docker/SQL Server.
/// </summary>
public sealed class IdempotencySqlServerStoreLifecycleTests : IAsyncLifetime
{
#region Fields

private readonly string _dbFilePath =
Path.Combine(Path.GetTempPath(), $"idempotency-lifecycle-{Guid.NewGuid():N}.db");

private ServiceProvider _serviceProvider = null!;
private IdempotencySqlServerStore _store = null!;

#endregion

#region Methods

public async Task DisposeAsync()
{
await _store.DisposeAsync();
await _serviceProvider.DisposeAsync();
File.Delete(_dbFilePath);
}

public async Task InitializeAsync()
{
var services = new ServiceCollection();
services.AddDbContextFactory<IdempotencyDbContext>(o => o.UseSqlite(
$"Data Source={_dbFilePath}",
sqlite => sqlite.MigrationsAssembly(
typeof(IdempotencySqlServerStoreConcurrencyTests).Assembly.GetName().Name))
.ReplaceService<IModelCustomizer, SqliteCompatibleModelCustomizer>());
_serviceProvider = services.BuildServiceProvider();

var factory = _serviceProvider.GetRequiredService<IDbContextFactory<IdempotencyDbContext>>();
await using var setupContext = await factory.CreateDbContextAsync();
await setupContext.Database.EnsureCreatedAsync();

_store = CreateStore(new IdempotencyOptions());
}

private IdempotencySqlServerStore CreateStore(IdempotencyOptions options) =>
new(_serviceProvider, Options.Create(options), NullLogger<IdempotencySqlServerStore>.Instance);

private static CachedResponse CreateResponse(int statusCode, string? body, DateTimeOffset? expiresAt) =>
new()
{
StatusCode = statusCode,
Body = body,
ContentType = "application/json",
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = expiresAt
};

[Fact]
public async Task IsKeyProcessedAsync_FullLifecycle_ReserveThenCompleteReturnsCachedResponse()
{
// Arrange
var keyInfo = new IdempotentKeyInfo
{
Endpoint = "/api/orders",
Method = "POST",
IdempotentKey = Guid.NewGuid().ToString()
};

// Act - first call finds nothing and reserves the key
var reserved = await _store.IsKeyProcessedAsync(keyInfo);

var response = CreateResponse(201, "{\"id\":1}", DateTimeOffset.UtcNow.AddHours(1));
await _store.MarkKeyAsProcessedAsync(keyInfo, response);

var completed = await _store.IsKeyProcessedAsync(keyInfo);

// Assert - reservation reports not-yet-processed, then the completed row replays the cached response
reserved.processed.ShouldBeFalse();
reserved.response.ShouldBeNull();

completed.processed.ShouldBeTrue();
completed.response.ShouldNotBeNull();
completed.response!.StatusCode.ShouldBe(201);
completed.response.Body.ShouldBe("{\"id\":1}");
}

[Fact]
public async Task IsKeyProcessedAsync_WhileReservationInFlight_ReturnsTrueWithNullResponse()
{
// Arrange
var keyInfo = new IdempotentKeyInfo
{
Endpoint = "/api/orders",
Method = "POST",
IdempotentKey = Guid.NewGuid().ToString()
};

// Act - reserve, then re-check before anyone completes it
await _store.IsKeyProcessedAsync(keyInfo);
var inFlight = await _store.IsKeyProcessedAsync(keyInfo);

// Assert - the caller is told the key is already being processed (409 path), with no cached body yet
inFlight.processed.ShouldBeTrue();
inFlight.response.ShouldBeNull();
}

[Fact]
public async Task IsKeyProcessedAsync_CollisionOnInsert_ReQueryReturnsCompletedResponse()
{
// Arrange - seed a completed row directly. ExpiresAt is left null so the initial
// "ExpiresAt > now" lookup in IsKeyProcessedAsync misses it (null compares false), forcing the
// store down the reserve-insert path, which then collides with this row's unique CompositeKey.
var keyInfo = new IdempotentKeyInfo
{
Endpoint = "/api/orders",
Method = "POST",
IdempotentKey = Guid.NewGuid().ToString()
};
var response = CreateResponse(200, "{\"id\":42}", null);

var factory = _serviceProvider.GetRequiredService<IDbContextFactory<IdempotencyDbContext>>();
await using (var seedContext = await factory.CreateDbContextAsync())
{
seedContext.IdempotencyKeys.Add(new IdempotencyKeyEntity(keyInfo, response));
await seedContext.SaveChangesAsync();
}

// Act
var result = await _store.IsKeyProcessedAsync(keyInfo);

// Assert - the collision handler re-queries and returns the already-completed response
result.processed.ShouldBeTrue();
result.response.ShouldNotBeNull();
result.response!.StatusCode.ShouldBe(200);
result.response.Body.ShouldBe("{\"id\":42}");
}

[Fact]
public async Task IsKeyProcessedAsync_ExpiredReservationCollision_ReturnsFalseForFreshReservation()
{
// Arrange - a very short InFlightReservationTimeout so the reservation row is expired by the
// time the second call collides with it, without actually waiting out the 30s default.
var shortTimeoutStore = CreateStore(new IdempotencyOptions
{
InFlightReservationTimeout = TimeSpan.FromMilliseconds(1)
});
var keyInfo = new IdempotentKeyInfo
{
Endpoint = "/api/orders",
Method = "POST",
IdempotentKey = Guid.NewGuid().ToString()
};

// Act
await shortTimeoutStore.IsKeyProcessedAsync(keyInfo); // reserves, expiring almost immediately
await Task.Delay(50);
var result = await shortTimeoutStore.IsKeyProcessedAsync(keyInfo); // collides, re-query finds it expired

// Assert - Rule R1: an abandoned/expired reservation must not permanently block retries
result.processed.ShouldBeFalse();
result.response.ShouldBeNull();

await shortTimeoutStore.DisposeAsync();
}

[Fact]
public async Task MarkKeyAsProcessedAsync_WithoutPriorReservation_CreatesEntityDefensively()
{
// Arrange - call MarkKeyAsProcessedAsync directly, skipping IsKeyProcessedAsync's reservation.
// Exercises the defensive "entity is null" branch that should not happen in the normal flow.
var keyInfo = new IdempotentKeyInfo
{
Endpoint = "/api/orders",
Method = "POST",
IdempotentKey = Guid.NewGuid().ToString()
};
var response = CreateResponse(204, null, DateTimeOffset.UtcNow.AddHours(1));

// Act
await _store.MarkKeyAsProcessedAsync(keyInfo, response);
var result = await _store.IsKeyProcessedAsync(keyInfo);

// Assert
result.processed.ShouldBeTrue();
result.response.ShouldNotBeNull();
result.response!.StatusCode.ShouldBe(204);
}

#endregion
}
Loading
Loading