Fix and test idempotency
MODIFY
WalletWasabi.Backend/Filters/IdempotentActionFilterAttributeImpl.cs
WalletWasabi.Backend/Filters/IdempotentActionFilterAttributeImpl.cs
using System;
using System.Security.Cryptography;
using System.Text;
+using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
@@ -18,32 +19,37 @@ namespace WalletWasabi.Backend.Filters
_cache = cache;
}
- public override void OnActionExecuting(ActionExecutingContext context)
+
+ public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var request = context.HttpContext.Request;
- var (cacheKey, body) = GetCacheEntryKey(request);
+ var (cacheKey, body) = await GetCacheEntryKeyAsync(request);
- if (_cache.TryGetValue<OkObjectResult>(cacheKey, out var cachedResponse))
+ if (_cache.TryGetValue<ObjectResult>(cacheKey, out var cachedResponse))
{
context.Result = cachedResponse;
+ context.HttpContext.Items.Remove("cached-key");
return;
}
context.HttpContext.Items["cached-key"] = cacheKey;
+
+ await next();
}
public override void OnResultExecuted(ResultExecutedContext context)
{
- var cacheKey = context.HttpContext.Items["cached-key"];
-
+ if (context.HttpContext.Items.TryGetValue("cached-key", out var cacheKey) && cacheKey is not null)
+ {
_cache.Set(cacheKey, context.Result, DateTimeOffset.UtcNow.AddMinutes(5));
}
+ }
- private (string, string) GetCacheEntryKey(HttpRequest request)
+ private async Task<(string, string)> GetCacheEntryKeyAsync(HttpRequest request)
{
using var reader = new System.IO.StreamReader(request.Body);
- var body = reader.ReadToEnd();
+ var body = await reader.ReadToEndAsync();
var rawKey = string.Join(":", request.Path, body);
using var sha256Hash = SHA256.Create();

MODIFY
WalletWasabi.Tests/Helpers/HttpClientWrapper.cs
WalletWasabi.Tests/Helpers/HttpClientWrapper.cs
@@ -17,7 +17,7 @@ namespace WalletWasabi.Tests.Helpers
public Func<Uri>? BaseUriGetter => ()=> _httpClient.BaseAddress;
- public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token = default)
+ public virtual Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token = default)
{
return _httpClient.SendAsync(request, token);
}

MODIFY
WalletWasabi.Tests/RegressionTests/WabiSabi/WabiSabiApiApplicationFactory.cs
WalletWasabi.Tests/RegressionTests/WabiSabi/WabiSabiApiApplicationFactory.cs
@@ -12,6 +12,7 @@ using NBitcoin;
using WalletWasabi.BitcoinCore.Rpc;
using WalletWasabi.Crypto.Randomness;
using WalletWasabi.Tests.Helpers;
+using WalletWasabi.Tor.Http;
using WalletWasabi.WabiSabi.Backend;
using WalletWasabi.WabiSabi.Backend.Banning;
using WalletWasabi.WabiSabi.Backend.PostRequests;
@@ -49,9 +50,15 @@ namespace WalletWasabi.Tests.RegressionTests.WabiSabi
});
}
- public async Task<ArenaClient> CreateArenaClientAsync(HttpClient? httpClient = null)
+
+ public Task<ArenaClient> CreateArenaClientAsync(HttpClient httpClient) =>
+ CreateArenaClientAsync(CreateWabiSabiHttpApiClient(httpClient));
+
+ public Task<ArenaClient> CreateArenaClientAsync(IHttpClient httpClient) =>
+ CreateArenaClientAsync(new WabiSabiHttpApiClient(httpClient));
+
+ public async Task<ArenaClient> CreateArenaClientAsync(WabiSabiHttpApiClient wabiSabiHttpApiClient)
{
- var wabiSabiHttpApiClient = CreateWabiSabiHttpApiClient(httpClient);
var rounds = await wabiSabiHttpApiClient.GetStatusAsync(CancellationToken.None);
var round = rounds.First(x => x.CoinjoinState is ConstructionState);
var arenaClient = new ArenaClient(
@@ -64,7 +71,7 @@ namespace WalletWasabi.Tests.RegressionTests.WabiSabi
return arenaClient;
}
- public WabiSabiHttpApiClient CreateWabiSabiHttpApiClient(HttpClient? httpClient = null) =>
- new(new HttpClientWrapper(httpClient ?? CreateClient()));
+ public WabiSabiHttpApiClient CreateWabiSabiHttpApiClient(HttpClient httpClient) =>
+ new(new HttpClientWrapper(httpClient));
}
}

