up tests and theme
This commit is contained in:
@@ -33,6 +33,7 @@ public sealed class EvidenceLockerIntegrationTests : IDisposable
|
||||
public EvidenceLockerIntegrationTests(EvidenceLockerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_factory.ResetTestState();
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,18 @@ public sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<
|
||||
|
||||
public TestTimelinePublisher TimelinePublisher => Services.GetRequiredService<TestTimelinePublisher>();
|
||||
|
||||
/// <summary>
|
||||
/// Resets all singleton test doubles to prevent accumulated state from
|
||||
/// leaking memory across test classes sharing this factory instance.
|
||||
/// Call from each test class constructor.
|
||||
/// </summary>
|
||||
public void ResetTestState()
|
||||
{
|
||||
Repository.Reset();
|
||||
ObjectStore.Reset();
|
||||
TimelinePublisher.Reset();
|
||||
}
|
||||
|
||||
private static SigningKeyMaterialOptions GenerateKeyMaterial()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
@@ -164,6 +176,12 @@ public sealed class TestTimelinePublisher : IEvidenceTimelinePublisher
|
||||
public List<string> PublishedEvents { get; } = new();
|
||||
public List<string> IncidentEvents { get; } = new();
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
PublishedEvents.Clear();
|
||||
IncidentEvents.Clear();
|
||||
}
|
||||
|
||||
public Task PublishBundleSealedAsync(
|
||||
EvidenceBundleSignature signature,
|
||||
EvidenceBundleManifest manifest,
|
||||
@@ -196,6 +214,12 @@ public sealed class TestEvidenceObjectStore : IEvidenceObjectStore
|
||||
|
||||
public void SeedExisting(string storageKey) => _preExisting.Add(storageKey);
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_objects.Clear();
|
||||
_preExisting.Clear();
|
||||
}
|
||||
|
||||
public Task<EvidenceObjectMetadata> StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
using var memory = new MemoryStream();
|
||||
@@ -235,6 +259,13 @@ public sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
|
||||
|
||||
public bool HoldConflict { get; set; }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_signatures.Clear();
|
||||
_bundles.Clear();
|
||||
HoldConflict = false;
|
||||
}
|
||||
|
||||
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
|
||||
{
|
||||
_bundles[(bundle.Id.Value, bundle.TenantId.Value)] = bundle;
|
||||
|
||||
@@ -38,6 +38,7 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
|
||||
public EvidenceLockerWebServiceContractTests(EvidenceLockerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_factory.ResetTestState();
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
@@ -322,7 +323,7 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceCreate);
|
||||
|
||||
var listener = new ActivityListener
|
||||
using var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = source => source.Name.Contains("StellaOps", StringComparison.OrdinalIgnoreCase),
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
@@ -359,8 +360,6 @@ public sealed class EvidenceLockerWebServiceContractTests : IDisposable
|
||||
.FirstOrDefault(e => e.Contains(bundleId!, StringComparison.Ordinal));
|
||||
timelineEvent.Should().NotBeNull($"expected a timeline event containing bundleId {bundleId}");
|
||||
timelineEvent.Should().Contain(bundleId!);
|
||||
|
||||
listener.Dispose();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed class EvidenceLockerWebServiceTests : IDisposable
|
||||
public EvidenceLockerWebServiceTests(EvidenceLockerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_factory.ResetTestState();
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ public sealed class EvidenceReindexIntegrationTests : IDisposable
|
||||
public EvidenceReindexIntegrationTests(EvidenceLockerWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_factory.ResetTestState();
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,19 +20,18 @@ namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for export API endpoints.
|
||||
/// Uses the shared EvidenceLockerWebApplicationFactory (via Collection fixture)
|
||||
/// instead of raw WebApplicationFactory<Program> to avoid loading real
|
||||
/// infrastructure services (database, auth, background services) which causes
|
||||
/// the test process to hang and consume excessive memory.
|
||||
/// Uses a single derived WebApplicationFactory for the entire class (via IClassFixture)
|
||||
/// to avoid creating a new TestServer per test, which previously leaked memory.
|
||||
/// </summary>
|
||||
[Collection(EvidenceLockerTestCollection.Name)]
|
||||
public sealed class ExportEndpointsTests
|
||||
public sealed class ExportEndpointsTests : IClassFixture<ExportEndpointsTests.ExportTestFixture>, IDisposable
|
||||
{
|
||||
private readonly EvidenceLockerWebApplicationFactory _factory;
|
||||
private readonly ExportTestFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ExportEndpointsTests(EvidenceLockerWebApplicationFactory factory)
|
||||
public ExportEndpointsTests(ExportTestFixture fixture)
|
||||
{
|
||||
_factory = factory;
|
||||
_fixture = fixture;
|
||||
_client = fixture.DerivedFactory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -52,11 +51,11 @@ public sealed class ExportEndpointsTests
|
||||
EstimatedSize = 1024
|
||||
});
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
var request = new ExportTriggerRequest();
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
@@ -79,11 +78,11 @@ public sealed class ExportEndpointsTests
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ExportJobResult { IsNotFound = true });
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
var request = new ExportTriggerRequest();
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.PostAsJsonAsync("/api/v1/bundles/nonexistent/export", request);
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/bundles/nonexistent/export", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
@@ -104,10 +103,10 @@ public sealed class ExportEndpointsTests
|
||||
CompletedAt = DateTimeOffset.Parse("2026-01-07T12:00:00Z")
|
||||
});
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
|
||||
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -133,10 +132,10 @@ public sealed class ExportEndpointsTests
|
||||
EstimatedTimeRemaining = "30s"
|
||||
});
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
|
||||
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
@@ -156,10 +155,10 @@ public sealed class ExportEndpointsTests
|
||||
.Setup(s => s.GetExportStatusAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ExportJobStatus?)null);
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent");
|
||||
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
@@ -181,10 +180,10 @@ public sealed class ExportEndpointsTests
|
||||
FileName = "evidence-bundle-123.tar.gz"
|
||||
});
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
|
||||
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -203,10 +202,10 @@ public sealed class ExportEndpointsTests
|
||||
Status = ExportJobStatusEnum.Processing
|
||||
});
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
|
||||
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
@@ -221,10 +220,10 @@ public sealed class ExportEndpointsTests
|
||||
.Setup(s => s.GetExportFileAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ExportFileResult?)null);
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
|
||||
// Act
|
||||
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent/download");
|
||||
var response = await _client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent/download");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
@@ -248,7 +247,7 @@ public sealed class ExportEndpointsTests
|
||||
Status = "pending"
|
||||
});
|
||||
|
||||
using var scope = CreateClientWithMock(mockService.Object);
|
||||
_fixture.CurrentMock = mockService.Object;
|
||||
var request = new ExportTriggerRequest
|
||||
{
|
||||
CompressionLevel = 9,
|
||||
@@ -257,7 +256,7 @@ public sealed class ExportEndpointsTests
|
||||
};
|
||||
|
||||
// Act
|
||||
await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
|
||||
await _client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedRequest);
|
||||
@@ -266,52 +265,76 @@ public sealed class ExportEndpointsTests
|
||||
Assert.False(capturedRequest.IncludeRekorProofs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a derived WebApplicationFactory and HttpClient so both are disposed together.
|
||||
/// Previously, WithWebHostBuilder() created a new factory per test that was never disposed,
|
||||
/// leaking TestServer instances and consuming gigabytes of memory.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Wraps a derived WebApplicationFactory and HttpClient so both are disposed together.
|
||||
/// Previously, WithWebHostBuilder() created a new factory per test that was never disposed,
|
||||
/// leaking TestServer instances and consuming gigabytes of memory.
|
||||
/// </summary>
|
||||
private sealed class MockScope : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
private readonly WebApplicationFactory<EvidenceLockerProgram> _derivedFactory;
|
||||
public HttpClient Client { get; }
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
public MockScope(WebApplicationFactory<EvidenceLockerProgram> derivedFactory)
|
||||
/// <summary>
|
||||
/// Fixture that creates a single derived WebApplicationFactory with a swappable
|
||||
/// IExportJobService mock. Tests set <see cref="CurrentMock"/> before each request
|
||||
/// instead of creating a new factory per test. This eliminates 9 TestServer instances
|
||||
/// that were previously leaking memory.
|
||||
/// </summary>
|
||||
public sealed class ExportTestFixture : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The current mock to delegate to. Set by each test before making requests.
|
||||
/// </summary>
|
||||
public IExportJobService CurrentMock { get; set; } = new Mock<IExportJobService>().Object;
|
||||
|
||||
public WebApplicationFactory<EvidenceLockerProgram> DerivedFactory { get; }
|
||||
|
||||
public ExportTestFixture()
|
||||
{
|
||||
_derivedFactory = derivedFactory;
|
||||
Client = derivedFactory.CreateClient();
|
||||
// Create ONE derived factory whose IExportJobService delegates to CurrentMock.
|
||||
// This avoids creating a new TestServer per test.
|
||||
var baseFactory = new EvidenceLockerWebApplicationFactory();
|
||||
DerivedFactory = baseFactory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(IExportJobService));
|
||||
if (descriptor != null)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
// Register a delegating wrapper that forwards to CurrentMock,
|
||||
// allowing each test to swap the mock without a new factory.
|
||||
services.AddSingleton<IExportJobService>(sp =>
|
||||
new DelegatingExportJobService(this));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Client.Dispose();
|
||||
_derivedFactory.Dispose();
|
||||
DerivedFactory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private MockScope CreateClientWithMock(IExportJobService mockService)
|
||||
/// <summary>
|
||||
/// Thin delegate that forwards all calls to the fixture's current mock,
|
||||
/// allowing per-test mock swapping without creating new WebApplicationFactory instances.
|
||||
/// </summary>
|
||||
private sealed class DelegatingExportJobService : IExportJobService
|
||||
{
|
||||
var derivedFactory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove existing registration
|
||||
var descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(IExportJobService));
|
||||
if (descriptor != null)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
private readonly ExportTestFixture _fixture;
|
||||
|
||||
// Add mock
|
||||
services.AddSingleton(mockService);
|
||||
});
|
||||
});
|
||||
return new MockScope(derivedFactory);
|
||||
public DelegatingExportJobService(ExportTestFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public Task<ExportJobResult> CreateExportJobAsync(string bundleId, ExportTriggerRequest request, CancellationToken cancellationToken)
|
||||
=> _fixture.CurrentMock.CreateExportJobAsync(bundleId, request, cancellationToken);
|
||||
|
||||
public Task<ExportJobStatus?> GetExportStatusAsync(string bundleId, string exportId, CancellationToken cancellationToken)
|
||||
=> _fixture.CurrentMock.GetExportStatusAsync(bundleId, exportId, cancellationToken);
|
||||
|
||||
public Task<ExportFileResult?> GetExportFileAsync(string bundleId, string exportId, CancellationToken cancellationToken)
|
||||
=> _fixture.CurrentMock.GetExportFileAsync(bundleId, exportId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,16 @@ public sealed class PostgreSqlFixture : IAsyncLifetime
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// On Windows, try to open the Docker named pipe with a short timeout.
|
||||
// File.Exists does not work for named pipes.
|
||||
using var pipe = new System.IO.Pipes.NamedPipeClientStream(".", "docker_engine", System.IO.Pipes.PipeDirection.InOut, System.IO.Pipes.PipeOptions.None);
|
||||
pipe.Connect(2000); // 2 second timeout
|
||||
return true;
|
||||
// Check if the Docker daemon is actually running by looking for its process.
|
||||
// NamedPipeClientStream.Connect() hangs indefinitely when Docker Desktop is
|
||||
// installed but not running (the pipe exists but nobody reads from it).
|
||||
// Testcontainers' own Docker client also hangs in this scenario.
|
||||
// Checking for a running process is instant and avoids the hang entirely.
|
||||
var dockerProcesses = System.Diagnostics.Process.GetProcessesByName("com.docker.backend");
|
||||
if (dockerProcesses.Length == 0)
|
||||
dockerProcesses = System.Diagnostics.Process.GetProcessesByName("dockerd");
|
||||
foreach (var p in dockerProcesses) p.Dispose();
|
||||
return dockerProcesses.Length > 0;
|
||||
}
|
||||
|
||||
// On Linux/macOS, check for the Docker socket
|
||||
|
||||
Reference in New Issue
Block a user