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