// ----------------------------------------------------------------------------- // ScannerOpenApiContractTests.cs // Sprint: SPRINT_5100_0007_0006_webservice_contract // Task: WEBSVC-5100-007 // Description: OpenAPI schema contract tests for Scanner.WebService // ----------------------------------------------------------------------------- using FluentAssertions; using StellaOps.TestKit; using StellaOps.TestKit.Fixtures; using Xunit; namespace StellaOps.Scanner.WebService.Tests.Contract; /// /// Contract tests for Scanner.WebService OpenAPI schema. /// Validates that the API contract remains stable and detects breaking changes. /// [Trait("Category", TestCategories.Contract)] [Collection("ScannerWebService")] public sealed class ScannerOpenApiContractTests : IClassFixture { private readonly ScannerApplicationFactory _factory; private readonly string _snapshotPath; public ScannerOpenApiContractTests(ScannerApplicationFactory factory) { _factory = factory; _snapshotPath = Path.Combine(AppContext.BaseDirectory, "Contract", "Expected", "scanner-openapi.json"); } /// /// Validates that the OpenAPI schema matches the expected snapshot. /// [Fact(Skip = "OpenAPI/Swagger not enabled in test environment")] public async Task OpenApiSchema_MatchesSnapshot() { await ContractTestHelper.ValidateOpenApiSchemaAsync(_factory, _snapshotPath); } /// /// Validates that all core Scanner endpoints exist in the schema. /// [Fact(Skip = "OpenAPI/Swagger not enabled in test environment")] public async Task OpenApiSchema_ContainsCoreEndpoints() { // Note: Health endpoints are at root level (/healthz, /readyz), not under /api/v1 // SBOM endpoint is POST /api/v1/scans/{scanId}/sbom (not a standalone /api/v1/sbom) // Reports endpoint is POST /api/v1/reports (not GET) // Findings endpoints are under /api/v1/findings/{findingId}/evidence var coreEndpoints = new[] { "/api/v1/scans", "/api/v1/scans/{scanId}", "/api/v1/reports", "/api/v1/findings/{findingId}/evidence", "/healthz", "/readyz" }; await ContractTestHelper.ValidateEndpointsExistAsync(_factory, coreEndpoints); } /// /// Detects breaking changes in the OpenAPI schema. /// [Fact(Skip = "OpenAPI/Swagger not enabled in test environment")] public async Task OpenApiSchema_NoBreakingChanges() { var changes = await ContractTestHelper.DetectBreakingChangesAsync(_factory, _snapshotPath); if (changes.HasBreakingChanges) { var message = "Breaking API changes detected:\n" + string.Join("\n", changes.BreakingChanges.Select(c => $" - {c}")); Assert.Fail(message); } // Non-breaking changes are allowed in contract checks. } /// /// Validates that security schemes are defined in the schema. /// [Fact(Skip = "OpenAPI/Swagger not enabled in test environment")] public async Task OpenApiSchema_HasSecuritySchemes() { using var client = _factory.CreateClient(); var response = await client.GetAsync("/swagger/v1/swagger.json"); response.EnsureSuccessStatusCode(); var schemaJson = await response.Content.ReadAsStringAsync(); var schema = System.Text.Json.JsonDocument.Parse(schemaJson); // Check for security schemes (Bearer token expected) if (schema.RootElement.TryGetProperty("components", out var components) && components.TryGetProperty("securitySchemes", out var securitySchemes)) { securitySchemes.EnumerateObject().Should().NotBeEmpty( "OpenAPI schema should define security schemes"); } } /// /// Validates that error responses are documented in the schema. /// [Fact(Skip = "OpenAPI/Swagger not enabled in test environment")] public async Task OpenApiSchema_DocumentsErrorResponses() { using var client = _factory.CreateClient(); var response = await client.GetAsync("/swagger/v1/swagger.json"); response.EnsureSuccessStatusCode(); var schemaJson = await response.Content.ReadAsStringAsync(); var schema = System.Text.Json.JsonDocument.Parse(schemaJson); if (schema.RootElement.TryGetProperty("paths", out var paths)) { var hasErrorResponses = false; foreach (var path in paths.EnumerateObject()) { foreach (var method in path.Value.EnumerateObject()) { if (method.Value.TryGetProperty("responses", out var responses)) { // Check for 4xx or 5xx responses foreach (var resp in responses.EnumerateObject()) { if (resp.Name.StartsWith("4") || resp.Name.StartsWith("5")) { hasErrorResponses = true; break; } } } } if (hasErrorResponses) break; } hasErrorResponses.Should().BeTrue( "OpenAPI schema should document error responses (4xx/5xx)"); } } /// /// Validates schema determinism: multiple fetches produce identical output. /// [Fact(Skip = "OpenAPI/Swagger not enabled in test environment")] public async Task OpenApiSchema_IsDeterministic() { var schemas = new List(); for (int i = 0; i < 3; i++) { using var client = _factory.CreateClient(); var response = await client.GetAsync("/swagger/v1/swagger.json"); response.EnsureSuccessStatusCode(); schemas.Add(await response.Content.ReadAsStringAsync()); } schemas.Distinct().Should().HaveCount(1, "OpenAPI schema should be deterministic across fetches"); } }