up tests and theme

This commit is contained in:
master
2026-02-02 08:57:29 +02:00
parent a53edd1e48
commit 817ffc7251
200 changed files with 30378 additions and 30287 deletions

View File

@@ -33,6 +33,7 @@ public sealed class EvidenceLockerIntegrationTests : IDisposable
public EvidenceLockerIntegrationTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = factory;
_factory.ResetTestState();
_client = _factory.CreateClient();
}

View File

@@ -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;

View File

@@ -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)]

View File

@@ -34,6 +34,7 @@ public sealed class EvidenceLockerWebServiceTests : IDisposable
public EvidenceLockerWebServiceTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = factory;
_factory.ResetTestState();
_client = factory.CreateClient();
}

View File

@@ -37,6 +37,7 @@ public sealed class EvidenceReindexIntegrationTests : IDisposable
public EvidenceReindexIntegrationTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = factory;
_factory.ResetTestState();
_client = _factory.CreateClient();
}

View File

@@ -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&lt;Program&gt; 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);
}
}

View File

@@ -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