318 lines
11 KiB
C#
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<Program> 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);
|
|
}
|
|
}
|