This commit is contained in:
StellaOps Bot
2025-12-07 22:49:53 +02:00
parent 11597679ed
commit 7c24ed96ee
204 changed files with 23313 additions and 1430 deletions

View File

@@ -0,0 +1,299 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using StellaOps.ExportCenter.Client.Models;
using Xunit;
namespace StellaOps.ExportCenter.Client.Tests;
/// <summary>
/// Smoke tests for ExportCenterClient with mock HTTP responses.
/// </summary>
public sealed class ExportCenterClientTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task GetDiscoveryMetadataAsync_ReturnsMetadata()
{
var expectedMetadata = new OpenApiDiscoveryMetadata(
Service: "export-center",
Version: "1.0.0",
SpecVersion: "3.0.3",
Format: "application/yaml",
Url: "/openapi/export-center.yaml",
JsonUrl: "/openapi/export-center.json",
ErrorEnvelopeSchema: "#/components/schemas/ErrorEnvelope",
GeneratedAt: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
ProfilesSupported: new[] { "attestation", "mirror" },
ChecksumSha256: null);
var handler = new MockHttpMessageHandler(request =>
{
Assert.Equal("/.well-known/openapi", request.RequestUri!.AbsolutePath);
return CreateJsonResponse(expectedMetadata);
});
var client = CreateClient(handler);
var result = await client.GetDiscoveryMetadataAsync();
Assert.Equal("export-center", result.Service);
Assert.Equal("1.0.0", result.Version);
Assert.Equal("3.0.3", result.SpecVersion);
}
[Fact]
public async Task ListProfilesAsync_ReturnsProfiles()
{
var expectedResponse = new ExportProfileListResponse(
Profiles: new[]
{
new ExportProfile(
ProfileId: "profile-1",
Name: "Test Profile",
Description: "Test",
Adapter: "evidence",
Selectors: new Dictionary<string, string> { ["org"] = "test" },
OutputFormat: "tar.gz",
SigningEnabled: true,
CreatedAt: DateTimeOffset.UtcNow,
UpdatedAt: null)
},
ContinuationToken: null,
HasMore: false);
var handler = new MockHttpMessageHandler(request =>
{
Assert.Equal("/v1/exports/profiles", request.RequestUri!.AbsolutePath);
return CreateJsonResponse(expectedResponse);
});
var client = CreateClient(handler);
var result = await client.ListProfilesAsync();
Assert.Single(result.Profiles);
Assert.Equal("profile-1", result.Profiles[0].ProfileId);
Assert.False(result.HasMore);
}
[Fact]
public async Task ListProfilesAsync_WithPagination_IncludesParameters()
{
var expectedResponse = new ExportProfileListResponse([], null, false);
var handler = new MockHttpMessageHandler(request =>
{
var query = request.RequestUri!.Query;
Assert.Contains("limit=10", query);
Assert.Contains("continuationToken=abc123", query);
return CreateJsonResponse(expectedResponse);
});
var client = CreateClient(handler);
await client.ListProfilesAsync(continuationToken: "abc123", limit: 10);
}
[Fact]
public async Task GetProfileAsync_WhenNotFound_ReturnsNull()
{
var handler = new MockHttpMessageHandler(request =>
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var client = CreateClient(handler);
var result = await client.GetProfileAsync("nonexistent");
Assert.Null(result);
}
[Fact]
public async Task CreateEvidenceExportAsync_ReturnsResponse()
{
var expectedResponse = new CreateEvidenceExportResponse(
RunId: "run-123",
Status: "pending",
StatusUrl: "/v1/exports/evidence/run-123/status",
EstimatedCompletionSeconds: 60);
var handler = new MockHttpMessageHandler(request =>
{
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal("/v1/exports/evidence", request.RequestUri!.AbsolutePath);
return CreateJsonResponse(expectedResponse, HttpStatusCode.Accepted);
});
var client = CreateClient(handler);
var request = new CreateEvidenceExportRequest("profile-1");
var result = await client.CreateEvidenceExportAsync(request);
Assert.Equal("run-123", result.RunId);
Assert.Equal("pending", result.Status);
}
[Fact]
public async Task GetEvidenceExportStatusAsync_ReturnsStatus()
{
var expectedStatus = new EvidenceExportStatus(
RunId: "run-123",
ProfileId: "profile-1",
Status: "completed",
Progress: 100,
StartedAt: DateTimeOffset.UtcNow.AddMinutes(-5),
CompletedAt: DateTimeOffset.UtcNow,
BundleHash: "sha256:abc123",
DownloadUrl: "/v1/exports/evidence/run-123/download",
ErrorCode: null,
ErrorMessage: null);
var handler = new MockHttpMessageHandler(request =>
{
Assert.Equal("/v1/exports/evidence/run-123/status", request.RequestUri!.AbsolutePath);
return CreateJsonResponse(expectedStatus);
});
var client = CreateClient(handler);
var result = await client.GetEvidenceExportStatusAsync("run-123");
Assert.NotNull(result);
Assert.Equal("completed", result.Status);
Assert.Equal(100, result.Progress);
}
[Fact]
public async Task DownloadEvidenceExportAsync_ReturnsStream()
{
var bundleContent = "test bundle content"u8.ToArray();
var handler = new MockHttpMessageHandler(request =>
{
Assert.Equal("/v1/exports/evidence/run-123/download", request.RequestUri!.AbsolutePath);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(bundleContent)
};
});
var client = CreateClient(handler);
var stream = await client.DownloadEvidenceExportAsync("run-123");
Assert.NotNull(stream);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
Assert.Equal(bundleContent, ms.ToArray());
}
[Fact]
public async Task DownloadEvidenceExportAsync_WhenNotReady_ReturnsNull()
{
var handler = new MockHttpMessageHandler(request =>
{
return new HttpResponseMessage(HttpStatusCode.Conflict);
});
var client = CreateClient(handler);
var result = await client.DownloadEvidenceExportAsync("run-123");
Assert.Null(result);
}
[Fact]
public async Task CreateAttestationExportAsync_ReturnsResponse()
{
var expectedResponse = new CreateAttestationExportResponse(
RunId: "att-run-123",
Status: "pending",
StatusUrl: "/v1/exports/attestations/att-run-123/status",
EstimatedCompletionSeconds: 30);
var handler = new MockHttpMessageHandler(request =>
{
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal("/v1/exports/attestations", request.RequestUri!.AbsolutePath);
return CreateJsonResponse(expectedResponse, HttpStatusCode.Accepted);
});
var client = CreateClient(handler);
var request = new CreateAttestationExportRequest("profile-1", IncludeTransparencyLog: true);
var result = await client.CreateAttestationExportAsync(request);
Assert.Equal("att-run-123", result.RunId);
}
[Fact]
public async Task GetAttestationExportStatusAsync_IncludesTransparencyLogField()
{
var expectedStatus = new AttestationExportStatus(
RunId: "att-run-123",
ProfileId: "profile-1",
Status: "completed",
Progress: 100,
StartedAt: DateTimeOffset.UtcNow.AddMinutes(-2),
CompletedAt: DateTimeOffset.UtcNow,
BundleHash: "sha256:def456",
DownloadUrl: "/v1/exports/attestations/att-run-123/download",
TransparencyLogIncluded: true,
ErrorCode: null,
ErrorMessage: null);
var handler = new MockHttpMessageHandler(request =>
{
return CreateJsonResponse(expectedStatus);
});
var client = CreateClient(handler);
var result = await client.GetAttestationExportStatusAsync("att-run-123");
Assert.NotNull(result);
Assert.True(result.TransparencyLogIncluded);
}
private static ExportCenterClient CreateClient(MockHttpMessageHandler handler)
{
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://localhost:5001")
};
return new ExportCenterClient(httpClient);
}
private static HttpResponseMessage CreateJsonResponse<T>(T content, HttpStatusCode statusCode = HttpStatusCode.OK)
{
var json = JsonSerializer.Serialize(content, JsonOptions);
return new HttpResponseMessage(statusCode)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
}
/// <summary>
/// Mock HTTP message handler for testing.
/// </summary>
internal sealed class MockHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
public MockHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(_handler(request));
}
}

