Files
git.stella-ops.org/src/__Libraries/StellaOps.TestKit/Fixtures/ContractTestHelper.cs
master 491e883653 Add tests for SBOM generation determinism across multiple formats
- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism.
- Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions.
- Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests.
- Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
2025-12-24 00:36:14 +02:00

201 lines
7.2 KiB
C#

using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace StellaOps.TestKit.Fixtures;
/// <summary>
/// Helpers for API contract testing using OpenAPI schema snapshots.
/// </summary>
public static class ContractTestHelper
{
/// <summary>
/// Fetches and validates the OpenAPI schema against a snapshot.
/// </summary>
public static async Task ValidateOpenApiSchemaAsync<TProgram>(
WebApplicationFactory<TProgram> factory,
string expectedSnapshotPath,
string swaggerEndpoint = "/swagger/v1/swagger.json")
where TProgram : class
{
using var client = factory.CreateClient();
var response = await client.GetAsync(swaggerEndpoint);
response.EnsureSuccessStatusCode();
var actualSchema = await response.Content.ReadAsStringAsync();
if (ShouldUpdateSnapshots())
{
await UpdateSnapshotAsync(expectedSnapshotPath, actualSchema);
return;
}
var expectedSchema = await File.ReadAllTextAsync(expectedSnapshotPath);
// Normalize both for comparison
var actualNormalized = NormalizeOpenApiSchema(actualSchema);
var expectedNormalized = NormalizeOpenApiSchema(expectedSchema);
actualNormalized.Should().Be(expectedNormalized,
"OpenAPI schema should match snapshot. Set STELLAOPS_UPDATE_FIXTURES=true to update.");
}
/// <summary>
/// Validates that the schema contains expected endpoints.
/// </summary>
public static async Task ValidateEndpointsExistAsync<TProgram>(
WebApplicationFactory<TProgram> factory,
IEnumerable<string> expectedEndpoints,
string swaggerEndpoint = "/swagger/v1/swagger.json")
where TProgram : class
{
using var client = factory.CreateClient();
var response = await client.GetAsync(swaggerEndpoint);
response.EnsureSuccessStatusCode();
var schemaJson = await response.Content.ReadAsStringAsync();
var schema = JsonDocument.Parse(schemaJson);
var paths = schema.RootElement.GetProperty("paths");
foreach (var endpoint in expectedEndpoints)
{
paths.TryGetProperty(endpoint, out _).Should().BeTrue(
$"Expected endpoint '{endpoint}' should exist in OpenAPI schema");
}
}
/// <summary>
/// Detects breaking changes between current schema and snapshot.
/// </summary>
public static async Task<SchemaBreakingChanges> DetectBreakingChangesAsync<TProgram>(
WebApplicationFactory<TProgram> factory,
string snapshotPath,
string swaggerEndpoint = "/swagger/v1/swagger.json")
where TProgram : class
{
using var client = factory.CreateClient();
var response = await client.GetAsync(swaggerEndpoint);
response.EnsureSuccessStatusCode();
var actualSchema = await response.Content.ReadAsStringAsync();
if (!File.Exists(snapshotPath))
{
return new SchemaBreakingChanges(new List<string> { "No previous snapshot exists" }, new List<string>());
}
var expectedSchema = await File.ReadAllTextAsync(snapshotPath);
return CompareSchemas(expectedSchema, actualSchema);
}
private static SchemaBreakingChanges CompareSchemas(string expected, string actual)
{
var breakingChanges = new List<string>();
var nonBreakingChanges = new List<string>();
try
{
var expectedDoc = JsonDocument.Parse(expected);
var actualDoc = JsonDocument.Parse(actual);
// Check for removed endpoints (breaking)
if (expectedDoc.RootElement.TryGetProperty("paths", out var expectedPaths) &&
actualDoc.RootElement.TryGetProperty("paths", out var actualPaths))
{
foreach (var path in expectedPaths.EnumerateObject())
{
if (!actualPaths.TryGetProperty(path.Name, out _))
{
breakingChanges.Add($"Endpoint removed: {path.Name}");
}
else
{
// Check for removed methods
foreach (var method in path.Value.EnumerateObject())
{
if (!actualPaths.GetProperty(path.Name).TryGetProperty(method.Name, out _))
{
breakingChanges.Add($"Method removed: {method.Name.ToUpper()} {path.Name}");
}
}
}
}
// Check for new endpoints (non-breaking)
foreach (var path in actualPaths.EnumerateObject())
{
if (!expectedPaths.TryGetProperty(path.Name, out _))
{
nonBreakingChanges.Add($"Endpoint added: {path.Name}");
}
}
}
// Check for removed schemas (breaking)
if (expectedDoc.RootElement.TryGetProperty("components", out var expectedComponents) &&
expectedComponents.TryGetProperty("schemas", out var expectedSchemas) &&
actualDoc.RootElement.TryGetProperty("components", out var actualComponents) &&
actualComponents.TryGetProperty("schemas", out var actualSchemas))
{
foreach (var schema in expectedSchemas.EnumerateObject())
{
if (!actualSchemas.TryGetProperty(schema.Name, out _))
{
breakingChanges.Add($"Schema removed: {schema.Name}");
}
}
}
}
catch (JsonException ex)
{
breakingChanges.Add($"Schema parse error: {ex.Message}");
}
return new SchemaBreakingChanges(breakingChanges, nonBreakingChanges);
}
private static string NormalizeOpenApiSchema(string schema)
{
try
{
var doc = JsonDocument.Parse(schema);
// Remove non-deterministic fields
return JsonSerializer.Serialize(doc, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
catch
{
return schema;
}
}
private static bool ShouldUpdateSnapshots()
{
return Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") == "true";
}
private static async Task UpdateSnapshotAsync(string path, string content)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
// Pretty-print for readability
var doc = JsonDocument.Parse(content);
var pretty = JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(path, pretty);
}
}
/// <summary>
/// Result of schema breaking change detection.
/// </summary>
public sealed record SchemaBreakingChanges(
IReadOnlyList<string> BreakingChanges,
IReadOnlyList<string> NonBreakingChanges)
{
public bool HasBreakingChanges => BreakingChanges.Count > 0;
}