148 lines
6.2 KiB
C#
148 lines
6.2 KiB
C#
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<FindingsLedgerWebApplicationFactory>
|
|
{
|
|
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<ProbeResult>(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<ProbeRequest> 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<ProbeResult> Requests,
|
|
string Verdict);
|
|
}
|
|
|