Files
git.stella-ops.org/src/__Libraries/StellaOps.TestKit/Templates/WebServiceTestBase.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

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);
}
}