Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/IdempotencyMiddlewareTests.cs

149 lines
5.7 KiB
C#

// -----------------------------------------------------------------------------
// IdempotencyMiddlewareTests.cs
// Sprint: SPRINT_3500_0002_0003_proof_replay_api
// Task: T6 - Unit Tests for Idempotency Middleware
// Description: Tests for Content-Digest idempotency handling
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Unit tests for IdempotencyMiddleware.
/// </summary>
public sealed class IdempotencyMiddlewareTests
{
private const string ContentDigestHeader = "Content-Digest";
private const string IdempotencyKeyHeader = "X-Idempotency-Key";
private const string IdempotencyCachedHeader = "X-Idempotency-Cached";
private static ScannerApplicationFactory CreateFactory() =>
new ScannerApplicationFactory(
configureConfiguration: config =>
{
config["Scanner:Idempotency:Enabled"] = "true";
config["Scanner:Idempotency:Window"] = "24:00:00";
});
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PostRequest_WithContentDigest_ReturnsIdempotencyKey()
{
// Arrange
await using var factory = CreateFactory();
using var client = factory.CreateClient();
var content = new StringContent("""{"test":"data"}""", Encoding.UTF8, "application/json");
var digest = ComputeContentDigest("""{"test":"data"}""");
content.Headers.Add(ContentDigestHeader, digest);
// Act
var response = await client.PostAsync("/api/v1/scans", content);
// Assert - Should process the request
// Not testing specific status since scan creation may require more setup
// Just verify no 500 error
Assert.NotEqual(HttpStatusCode.InternalServerError, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DuplicateRequest_WithSameContentDigest_ReturnsCachedResponse()
{
// Arrange
await using var factory = CreateFactory();
using var client = factory.CreateClient();
var requestBody = """{"artifactDigest":"sha256:test123"}""";
var digest = ComputeContentDigest(requestBody);
// First request
var content1 = new StringContent(requestBody, Encoding.UTF8, "application/json");
content1.Headers.Add(ContentDigestHeader, digest);
var response1 = await client.PostAsync("/api/v1/scans", content1);
// Second request with same digest
var content2 = new StringContent(requestBody, Encoding.UTF8, "application/json");
content2.Headers.Add(ContentDigestHeader, digest);
var response2 = await client.PostAsync("/api/v1/scans", content2);
// Assert - Second request should be handled (either cached or processed)
// The middleware may return cached response with X-Idempotency-Cached: true
Assert.NotEqual(HttpStatusCode.InternalServerError, response2.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DifferentRequests_WithDifferentDigests_AreProcessedSeparately()
{
// Arrange
await using var factory = CreateFactory();
using var client = factory.CreateClient();
var requestBody1 = """{"artifactDigest":"sha256:unique1"}""";
var requestBody2 = """{"artifactDigest":"sha256:unique2"}""";
var content1 = new StringContent(requestBody1, Encoding.UTF8, "application/json");
content1.Headers.Add(ContentDigestHeader, ComputeContentDigest(requestBody1));
var content2 = new StringContent(requestBody2, Encoding.UTF8, "application/json");
content2.Headers.Add(ContentDigestHeader, ComputeContentDigest(requestBody2));
// Act
var response1 = await client.PostAsync("/api/v1/scans", content1);
var response2 = await client.PostAsync("/api/v1/scans", content2);
// Assert - Both should be processed (not cached duplicates)
Assert.NotEqual(HttpStatusCode.InternalServerError, response1.StatusCode);
Assert.NotEqual(HttpStatusCode.InternalServerError, response2.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetRequest_BypassesIdempotencyMiddleware()
{
// Arrange
await using var factory = CreateFactory();
using var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/api/v1/scans");
// Assert - GET should bypass idempotency middleware and return normally
Assert.NotEqual(HttpStatusCode.InternalServerError, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PostRequest_WithoutContentDigest_ComputesDigest()
{
// Arrange
await using var factory = CreateFactory();
using var client = factory.CreateClient();
var content = new StringContent("""{"test":"nodigest"}""", Encoding.UTF8, "application/json");
// Not adding Content-Digest header - middleware should compute it
// Act
var response = await client.PostAsync("/api/v1/scans", content);
// Assert - Request should still be processed
Assert.NotEqual(HttpStatusCode.InternalServerError, response.StatusCode);
}
private static string ComputeContentDigest(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
var base64 = Convert.ToBase64String(hash);
return $"sha-256=:{base64}:";
}
}