Files
git.stella-ops.org/src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Tests/ExportEndpointsTests.cs
2026-02-01 21:37:40 +02:00

318 lines
11 KiB
C#

// -----------------------------------------------------------------------------
// ExportEndpointsTests.cs
// Sprint: SPRINT_20260106_003_003_EVIDENCE_export_bundle
// Task: T024
// Description: Integration tests for evidence bundle export API endpoints.
// -----------------------------------------------------------------------------
using EvidenceLockerProgram = StellaOps.EvidenceLocker.WebService.Program;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using StellaOps.EvidenceLocker.Api;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Xunit;
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.
/// </summary>
[Collection(EvidenceLockerTestCollection.Name)]
public sealed class ExportEndpointsTests
{
private readonly EvidenceLockerWebApplicationFactory _factory;
public ExportEndpointsTests(EvidenceLockerWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task TriggerExport_ValidBundle_Returns202Accepted()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.CreateExportJobAsync(
"bundle-123",
It.IsAny<ExportTriggerRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportJobResult
{
ExportId = "exp-123",
Status = "pending",
EstimatedSize = 1024
});
using var scope = CreateClientWithMock(mockService.Object);
var request = new ExportTriggerRequest();
// Act
var response = await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
// Assert
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ExportTriggerResponse>();
Assert.NotNull(result);
Assert.Equal("exp-123", result.ExportId);
Assert.Equal("pending", result.Status);
}
[Fact]
public async Task TriggerExport_BundleNotFound_Returns404()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.CreateExportJobAsync(
"nonexistent",
It.IsAny<ExportTriggerRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportJobResult { IsNotFound = true });
using var scope = CreateClientWithMock(mockService.Object);
var request = new ExportTriggerRequest();
// Act
var response = await scope.Client.PostAsJsonAsync("/api/v1/bundles/nonexistent/export", request);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetExportStatus_ExportReady_Returns200WithDownloadUrl()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.GetExportStatusAsync("bundle-123", "exp-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportJobStatus
{
ExportId = "exp-123",
Status = ExportJobStatusEnum.Ready,
FileSize = 2048,
CompletedAt = DateTimeOffset.Parse("2026-01-07T12:00:00Z")
});
using var scope = CreateClientWithMock(mockService.Object);
// Act
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ExportStatusResponse>();
Assert.NotNull(result);
Assert.Equal("ready", result.Status);
Assert.Contains("download", result.DownloadUrl);
}
[Fact]
public async Task GetExportStatus_ExportProcessing_Returns202()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.GetExportStatusAsync("bundle-123", "exp-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportJobStatus
{
ExportId = "exp-123",
Status = ExportJobStatusEnum.Processing,
Progress = 50,
EstimatedTimeRemaining = "30s"
});
using var scope = CreateClientWithMock(mockService.Object);
// Act
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
// Assert
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ExportStatusResponse>();
Assert.NotNull(result);
Assert.Equal("processing", result.Status);
Assert.Equal(50, result.Progress);
}
[Fact]
public async Task GetExportStatus_ExportNotFound_Returns404()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.GetExportStatusAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExportJobStatus?)null);
using var scope = CreateClientWithMock(mockService.Object);
// Act
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task DownloadExport_ExportReady_ReturnsFileStream()
{
// Arrange
var mockService = new Mock<IExportJobService>();
var testStream = new MemoryStream(new byte[] { 0x1f, 0x8b, 0x08 }); // gzip magic bytes
mockService
.Setup(s => s.GetExportFileAsync("bundle-123", "exp-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportFileResult
{
Status = ExportJobStatusEnum.Ready,
FileStream = testStream,
FileName = "evidence-bundle-123.tar.gz"
});
using var scope = CreateClientWithMock(mockService.Object);
// Act
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/gzip", response.Content.Headers.ContentType?.MediaType);
}
[Fact]
public async Task DownloadExport_ExportNotReady_Returns409Conflict()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.GetExportFileAsync("bundle-123", "exp-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportFileResult
{
Status = ExportJobStatusEnum.Processing
});
using var scope = CreateClientWithMock(mockService.Object);
// Act
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
// Assert
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
[Fact]
public async Task DownloadExport_ExportNotFound_Returns404()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.GetExportFileAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExportFileResult?)null);
using var scope = CreateClientWithMock(mockService.Object);
// Act
var response = await scope.Client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent/download");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task TriggerExport_WithOptions_PassesOptionsToService()
{
// Arrange
ExportTriggerRequest? capturedRequest = null;
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.CreateExportJobAsync(
"bundle-123",
It.IsAny<ExportTriggerRequest>(),
It.IsAny<CancellationToken>()))
.Callback<string, ExportTriggerRequest, CancellationToken>((_, req, _) => capturedRequest = req)
.ReturnsAsync(new ExportJobResult
{
ExportId = "exp-123",
Status = "pending"
});
using var scope = CreateClientWithMock(mockService.Object);
var request = new ExportTriggerRequest
{
CompressionLevel = 9,
IncludeLayerSboms = false,
IncludeRekorProofs = false
};
// Act
await scope.Client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
// Assert
Assert.NotNull(capturedRequest);
Assert.Equal(9, capturedRequest.CompressionLevel);
Assert.False(capturedRequest.IncludeLayerSboms);
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
{
private readonly WebApplicationFactory<EvidenceLockerProgram> _derivedFactory;
public HttpClient Client { get; }
public MockScope(WebApplicationFactory<EvidenceLockerProgram> derivedFactory)
{
_derivedFactory = derivedFactory;
Client = derivedFactory.CreateClient();
}
public void Dispose()
{
Client.Dispose();
_derivedFactory.Dispose();
}
}
private MockScope CreateClientWithMock(IExportJobService mockService)
{
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);
}
// Add mock
services.AddSingleton(mockService);
});
});
return new MockScope(derivedFactory);
}
}