View File

@@ -0,0 +1,170 @@
using StellaOps.ExportCenter.Client.Streaming;
using Xunit;
namespace StellaOps.ExportCenter.Client.Tests;
public sealed class ExportDownloadHelperTests : IDisposable
{
private readonly string _tempDir;
public ExportDownloadHelperTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"export-download-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
{
Directory.Delete(_tempDir, recursive: true);
}
}
[Fact]
public async Task DownloadToFileAsync_WritesContentToFile()
{
var content = "test content"u8.ToArray();
using var stream = new MemoryStream(content);
var outputPath = Path.Combine(_tempDir, "output.bin");
var bytesWritten = await ExportDownloadHelper.DownloadToFileAsync(stream, outputPath);
Assert.Equal(content.Length, bytesWritten);
Assert.True(File.Exists(outputPath));
Assert.Equal(content, await File.ReadAllBytesAsync(outputPath));
}
[Fact]
public async Task DownloadToFileAsync_ReportsProgress()
{
var content = new byte[10000];
Random.Shared.NextBytes(content);
using var stream = new MemoryStream(content);
var outputPath = Path.Combine(_tempDir, "progress.bin");
var progressReports = new List<(long bytes, long? total)>();
await ExportDownloadHelper.DownloadToFileAsync(
stream, outputPath, content.Length, (b, t) => progressReports.Add((b, t)));
Assert.NotEmpty(progressReports);
Assert.Equal(content.Length, progressReports[^1].bytes);
}
[Fact]
public async Task ComputeSha256Async_ReturnsCorrectHash()
{
var content = "test content for hashing"u8.ToArray();
using var stream = new MemoryStream(content);
var hash = await ExportDownloadHelper.ComputeSha256Async(stream);
// Verify it's a valid hex string
Assert.Equal(64, hash.Length); // SHA-256 produces 32 bytes = 64 hex chars
Assert.All(hash, c => Assert.True(char.IsLetterOrDigit(c)));
}
[Fact]
public async Task DownloadAndVerifyAsync_SucceedsWithCorrectHash()
{
var content = "deterministic content"u8.ToArray();
using var hashStream = new MemoryStream(content);
var expectedHash = await ExportDownloadHelper.ComputeSha256Async(hashStream);
using var downloadStream = new MemoryStream(content);
var outputPath = Path.Combine(_tempDir, "verified.bin");
var actualHash = await ExportDownloadHelper.DownloadAndVerifyAsync(
downloadStream, outputPath, expectedHash);
Assert.Equal(expectedHash, actualHash);
Assert.True(File.Exists(outputPath));
}
[Fact]
public async Task DownloadAndVerifyAsync_ThrowsOnHashMismatch()
{
var content = "actual content"u8.ToArray();
using var stream = new MemoryStream(content);
var outputPath = Path.Combine(_tempDir, "mismatch.bin");
var wrongHash = "0000000000000000000000000000000000000000000000000000000000000000";
await Assert.ThrowsAsync<InvalidOperationException>(() =>
ExportDownloadHelper.DownloadAndVerifyAsync(stream, outputPath, wrongHash));
// Verify file was deleted
Assert.False(File.Exists(outputPath));
}
[Fact]
public async Task DownloadAndVerifyAsync_HandlesSha256Prefix()
{
var content = "prefixed hash test"u8.ToArray();
using var hashStream = new MemoryStream(content);
var hash = await ExportDownloadHelper.ComputeSha256Async(hashStream);
var prefixedHash = "sha256:" + hash;
using var downloadStream = new MemoryStream(content);
var outputPath = Path.Combine(_tempDir, "prefixed.bin");
var actualHash = await ExportDownloadHelper.DownloadAndVerifyAsync(
downloadStream, outputPath, prefixedHash);
Assert.Equal(hash, actualHash);
}
[Fact]
public async Task CopyWithProgressAsync_CopiesCorrectly()
{
var content = new byte[5000];
Random.Shared.NextBytes(content);
using var source = new MemoryStream(content);
using var destination = new MemoryStream();
var bytesCopied = await ExportDownloadHelper.CopyWithProgressAsync(source, destination);
Assert.Equal(content.Length, bytesCopied);
Assert.Equal(content, destination.ToArray());
}
[Fact]
public void CreateProgressLogger_ReturnsWorkingCallback()
{
var messages = new List<string>();
var callback = ExportDownloadHelper.CreateProgressLogger(msg => messages.Add(msg), 100);
// Simulate progress
callback(50, 1000); // Should not log (below threshold)
callback(150, 1000); // Should log
callback(200, 1000); // Should not log (too close to last)
callback(300, 1000); // Should log
Assert.Equal(2, messages.Count);
Assert.Contains("150", messages[0]);
Assert.Contains("300", messages[1]);
}
[Fact]
public void CreateProgressLogger_FormatsWithoutTotalBytes()
{
var messages = new List<string>();
var callback = ExportDownloadHelper.CreateProgressLogger(msg => messages.Add(msg), 100);
callback(200, null);
Assert.Single(messages);
Assert.DoesNotContain("%", messages[0]);
}
[Fact]
public void CreateProgressLogger_FormatsWithTotalBytes()
{
var messages = new List<string>();
var callback = ExportDownloadHelper.CreateProgressLogger(msg => messages.Add(msg), 100);
callback(500, 1000);
Assert.Single(messages);
Assert.Contains("%", messages[0]);
}
}

