// ----------------------------------------------------------------------------- // 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; /// /// 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. /// [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(); mockService .Setup(s => s.CreateExportJobAsync( "bundle-123", It.IsAny(), It.IsAny())) .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(); 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(); mockService .Setup(s => s.CreateExportJobAsync( "nonexistent", It.IsAny(), It.IsAny())) .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(); mockService .Setup(s => s.GetExportStatusAsync("bundle-123", "exp-123", It.IsAny())) .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(); 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(); mockService .Setup(s => s.GetExportStatusAsync("bundle-123", "exp-123", It.IsAny())) .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(); 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(); mockService .Setup(s => s.GetExportStatusAsync("bundle-123", "nonexistent", It.IsAny())) .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(); var testStream = new MemoryStream(new byte[] { 0x1f, 0x8b, 0x08 }); // gzip magic bytes mockService .Setup(s => s.GetExportFileAsync("bundle-123", "exp-123", It.IsAny())) .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(); mockService .Setup(s => s.GetExportFileAsync("bundle-123", "exp-123", It.IsAny())) .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(); mockService .Setup(s => s.GetExportFileAsync("bundle-123", "nonexistent", It.IsAny())) .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(); mockService .Setup(s => s.CreateExportJobAsync( "bundle-123", It.IsAny(), It.IsAny())) .Callback((_, 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); } /// /// 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. /// /// /// 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. /// private sealed class MockScope : IDisposable { private readonly WebApplicationFactory _derivedFactory; public HttpClient Client { get; } public MockScope(WebApplicationFactory 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); } }