IdempotencyStore keyed a replayed submission on the raw Idempotency-Key
header alone. Two different callers who send the same header value
shared one cache slot: the second caller received the first caller's
cached reference instead of running its own submission.
Program.cs now composes the key as "{SubjectId}:{idemKey}" in the
Submit helper, so the cache is scoped per caller. Add a test that
proves a caller cannot replay another caller's idempotency key and
receive their cached result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
95 lines
3.7 KiB
C#
95 lines
3.7 KiB
C#
using System.Net.Http.Json;
|
|
using BigRegister.Api.Contracts;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
|
|
namespace BigRegister.Tests;
|
|
|
|
public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
|
{
|
|
private readonly HttpClient _client = factory.CreateClient();
|
|
|
|
private static HttpRequestMessage ChangeRequestWithKey(string key)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
|
|
{
|
|
Content = JsonContent.Create(new { telefoon = "0612345678" }),
|
|
};
|
|
req.Headers.Add("Idempotency-Key", key);
|
|
return req;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Replaying_the_same_idempotency_key_returns_the_same_reference_not_a_new_one()
|
|
{
|
|
var key = Guid.NewGuid().ToString();
|
|
|
|
var first = await _client.SendAsync(ChangeRequestWithKey(key));
|
|
first.EnsureSuccessStatusCode();
|
|
var firstBody = await first.Content.ReadFromJsonAsync<ReferentieResponse>();
|
|
|
|
var replay = await _client.SendAsync(ChangeRequestWithKey(key));
|
|
replay.EnsureSuccessStatusCode();
|
|
var replayBody = await replay.Content.ReadFromJsonAsync<ReferentieResponse>();
|
|
|
|
Assert.Equal(firstBody!.Referentie, replayBody!.Referentie);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Different_idempotency_keys_are_independent_submissions()
|
|
{
|
|
var first = await _client.SendAsync(ChangeRequestWithKey(Guid.NewGuid().ToString()));
|
|
var second = await _client.SendAsync(ChangeRequestWithKey(Guid.NewGuid().ToString()));
|
|
var firstBody = await first.Content.ReadFromJsonAsync<ReferentieResponse>();
|
|
var secondBody = await second.Content.ReadFromJsonAsync<ReferentieResponse>();
|
|
|
|
Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie);
|
|
}
|
|
|
|
// RB-18/BIO-018: IdempotencyStore used to key on the raw client-supplied header alone, so
|
|
// caller B replaying caller A's Idempotency-Key got caller A's cached reference back —
|
|
// a cross-caller leak of a value caller B never submitted. The store now keys on
|
|
// "{SubjectId}:{idemKey}", so the same header value from two different callers is two
|
|
// independent submissions.
|
|
[Fact]
|
|
public async Task A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result()
|
|
{
|
|
var sharedKey = Guid.NewGuid().ToString();
|
|
|
|
var callerARequest = ChangeRequestWithKey(sharedKey);
|
|
callerARequest.Headers.Add("X-Subject", "111222333");
|
|
var callerA = await _client.SendAsync(callerARequest);
|
|
callerA.EnsureSuccessStatusCode();
|
|
var callerABody = await callerA.Content.ReadFromJsonAsync<ReferentieResponse>();
|
|
|
|
var callerBRequest = ChangeRequestWithKey(sharedKey);
|
|
callerBRequest.Headers.Add("X-Subject", "999888777");
|
|
var callerB = await _client.SendAsync(callerBRequest);
|
|
callerB.EnsureSuccessStatusCode();
|
|
var callerBBody = await callerB.Content.ReadFromJsonAsync<ReferentieResponse>();
|
|
|
|
Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry()
|
|
{
|
|
var key = Guid.NewGuid().ToString();
|
|
var badRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
|
|
{
|
|
Content = JsonContent.Create(new { telefoon = "nope" }),
|
|
};
|
|
badRequest.Headers.Add("Idempotency-Key", key);
|
|
|
|
var first = await _client.SendAsync(badRequest);
|
|
Assert.Equal(System.Net.HttpStatusCode.UnprocessableEntity, first.StatusCode);
|
|
|
|
var replayRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
|
|
{
|
|
Content = JsonContent.Create(new { telefoon = "nope" }),
|
|
};
|
|
replayRequest.Headers.Add("Idempotency-Key", key);
|
|
var replay = await _client.SendAsync(replayRequest);
|
|
Assert.Equal(System.Net.HttpStatusCode.UnprocessableEntity, replay.StatusCode);
|
|
}
|
|
}
|