using System.Net;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.TestKit.Extensions;
using StellaOps.TestKit.Observability;
using Xunit;
namespace StellaOps.TestKit.Templates;
///
/// Base class for web service contract tests.
/// Provides OpenAPI schema validation and standard test patterns.
///
/// The program entry point class.
public abstract class WebServiceContractTestBase : IClassFixture>, IDisposable
where TProgram : class
{
protected readonly WebApplicationFactory Factory;
protected readonly HttpClient Client;
protected readonly OtelCapture OtelCapture;
private bool _disposed;
protected WebServiceContractTestBase(WebApplicationFactory factory)
{
Factory = factory;
Client = Factory.CreateClient();
OtelCapture = new OtelCapture();
}
///
/// Gets the path to the OpenAPI schema snapshot.
///
protected abstract string OpenApiSnapshotPath { get; }
///
/// Gets the Swagger endpoint path.
///
protected virtual string SwaggerEndpoint => "/swagger/v1/swagger.json";
///
/// Gets the expected endpoints that must exist.
///
protected abstract IEnumerable RequiredEndpoints { get; }
///
/// Gets the endpoints requiring authentication.
///
protected abstract IEnumerable 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);
}
}
///
/// Base class for web service negative tests.
/// Tests malformed requests, oversized payloads, wrong methods, etc.
///
/// The program entry point class.
public abstract class WebServiceNegativeTestBase : IClassFixture>, IDisposable
where TProgram : class
{
protected readonly WebApplicationFactory Factory;
protected readonly HttpClient Client;
private bool _disposed;
protected WebServiceNegativeTestBase(WebApplicationFactory factory)
{
Factory = factory;
Client = Factory.CreateClient();
}
///
/// Gets test cases for malformed content type (endpoint, expected status).
///
protected abstract IEnumerable<(string Endpoint, HttpStatusCode ExpectedStatus)> MalformedContentTypeTestCases { get; }
///
/// Gets test cases for oversized payloads.
///
protected abstract IEnumerable<(string Endpoint, int PayloadSizeBytes)> OversizedPayloadTestCases { get; }
///
/// Gets test cases for method mismatch.
///
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);
}
}
///
/// Base class for web service auth/authz tests.
/// Tests deny-by-default, token expiry, tenant isolation.
///
/// The program entry point class.
public abstract class WebServiceAuthTestBase : IClassFixture>, IDisposable
where TProgram : class
{
protected readonly WebApplicationFactory Factory;
private bool _disposed;
protected WebServiceAuthTestBase(WebApplicationFactory factory)
{
Factory = factory;
}
///
/// Gets endpoints that require authentication.
///
protected abstract IEnumerable ProtectedEndpoints { get; }
///
/// Generates a valid token for the given tenant.
///
protected abstract string GenerateValidToken(string tenantId);
///
/// Generates an expired token.
///
protected abstract string GenerateExpiredToken();
///
/// Generates a token for a different tenant (for isolation tests).
///
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);
}
}
///
/// Base class for web service OTel trace tests.
/// Validates that traces are emitted with required attributes.
///
/// The program entry point class.
public abstract class WebServiceOtelTestBase : IClassFixture>, IDisposable
where TProgram : class
{
protected readonly WebApplicationFactory Factory;
protected readonly HttpClient Client;
protected readonly OtelCapture OtelCapture;
private bool _disposed;
protected WebServiceOtelTestBase(WebApplicationFactory factory)
{
Factory = factory;
Client = Factory.CreateClient();
OtelCapture = new OtelCapture();
}
///
/// Gets endpoints and their expected span names.
///
protected abstract IEnumerable<(string Endpoint, string ExpectedSpanName)> TracedEndpoints { get; }
///
/// Gets required trace attributes for all spans.
///
protected abstract IEnumerable 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);
}
}