View File

@@ -0,0 +1,182 @@
using StellaOps.ExportCenter.Client.Lifecycle;
using StellaOps.ExportCenter.Client.Models;
using Xunit;
namespace StellaOps.ExportCenter.Client.Tests;
public sealed class ExportJobLifecycleHelperTests
{
[Theory]
[InlineData("completed", true)]
[InlineData("failed", true)]
[InlineData("cancelled", true)]
[InlineData("pending", false)]
[InlineData("running", false)]
[InlineData("COMPLETED", true)]
public void IsTerminalStatus_ReturnsCorrectValue(string status, bool expected)
{
var result = ExportJobLifecycleHelper.IsTerminalStatus(status);
Assert.Equal(expected, result);
}
[Fact]
public async Task WaitForEvidenceExportCompletionAsync_ReturnsOnTerminalStatus()
{
var callCount = 0;
var mockClient = new MockExportCenterClient
{
GetEvidenceExportStatusHandler = runId =>
{
callCount++;
var status = callCount < 3 ? "running" : "completed";
return new EvidenceExportStatus(
RunId: runId,
ProfileId: "profile-1",
Status: status,
Progress: callCount < 3 ? 50 : 100,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: callCount >= 3 ? DateTimeOffset.UtcNow : null,
BundleHash: callCount >= 3 ? "sha256:abc" : null,
DownloadUrl: null,
ErrorCode: null,
ErrorMessage: null);
}
};
var result = await ExportJobLifecycleHelper.WaitForEvidenceExportCompletionAsync(
mockClient, "run-1", TimeSpan.FromMilliseconds(10), TimeSpan.FromSeconds(10));
Assert.Equal("completed", result.Status);
Assert.Equal(100, result.Progress);
Assert.Equal(3, callCount);
}
[Fact]
public async Task WaitForEvidenceExportCompletionAsync_ThrowsOnNotFound()
{
var mockClient = new MockExportCenterClient
{
GetEvidenceExportStatusHandler = _ => null
};
await Assert.ThrowsAsync<InvalidOperationException>(() =>
ExportJobLifecycleHelper.WaitForEvidenceExportCompletionAsync(
mockClient, "nonexistent", TimeSpan.FromMilliseconds(10), TimeSpan.FromSeconds(1)));
}
[Fact]
public async Task WaitForAttestationExportCompletionAsync_ReturnsOnTerminalStatus()
{
var callCount = 0;
var mockClient = new MockExportCenterClient
{
GetAttestationExportStatusHandler = runId =>
{
callCount++;
var status = callCount < 2 ? "running" : "completed";
return new AttestationExportStatus(
RunId: runId,
ProfileId: "profile-1",
Status: status,
Progress: callCount < 2 ? 50 : 100,
StartedAt: DateTimeOffset.UtcNow,
CompletedAt: callCount >= 2 ? DateTimeOffset.UtcNow : null,
BundleHash: callCount >= 2 ? "sha256:abc" : null,
DownloadUrl: null,
TransparencyLogIncluded: true,
ErrorCode: null,
ErrorMessage: null);
}
};
var result = await ExportJobLifecycleHelper.WaitForAttestationExportCompletionAsync(
mockClient, "run-1", TimeSpan.FromMilliseconds(10), TimeSpan.FromSeconds(10));
Assert.Equal("completed", result.Status);
Assert.True(result.TransparencyLogIncluded);
}
[Fact]
public async Task CreateEvidenceExportAndWaitAsync_CreatesAndWaits()
{
var createCalled = false;
var mockClient = new MockExportCenterClient
{
CreateEvidenceExportHandler = request =>
{
createCalled = true;
return new CreateEvidenceExportResponse("run-1", "pending", "/status", 10);
},
GetEvidenceExportStatusHandler = runId =>
{
return new EvidenceExportStatus(
runId, "profile-1", "completed", 100,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
"sha256:abc", "/download", null, null);
}
};
var result = await ExportJobLifecycleHelper.CreateEvidenceExportAndWaitAsync(
mockClient,
new CreateEvidenceExportRequest("profile-1"),
TimeSpan.FromMilliseconds(10),
TimeSpan.FromSeconds(10));
Assert.True(createCalled);
Assert.Equal("completed", result.Status);
}
[Fact]
public void TerminalStatuses_ContainsExpectedValues()
{
Assert.Contains("completed", ExportJobLifecycleHelper.TerminalStatuses);
Assert.Contains("failed", ExportJobLifecycleHelper.TerminalStatuses);
Assert.Contains("cancelled", ExportJobLifecycleHelper.TerminalStatuses);
Assert.DoesNotContain("pending", ExportJobLifecycleHelper.TerminalStatuses);
Assert.DoesNotContain("running", ExportJobLifecycleHelper.TerminalStatuses);
}
}
/// <summary>
/// Mock implementation of IExportCenterClient for testing.
/// </summary>
internal sealed class MockExportCenterClient : IExportCenterClient
{
public Func<string, EvidenceExportStatus?>? GetEvidenceExportStatusHandler { get; set; }
public Func<string, AttestationExportStatus?>? GetAttestationExportStatusHandler { get; set; }
public Func<CreateEvidenceExportRequest, CreateEvidenceExportResponse>? CreateEvidenceExportHandler { get; set; }
public Func<CreateAttestationExportRequest, CreateAttestationExportResponse>? CreateAttestationExportHandler { get; set; }
public Task<OpenApiDiscoveryMetadata> GetDiscoveryMetadataAsync(CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<ExportProfileListResponse> ListProfilesAsync(string? continuationToken = null, int? limit = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<ExportProfile?> GetProfileAsync(string profileId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<ExportRunListResponse> ListRunsAsync(string? profileId = null, string? continuationToken = null, int? limit = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<ExportRun?> GetRunAsync(string runId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<CreateEvidenceExportResponse> CreateEvidenceExportAsync(CreateEvidenceExportRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(CreateEvidenceExportHandler?.Invoke(request) ?? throw new NotImplementedException());
public Task<EvidenceExportStatus?> GetEvidenceExportStatusAsync(string runId, CancellationToken cancellationToken = default)
=> Task.FromResult(GetEvidenceExportStatusHandler?.Invoke(runId));
public Task<Stream?> DownloadEvidenceExportAsync(string runId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<CreateAttestationExportResponse> CreateAttestationExportAsync(CreateAttestationExportRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(CreateAttestationExportHandler?.Invoke(request) ?? throw new NotImplementedException());
public Task<AttestationExportStatus?> GetAttestationExportStatusAsync(string runId, CancellationToken cancellationToken = default)
=> Task.FromResult(GetAttestationExportStatusHandler?.Invoke(runId));
public Task<Stream?> DownloadAttestationExportAsync(string runId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit.v3" Version="3.0.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.ExportCenter.Client\StellaOps.ExportCenter.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"methodDisplay": "classAndMethod"
}