- 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.
326 lines
10 KiB
C#
326 lines
10 KiB
C#
using System.Net;
|
|
using FluentAssertions;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using StellaOps.TestKit.Extensions;
|
|
using StellaOps.TestKit.Observability;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.TestKit.Templates;
|
|
|
|
/// <summary>
|
|
/// Base class for web service contract tests.
|
|
/// Provides OpenAPI schema validation and standard test patterns.
|
|
/// </summary>
|
|
/// <typeparam name="TProgram">The program entry point class.</typeparam>
|
|
public abstract class WebServiceContractTestBase<TProgram> : IClassFixture<WebApplicationFactory<TProgram>>, IDisposable
|
|
where TProgram : class
|
|
{
|
|
protected readonly WebApplicationFactory<TProgram> Factory;
|
|
protected readonly HttpClient Client;
|
|
protected readonly OtelCapture OtelCapture;
|
|
private bool _disposed;
|
|
|
|
protected WebServiceContractTestBase(WebApplicationFactory<TProgram> factory)
|
|
{
|
|
Factory = factory;
|
|
Client = Factory.CreateClient();
|
|
OtelCapture = new OtelCapture();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the path to the OpenAPI schema snapshot.
|
|
/// </summary>
|
|
protected abstract string OpenApiSnapshotPath { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the Swagger endpoint path.
|
|
/// </summary>
|
|
protected virtual string SwaggerEndpoint => "/swagger/v1/swagger.json";
|
|
|
|
/// <summary>
|
|
/// Gets the expected endpoints that must exist.
|
|
/// </summary>
|
|
protected abstract IEnumerable<string> RequiredEndpoints { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the endpoints requiring authentication.
|
|
/// </summary>
|
|
protected abstract IEnumerable<string> AuthenticatedEndpoints { get; }
|
|
|
|
[Fact]
|
|
public virtual async Task OpenApiSchema_MatchesSnapshot()
|
|
{
|
|
await Fixtures.ContractTestHelper.ValidateOpenApiSchemaAsync(
|
|
Factory, OpenApiSnapshotPath, SwaggerEndpoint);
|
|
}
|
|
|
|
[Fact]
|
|
public virtual async Task OpenApiSchema_ContainsRequiredEndpoints()
|
|
{
|
|
await Fixtures.ContractTestHelper.ValidateEndpointsExistAsync(
|
|
Factory, RequiredEndpoints, SwaggerEndpoint);
|
|
}
|
|
|
|
[Fact]
|
|
public virtual async Task OpenApiSchema_HasNoBreakingChanges()
|
|
{
|
|
var changes = await Fixtures.ContractTestHelper.DetectBreakingChangesAsync(
|
|
Factory, OpenApiSnapshotPath, SwaggerEndpoint);
|
|
|
|
changes.HasBreakingChanges.Should().BeFalse(
|
|
$"Breaking changes detected: {string.Join(", ", changes.BreakingChanges)}");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
OtelCapture.Dispose();
|
|
Client.Dispose();
|
|
_disposed = true;
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Base class for web service negative tests.
|
|
/// Tests malformed requests, oversized payloads, wrong methods, etc.
|
|
/// </summary>
|
|
/// <typeparam name="TProgram">The program entry point class.</typeparam>
|
|
public abstract class WebServiceNegativeTestBase<TProgram> : IClassFixture<WebApplicationFactory<TProgram>>, IDisposable
|
|
where TProgram : class
|
|
{
|
|
protected readonly WebApplicationFactory<TProgram> Factory;
|
|
protected readonly HttpClient Client;
|
|
private bool _disposed;
|
|
|
|
protected WebServiceNegativeTestBase(WebApplicationFactory<TProgram> factory)
|
|
{
|
|
Factory = factory;
|
|
Client = Factory.CreateClient();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets test cases for malformed content type (endpoint, expected status).
|
|
/// </summary>
|
|
protected abstract IEnumerable<(string Endpoint, HttpStatusCode ExpectedStatus)> MalformedContentTypeTestCases { get; }
|
|
|
|
/// <summary>
|
|
/// Gets test cases for oversized payloads.
|
|
/// </summary>
|
|
protected abstract IEnumerable<(string Endpoint, int PayloadSizeBytes)> OversizedPayloadTestCases { get; }
|
|
|
|
/// <summary>
|
|
/// Gets test cases for method mismatch.
|
|
/// </summary>
|
|
protected abstract IEnumerable<(string Endpoint, HttpMethod ExpectedMethod)> MethodMismatchTestCases { get; }
|
|
|
|
[Fact]
|
|
public virtual async Task MalformedContentType_Returns415()
|
|
{
|
|
foreach (var (endpoint, expectedStatus) in MalformedContentTypeTestCases)
|
|
{
|
|
var response = await Client.SendWithMalformedContentTypeAsync(
|
|
HttpMethod.Post, endpoint, "{}");
|
|
|
|
response.StatusCode.Should().Be(expectedStatus,
|
|
$"endpoint {endpoint} should return {expectedStatus} for malformed content type");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public virtual async Task OversizedPayload_Returns413()
|
|
{
|
|
foreach (var (endpoint, sizeBytes) in OversizedPayloadTestCases)
|
|
{
|
|
var response = await Client.SendOversizedPayloadAsync(endpoint, sizeBytes);
|
|
|
|
response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge,
|
|
$"endpoint {endpoint} should return 413 for oversized payload ({sizeBytes} bytes)");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public virtual async Task WrongHttpMethod_Returns405()
|
|
{
|
|
foreach (var (endpoint, expectedMethod) in MethodMismatchTestCases)
|
|
{
|
|
var response = await Client.SendWithWrongMethodAsync(endpoint, expectedMethod);
|
|
|
|
response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed,
|
|
$"endpoint {endpoint} should return 405 when called with wrong method");
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
Client.Dispose();
|
|
_disposed = true;
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Base class for web service auth/authz tests.
|
|
/// Tests deny-by-default, token expiry, tenant isolation.
|
|
/// </summary>
|
|
/// <typeparam name="TProgram">The program entry point class.</typeparam>
|
|
public abstract class WebServiceAuthTestBase<TProgram> : IClassFixture<WebApplicationFactory<TProgram>>, IDisposable
|
|
where TProgram : class
|
|
{
|
|
protected readonly WebApplicationFactory<TProgram> Factory;
|
|
private bool _disposed;
|
|
|
|
protected WebServiceAuthTestBase(WebApplicationFactory<TProgram> factory)
|
|
{
|
|
Factory = factory;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets endpoints that require authentication.
|
|
/// </summary>
|
|
protected abstract IEnumerable<string> ProtectedEndpoints { get; }
|
|
|
|
/// <summary>
|
|
/// Generates a valid token for the given tenant.
|
|
/// </summary>
|
|
protected abstract string GenerateValidToken(string tenantId);
|
|
|
|
/// <summary>
|
|
/// Generates an expired token.
|
|
/// </summary>
|
|
protected abstract string GenerateExpiredToken();
|
|
|
|
/// <summary>
|
|
/// Generates a token for a different tenant (for isolation tests).
|
|
/// </summary>
|
|
protected abstract string GenerateOtherTenantToken(string otherTenantId);
|
|
|
|
[Fact]
|
|
public virtual async Task ProtectedEndpoints_WithoutAuth_Returns401()
|
|
{
|
|
using var client = Factory.CreateClient();
|
|
|
|
foreach (var endpoint in ProtectedEndpoints)
|
|
{
|
|
var response = await client.SendWithoutAuthAsync(HttpMethod.Get, endpoint);
|
|
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
|
|
$"endpoint {endpoint} should require authentication");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public virtual async Task ProtectedEndpoints_WithExpiredToken_Returns401()
|
|
{
|
|
using var client = Factory.CreateClient();
|
|
var expiredToken = GenerateExpiredToken();
|
|
|
|
foreach (var endpoint in ProtectedEndpoints)
|
|
{
|
|
var response = await client.SendWithExpiredTokenAsync(endpoint, expiredToken);
|
|
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
|
|
$"endpoint {endpoint} should reject expired tokens");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public virtual async Task ProtectedEndpoints_WithValidToken_ReturnsSuccess()
|
|
{
|
|
using var client = Factory.CreateClient();
|
|
var validToken = GenerateValidToken("test-tenant");
|
|
client.DefaultRequestHeaders.Authorization =
|
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", validToken);
|
|
|
|
foreach (var endpoint in ProtectedEndpoints)
|
|
{
|
|
var response = await client.GetAsync(endpoint);
|
|
|
|
response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized,
|
|
$"endpoint {endpoint} should accept valid tokens");
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Base class for web service OTel trace tests.
|
|
/// Validates that traces are emitted with required attributes.
|
|
/// </summary>
|
|
/// <typeparam name="TProgram">The program entry point class.</typeparam>
|
|
public abstract class WebServiceOtelTestBase<TProgram> : IClassFixture<WebApplicationFactory<TProgram>>, IDisposable
|
|
where TProgram : class
|
|
{
|
|
protected readonly WebApplicationFactory<TProgram> Factory;
|
|
protected readonly HttpClient Client;
|
|
protected readonly OtelCapture OtelCapture;
|
|
private bool _disposed;
|
|
|
|
protected WebServiceOtelTestBase(WebApplicationFactory<TProgram> factory)
|
|
{
|
|
Factory = factory;
|
|
Client = Factory.CreateClient();
|
|
OtelCapture = new OtelCapture();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets endpoints and their expected span names.
|
|
/// </summary>
|
|
protected abstract IEnumerable<(string Endpoint, string ExpectedSpanName)> TracedEndpoints { get; }
|
|
|
|
/// <summary>
|
|
/// Gets required trace attributes for all spans.
|
|
/// </summary>
|
|
protected abstract IEnumerable<string> RequiredTraceAttributes { get; }
|
|
|
|
[Fact]
|
|
public virtual async Task Endpoints_EmitTraces()
|
|
{
|
|
foreach (var (endpoint, expectedSpan) in TracedEndpoints)
|
|
{
|
|
var capture = new OtelCapture();
|
|
|
|
var response = await Client.GetAsync(endpoint);
|
|
|
|
capture.AssertHasSpan(expectedSpan);
|
|
capture.Dispose();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public virtual async Task Traces_ContainRequiredAttributes()
|
|
{
|
|
foreach (var (endpoint, _) in TracedEndpoints)
|
|
{
|
|
var capture = new OtelCapture();
|
|
|
|
await Client.GetAsync(endpoint);
|
|
|
|
foreach (var attr in RequiredTraceAttributes)
|
|
{
|
|
capture.CapturedActivities.Should().Contain(a =>
|
|
a.Tags.Any(t => t.Key == attr),
|
|
$"trace for {endpoint} should have attribute '{attr}'");
|
|
}
|
|
|
|
capture.Dispose();
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
OtelCapture.Dispose();
|
|
Client.Dispose();
|
|
_disposed = true;
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|