MODIFY
WalletWasabi.Tests/RegressionTests/WabiSabi/WabiSabiHttpApiIntegrationTests.cs
WalletWasabi.Tests/RegressionTests/WabiSabi/WabiSabiHttpApiIntegrationTests.cs
@@ -26,7 +26,7 @@ namespace WalletWasabi.Tests.RegressionTests.WabiSabi
{
var httpClient = _apiApplicationFactory.CreateClient();
- var apiClient = await _apiApplicationFactory.CreateArenaClientAsync();
+ var apiClient = await _apiApplicationFactory.CreateArenaClientAsync(httpClient);
var rounds = await apiClient.GetStatusAsync(CancellationToken.None);
var round = rounds.First(x => x.CoinjoinState is ConstructionState);
@@ -74,5 +74,73 @@ namespace WalletWasabi.Tests.RegressionTests.WabiSabi
Assert.NotEqual(uint256.Zero, response.Value);
}
+
+ [Fact]
+ public async Task RegisterCoinIdempotencyAsync()
+ {
+ using var signingKey = new Key();
+ var coinToRegister = new Coin(
+ BitcoinFactory.CreateOutPoint(),
+ new TxOut(Money.Coins(1), signingKey.PubKey.WitHash.ScriptPubKey));
+
+ var httpClient = _apiApplicationFactory.WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureServices(services =>
+ {
+ var rpc = BitcoinFactory.GetMockMinimalRpc();
+ rpc.OnGetTxOutAsync = (_, _, _) => new()
+ {
+ Confirmations = 101,
+ IsCoinBase = false,
+ ScriptPubKeyType = "witness_v0_keyhash",
+ TxOut = coinToRegister.TxOut
+ };
+ services.AddScoped<IRPCClient>(s => rpc);
+ });
+ }).CreateClient();
+
+ var apiClient = await _apiApplicationFactory.CreateArenaClientAsync(new StuttererHttpClient(httpClient));
+ var rounds = await apiClient.GetStatusAsync(CancellationToken.None);
+ var round = rounds.First(x => x.CoinjoinState is ConstructionState);
+
+ var response = await apiClient.RegisterInputAsync(Money.Coins(1), coinToRegister.Outpoint, signingKey, round.Id, CancellationToken.None);
+
+ Assert.NotEqual(uint256.Zero, response.Value);
+ }
+
+ }
+
+ class StuttererHttpClient : HttpClientWrapper
+ {
+ public StuttererHttpClient(HttpClient httpClient) : base(httpClient)
+ {
+ }
+ public override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token = default)
+ {
+ var result1 = await base.SendAsync(request.Clone(), token);
+ var result2 = await base.SendAsync(request.Clone(), token);
+ var content1 = await result1.Content.ReadAsStringAsync();
+ var content2 = await result2.Content.ReadAsStringAsync();
+ Assert.Equal(content1, content2);
+ return result2;
+ }
+ }
+
+ public static class HttpRequestMessageExtensions
+ {
+ public static HttpRequestMessage Clone(this HttpRequestMessage request)
+ {
+ var clone = new HttpRequestMessage(request.Method, request.RequestUri)
+ {
+ Content = request.Content,
+ Version = request.Version
+ };
+ foreach (var header in request.Headers)
+ {
+ clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+
+ return clone;
+ }
}
}

Fix and test idempotency