using System.Net.Http.Headers; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Mvc.Testing; using Xunit; namespace StellaOps.Findings.Ledger.Tests.Integration; [Trait("Category", "Integration")] public sealed class FeatureVerificationProbeTests : IClassFixture { private readonly FindingsLedgerWebApplicationFactory _factory; public FeatureVerificationProbeTests(FindingsLedgerWebApplicationFactory factory) { _factory = factory; } [Fact(DisplayName = "Feature verification probe captures request/response evidence")] public async Task CaptureFeatureProbeEvidence() { var scenario = Environment.GetEnvironmentVariable("STELLA_FEATURE_PROBE_SCENARIO"); var outputPath = Environment.GetEnvironmentVariable("STELLA_FEATURE_PROBE_OUT"); // Keep this test no-op for normal test runs unless explicitly invoked for feature verification. if (string.IsNullOrWhiteSpace(scenario) || string.IsNullOrWhiteSpace(outputPath)) { return; } using var authedClient = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var anonClient = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); authedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token"); authedClient.DefaultRequestHeaders.Add("X-Scopes", "findings:read findings:write"); authedClient.DefaultRequestHeaders.Add("X-Tenant-Id", "11111111-1111-1111-1111-111111111111"); authedClient.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-qa"); var requests = BuildScenario(scenario); var capturedAt = DateTimeOffset.UtcNow; var results = new List(requests.Count); foreach (var req in requests) { var client = req.Auth ? authedClient : anonClient; using var message = new HttpRequestMessage(new HttpMethod(req.Method), req.Path); if (req.JsonBody is not null) { message.Content = new StringContent(req.JsonBody, Encoding.UTF8, "application/json"); } var requestAt = DateTimeOffset.UtcNow; using var response = await client.SendAsync(message); var body = await response.Content.ReadAsStringAsync(); var snippet = body.Length > 600 ? body[..600] : body; results.Add(new ProbeResult( req.Description, req.Method, req.Path, req.ExpectedStatus, (int)response.StatusCode, req.Assertion, (int)response.StatusCode == req.ExpectedStatus ? "pass" : "fail", requestAt.ToString("O"), snippet)); } var artifact = new ProbeArtifact( Type: "api", Module: "api", Feature: scenario, BaseUrl: "in-memory-testserver", CapturedAtUtc: capturedAt.ToString("O"), Requests: results, Verdict: results.All(r => string.Equals(r.Result, "pass", StringComparison.Ordinal)) ? "pass" : "fail"); var directory = Path.GetDirectoryName(outputPath); if (!string.IsNullOrWhiteSpace(directory)) { Directory.CreateDirectory(directory); } var json = JsonSerializer.Serialize(artifact, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(outputPath, json); Assert.All(results, r => Assert.Equal("pass", r.Result)); } private static IReadOnlyList BuildScenario(string scenario) { if (string.Equals(scenario, "policy-trace-panel", StringComparison.Ordinal)) { return [ new("List finding summaries with auth", "GET", "/api/v1/findings/summaries", true, 200, null, "authorized summaries query succeeds"), new("Invalid finding id returns bad request", "GET", "/api/v1/findings/not-a-guid/summary", true, 400, null, "invalid GUID is rejected"), new("Unknown finding summary returns not found", "GET", $"/api/v1/findings/{Guid.NewGuid():D}/summary", true, 404, null, "unknown finding returns 404"), new("Unknown finding evidence graph returns not found", "GET", $"/api/v1/findings/{Guid.NewGuid():D}/evidence-graph", true, 404, null, "unknown graph returns 404"), new("Unauthorized summaries request is rejected", "GET", "/api/v1/findings/summaries", false, 401, null, "missing token returns 401") ]; } if (string.Equals(scenario, "score-api-endpoints", StringComparison.Ordinal)) { return [ new("Scoring policy endpoint returns active policy", "GET", "/api/v1/scoring/policy", true, 200, null, "authorized policy read succeeds"), new("Batch scoring rejects empty list", "POST", "/api/v1/findings/scores", true, 400, "{\"findingIds\":[]}", "empty batch rejected"), new("Cached score unknown finding returns not found", "GET", "/api/v1/findings/CVE-9999-0000%40pkg%3Anpm%2Fnone%401.0.0/score", true, 404, null, "unknown score returns 404"), new("Scoring policy without auth is rejected", "GET", "/api/v1/scoring/policy", false, 401, null, "missing token returns 401") ]; } throw new InvalidOperationException($"Unknown feature probe scenario: {scenario}"); } private sealed record ProbeRequest( string Description, string Method, string Path, bool Auth, int ExpectedStatus, string? JsonBody, string Assertion); private sealed record ProbeResult( string Description, string Method, string Path, int ExpectedStatus, int ActualStatus, string Assertion, string Result, string RequestCapturedAtUtc, string ResponseSnippet); private sealed record ProbeArtifact( string Type, string Module, string Feature, string BaseUrl, string CapturedAtUtc, IReadOnlyList Requests, string Verdict); }