feat(rate-limiting): Implement core rate limiting functionality with configuration, decision-making, metrics, middleware, and service registration

- Add RateLimitConfig for configuration management with YAML binding support.
- Introduce RateLimitDecision to encapsulate the result of rate limit checks.
- Implement RateLimitMetrics for OpenTelemetry metrics tracking.
- Create RateLimitMiddleware for enforcing rate limits on incoming requests.
- Develop RateLimitService to orchestrate instance and environment rate limit checks.
- Add RateLimitServiceCollectionExtensions for dependency injection registration.
This commit is contained in:
master
2025-12-17 18:02:37 +02:00
parent 394b57f6bf
commit 8bbfe4d2d2
211 changed files with 47179 additions and 1590 deletions

View File

@@ -0,0 +1,481 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_3600_0001_0001
// Task: TRI-MASTER-0007 - Performance benchmark suite (TTFS)
using System.Diagnostics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Loggers;
using BenchmarkDotNet.Running;
using FluentAssertions;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests.Benchmarks;
/// <summary>
/// TTFS (Time-To-First-Signal) performance benchmarks for triage workflows.
/// Measures the latency from request initiation to first meaningful evidence display.
///
/// Target KPIs (from Triage Advisory §3):
/// - TTFS p95 < 1.5s (with 100ms RTT, 1% loss)
/// - Clicks-to-Closure median < 6 clicks
/// - Evidence Completeness ≥ 90%
/// </summary>
[Config(typeof(TtfsBenchmarkConfig))]
[MemoryDiagnoser]
[RankColumn]
public class TtfsPerformanceBenchmarks
{
private MockAlertDataStore _alertStore = null!;
private MockEvidenceCache _evidenceCache = null!;
[GlobalSetup]
public void Setup()
{
_alertStore = new MockAlertDataStore(alertCount: 1000);
_evidenceCache = new MockEvidenceCache();
}
/// <summary>
/// Measures time to retrieve alert list (first page).
/// Target: < 200ms
/// </summary>
[Benchmark(Baseline = true)]
public AlertListResult GetAlertList_FirstPage()
{
return _alertStore.GetAlerts(page: 1, pageSize: 25);
}
/// <summary>
/// Measures time to retrieve minimal evidence bundle for a single alert.
/// Target: < 500ms (the main TTFS component)
/// </summary>
[Benchmark]
public EvidenceBundle GetAlertEvidence()
{
var alertId = _alertStore.GetRandomAlertId();
return _evidenceCache.GetEvidence(alertId);
}
/// <summary>
/// Measures time to retrieve alert detail with evidence pre-fetched.
/// Target: < 300ms
/// </summary>
[Benchmark]
public AlertWithEvidence GetAlertWithEvidence()
{
var alertId = _alertStore.GetRandomAlertId();
var alert = _alertStore.GetAlert(alertId);
var evidence = _evidenceCache.GetEvidence(alertId);
return new AlertWithEvidence(alert, evidence);
}
/// <summary>
/// Measures time to record a triage decision.
/// Target: < 100ms
/// </summary>
[Benchmark]
public DecisionResult RecordDecision()
{
var alertId = _alertStore.GetRandomAlertId();
return _alertStore.RecordDecision(alertId, new DecisionRequest
{
Status = "not_affected",
Justification = "vulnerable_code_not_in_execute_path",
ReasonText = "Code path analysis confirms non-reachability"
});
}
/// <summary>
/// Measures time to generate a replay token.
/// Target: < 50ms
/// </summary>
[Benchmark]
public ReplayToken GenerateReplayToken()
{
var alertId = _alertStore.GetRandomAlertId();
var evidence = _evidenceCache.GetEvidence(alertId);
return ReplayTokenGenerator.Generate(alertId, evidence);
}
/// <summary>
/// Measures full TTFS flow: list -> select -> evidence.
/// Target: < 1.5s total
/// </summary>
[Benchmark]
public AlertWithEvidence FullTtfsFlow()
{
// Step 1: Get alert list
var list = _alertStore.GetAlerts(page: 1, pageSize: 25);
// Step 2: Select first alert (simulated user click)
var alertId = list.Alerts[0].Id;
// Step 3: Load evidence
var alert = _alertStore.GetAlert(alertId);
var evidence = _evidenceCache.GetEvidence(alertId);
return new AlertWithEvidence(alert, evidence);
}
}
/// <summary>
/// Unit tests for TTFS performance thresholds.
/// These tests fail CI if benchmarks regress.
/// </summary>
public sealed class TtfsPerformanceTests
{
[Fact]
public void AlertList_ShouldLoadWithin200ms()
{
// Arrange
var store = new MockAlertDataStore(alertCount: 1000);
// Act
var sw = Stopwatch.StartNew();
var result = store.GetAlerts(page: 1, pageSize: 25);
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(200,
"Alert list should load within 200ms");
result.Alerts.Count.Should().Be(25);
}
[Fact]
public void EvidenceBundle_ShouldLoadWithin500ms()
{
// Arrange
var cache = new MockEvidenceCache();
var alertId = Guid.NewGuid().ToString();
// Act
var sw = Stopwatch.StartNew();
var evidence = cache.GetEvidence(alertId);
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(500,
"Evidence bundle should load within 500ms");
evidence.Should().NotBeNull();
}
[Fact]
public void DecisionRecording_ShouldCompleteWithin100ms()
{
// Arrange
var store = new MockAlertDataStore(alertCount: 100);
var alertId = store.GetRandomAlertId();
// Act
var sw = Stopwatch.StartNew();
var result = store.RecordDecision(alertId, new DecisionRequest
{
Status = "not_affected",
Justification = "inline_mitigations_already_exist"
});
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(100,
"Decision recording should complete within 100ms");
result.Success.Should().BeTrue();
}
[Fact]
public void ReplayTokenGeneration_ShouldCompleteWithin50ms()
{
// Arrange
var cache = new MockEvidenceCache();
var alertId = Guid.NewGuid().ToString();
var evidence = cache.GetEvidence(alertId);
// Act
var sw = Stopwatch.StartNew();
var token = ReplayTokenGenerator.Generate(alertId, evidence);
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(50,
"Replay token generation should complete within 50ms");
token.Token.Should().NotBeNullOrEmpty();
}
[Fact]
public void FullTtfsFlow_ShouldCompleteWithin1500ms()
{
// Arrange
var store = new MockAlertDataStore(alertCount: 1000);
var cache = new MockEvidenceCache();
// Act - simulate full user flow
var sw = Stopwatch.StartNew();
// Step 1: Load list
var list = store.GetAlerts(page: 1, pageSize: 25);
// Step 2: Select alert
var alertId = list.Alerts[0].Id;
// Step 3: Load detail + evidence
var alert = store.GetAlert(alertId);
var evidence = cache.GetEvidence(alertId);
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(1500,
"Full TTFS flow should complete within 1.5s");
}
[Fact]
public void EvidenceCompleteness_ShouldMeetThreshold()
{
// Arrange
var cache = new MockEvidenceCache();
var alertId = Guid.NewGuid().ToString();
// Act
var evidence = cache.GetEvidence(alertId);
var completeness = CalculateEvidenceCompleteness(evidence);
// Assert
completeness.Should().BeGreaterOrEqualTo(0.90,
"Evidence completeness should be >= 90%");
}
private static double CalculateEvidenceCompleteness(EvidenceBundle bundle)
{
var fields = new[]
{
bundle.Reachability != null,
bundle.CallStack != null,
bundle.Provenance != null,
bundle.VexStatus != null,
bundle.GraphRevision != null
};
return (double)fields.Count(f => f) / fields.Length;
}
}
#region Benchmark Config
public sealed class TtfsBenchmarkConfig : ManualConfig
{
public TtfsBenchmarkConfig()
{
AddJob(Job.ShortRun
.WithWarmupCount(3)
.WithIterationCount(5));
AddLogger(ConsoleLogger.Default);
AddColumnProvider(DefaultColumnProviders.Instance);
}
}
#endregion
#region Mock Implementations
public sealed class MockAlertDataStore
{
private readonly List<Alert> _alerts;
private readonly Random _random = new(42);
public MockAlertDataStore(int alertCount)
{
_alerts = Enumerable.Range(0, alertCount)
.Select(i => new Alert
{
Id = Guid.NewGuid().ToString(),
CveId = $"CVE-2024-{10000 + i}",
Severity = _random.Next(0, 4) switch { 0 => "LOW", 1 => "MEDIUM", 2 => "HIGH", _ => "CRITICAL" },
Status = "open",
CreatedAt = DateTime.UtcNow.AddDays(-_random.Next(1, 30))
})
.ToList();
}
public string GetRandomAlertId() => _alerts[_random.Next(_alerts.Count)].Id;
public AlertListResult GetAlerts(int page, int pageSize)
{
// Simulate DB query latency
Thread.Sleep(5);
var skip = (page - 1) * pageSize;
return new AlertListResult
{
Alerts = _alerts.Skip(skip).Take(pageSize).ToList(),
TotalCount = _alerts.Count,
Page = page,
PageSize = pageSize
};
}
public Alert GetAlert(string id)
{
Thread.Sleep(2);
return _alerts.First(a => a.Id == id);
}
public DecisionResult RecordDecision(string alertId, DecisionRequest request)
{
Thread.Sleep(3);
return new DecisionResult { Success = true, DecisionId = Guid.NewGuid().ToString() };
}
}
public sealed class MockEvidenceCache
{
public EvidenceBundle GetEvidence(string alertId)
{
// Simulate evidence retrieval latency
Thread.Sleep(10);
return new EvidenceBundle
{
AlertId = alertId,
Reachability = new ReachabilityEvidence
{
IsReachable = true,
Tier = "executed",
CallPath = new[] { "main", "process", "vulnerable_func" }
},
CallStack = new CallStackEvidence
{
Frames = new[] { "app.dll!Main", "lib.dll!Process", "vulnerable.dll!Sink" }
},
Provenance = new ProvenanceEvidence
{
Digest = "sha256:abc123",
Registry = "ghcr.io/stellaops"
},
VexStatus = new VexStatusEvidence
{
Status = "under_investigation",
LastUpdated = DateTime.UtcNow.AddDays(-2)
},
GraphRevision = new GraphRevisionEvidence
{
Revision = "graph-v1.2.3",
NodeCount = 1500,
EdgeCount = 3200
}
};
}
}
public static class ReplayTokenGenerator
{
public static ReplayToken Generate(string alertId, EvidenceBundle evidence)
{
// Simulate token generation
var hash = $"{alertId}:{evidence.Reachability?.Tier}:{evidence.VexStatus?.Status}".GetHashCode();
return new ReplayToken
{
Token = $"replay_{Math.Abs(hash):x8}",
AlertId = alertId,
GeneratedAt = DateTime.UtcNow
};
}
}
#endregion
#region Models
public sealed class Alert
{
public string Id { get; set; } = "";
public string CveId { get; set; } = "";
public string Severity { get; set; } = "";
public string Status { get; set; } = "";
public DateTime CreatedAt { get; set; }
}
public sealed class AlertListResult
{
public List<Alert> Alerts { get; set; } = new();
public int TotalCount { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
public sealed class EvidenceBundle
{
public string AlertId { get; set; } = "";
public ReachabilityEvidence? Reachability { get; set; }
public CallStackEvidence? CallStack { get; set; }
public ProvenanceEvidence? Provenance { get; set; }
public VexStatusEvidence? VexStatus { get; set; }
public GraphRevisionEvidence? GraphRevision { get; set; }
}
public sealed class ReachabilityEvidence
{
public bool IsReachable { get; set; }
public string Tier { get; set; } = "";
public string[] CallPath { get; set; } = Array.Empty<string>();
}
public sealed class CallStackEvidence
{
public string[] Frames { get; set; } = Array.Empty<string>();
}
public sealed class ProvenanceEvidence
{
public string Digest { get; set; } = "";
public string Registry { get; set; } = "";
}
public sealed class VexStatusEvidence
{
public string Status { get; set; } = "";
public DateTime LastUpdated { get; set; }
}
public sealed class GraphRevisionEvidence
{
public string Revision { get; set; } = "";
public int NodeCount { get; set; }
public int EdgeCount { get; set; }
}
public sealed class AlertWithEvidence
{
public Alert Alert { get; }
public EvidenceBundle Evidence { get; }
public AlertWithEvidence(Alert alert, EvidenceBundle evidence)
{
Alert = alert;
Evidence = evidence;
}
}
public sealed class DecisionRequest
{
public string Status { get; set; } = "";
public string? Justification { get; set; }
public string? ReasonText { get; set; }
}
public sealed class DecisionResult
{
public bool Success { get; set; }
public string DecisionId { get; set; } = "";
}
public sealed class ReplayToken
{
public string Token { get; set; } = "";
public string AlertId { get; set; } = "";
public DateTime GeneratedAt { get; set; }
}
#endregion

View File

@@ -0,0 +1,431 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_3600_0001_0001
// Task: TRI-MASTER-0002 - Integration test suite for triage flow
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests.Integration;
/// <summary>
/// End-to-end integration tests for the Triage workflow.
/// Tests the complete flow from alert list to decision recording.
/// </summary>
public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplicationFactory>
{
private readonly HttpClient _client;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public TriageWorkflowIntegrationTests(ScannerApplicationFactory factory)
{
_client = factory.CreateClient();
}
#region Alert List Tests
[Fact]
public async Task GetAlerts_ReturnsOk_WithPagination()
{
// Arrange
var request = "/api/v1/alerts?page=1&pageSize=25";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetAlerts_SupportsBandFilter()
{
// Arrange - filter by HOT band (high priority)
var request = "/api/v1/alerts?band=HOT&page=1&pageSize=25";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetAlerts_SupportsSeverityFilter()
{
// Arrange
var request = "/api/v1/alerts?severity=CRITICAL,HIGH&page=1";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetAlerts_SupportsStatusFilter()
{
// Arrange
var request = "/api/v1/alerts?status=open&page=1";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetAlerts_SupportsSortByScore()
{
// Arrange
var request = "/api/v1/alerts?sortBy=score&sortOrder=desc&page=1";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
#endregion
#region Alert Detail Tests
[Fact]
public async Task GetAlertById_ReturnsNotFound_WhenAlertDoesNotExist()
{
// Arrange
var request = "/api/v1/alerts/alert-nonexistent-12345";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
#endregion
#region Evidence Tests
[Fact]
public async Task GetAlertEvidence_ReturnsNotFound_WhenAlertDoesNotExist()
{
// Arrange
var request = "/api/v1/alerts/alert-nonexistent-12345/evidence";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetAlertEvidence_SupportsMinimalFormat()
{
// Arrange - request minimal evidence bundle
var request = "/api/v1/alerts/alert-12345/evidence?format=minimal";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetAlertEvidence_SupportsFullFormat()
{
// Arrange - request full evidence bundle with graph
var request = "/api/v1/alerts/alert-12345/evidence?format=full";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
#endregion
#region Decision Recording Tests
[Fact]
public async Task RecordDecision_ReturnsNotFound_WhenAlertDoesNotExist()
{
// Arrange
var request = "/api/v1/alerts/alert-nonexistent-12345/decisions";
var decision = new
{
status = "not_affected",
justification = "vulnerable_code_not_in_execute_path",
reasonText = "Code path analysis confirms non-reachability"
};
// Act
var response = await _client.PostAsJsonAsync(request, decision);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task RecordDecision_ValidatesStatus()
{
// Arrange - invalid status
var request = "/api/v1/alerts/alert-12345/decisions";
var decision = new
{
status = "invalid_status",
justification = "some_justification"
};
// Act
var response = await _client.PostAsJsonAsync(request, decision);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.NotFound,
HttpStatusCode.UnprocessableEntity);
}
[Fact]
public async Task RecordDecision_RequiresJustificationForNotAffected()
{
// Arrange - not_affected without justification
var request = "/api/v1/alerts/alert-12345/decisions";
var decision = new
{
status = "not_affected"
// Missing justification
};
// Act
var response = await _client.PostAsJsonAsync(request, decision);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.NotFound,
HttpStatusCode.UnprocessableEntity);
}
#endregion
#region Audit Trail Tests
[Fact]
public async Task GetAlertAudit_ReturnsNotFound_WhenAlertDoesNotExist()
{
// Arrange
var request = "/api/v1/alerts/alert-nonexistent-12345/audit";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetAlertAudit_SupportsPagination()
{
// Arrange
var request = "/api/v1/alerts/alert-12345/audit?page=1&pageSize=50";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
#endregion
#region Replay Token Tests
[Fact]
public async Task GetReplayToken_ReturnsNotFound_WhenAlertDoesNotExist()
{
// Arrange
var request = "/api/v1/alerts/alert-nonexistent-12345/replay-token";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task VerifyReplayToken_ReturnsNotFound_WhenTokenInvalid()
{
// Arrange
var request = "/api/v1/replay/verify";
var verifyRequest = new { token = "invalid-token-12345" };
// Act
var response = await _client.PostAsJsonAsync(request, verifyRequest);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.NotFound,
HttpStatusCode.UnprocessableEntity);
}
#endregion
#region Offline Bundle Tests
[Fact]
public async Task DownloadBundle_ReturnsNotFound_WhenAlertDoesNotExist()
{
// Arrange
var request = "/api/v1/alerts/alert-nonexistent-12345/bundle";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task VerifyBundle_EndpointExists()
{
// Arrange
var request = "/api/v1/bundles/verify";
var bundleData = new { bundleId = "bundle-12345" };
// Act
var response = await _client.PostAsJsonAsync(request, bundleData);
// Assert
response.StatusCode.Should().BeOneOf(
HttpStatusCode.OK,
HttpStatusCode.BadRequest,
HttpStatusCode.NotFound);
}
#endregion
#region Diff Tests
[Fact]
public async Task GetAlertDiff_ReturnsNotFound_WhenAlertDoesNotExist()
{
// Arrange
var request = "/api/v1/alerts/alert-nonexistent-12345/diff";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetAlertDiff_SupportsBaselineParameter()
{
// Arrange - diff against specific baseline
var request = "/api/v1/alerts/alert-12345/diff?baseline=scan-001";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
#endregion
}
/// <summary>
/// Tests for triage workflow state machine.
/// </summary>
public sealed class TriageStateMachineTests
{
[Theory]
[InlineData("open", "not_affected", true)]
[InlineData("open", "affected", true)]
[InlineData("open", "under_investigation", true)]
[InlineData("open", "fixed", true)]
[InlineData("not_affected", "open", true)] // Can reopen
[InlineData("fixed", "open", true)] // Can reopen
[InlineData("affected", "fixed", true)]
[InlineData("under_investigation", "not_affected", true)]
public void TriageStatus_TransitionIsValid(string from, string to, bool expectedValid)
{
// Act
var isValid = TriageStateMachine.IsValidTransition(from, to);
// Assert
isValid.Should().Be(expectedValid);
}
[Theory]
[InlineData("not_affected", "vulnerable_code_not_in_execute_path")]
[InlineData("not_affected", "vulnerable_code_cannot_be_controlled_by_adversary")]
[InlineData("not_affected", "inline_mitigations_already_exist")]
public void NotAffectedJustification_MustBeValid(string status, string justification)
{
// Act
var isValid = TriageStateMachine.IsValidJustification(status, justification);
// Assert
isValid.Should().BeTrue();
}
}
/// <summary>
/// Triage workflow state machine validation.
/// </summary>
public static class TriageStateMachine
{
private static readonly HashSet<string> ValidStatuses = new(StringComparer.OrdinalIgnoreCase)
{
"open",
"under_investigation",
"affected",
"not_affected",
"fixed"
};
private static readonly HashSet<string> ValidJustifications = new(StringComparer.OrdinalIgnoreCase)
{
"component_not_present",
"vulnerable_code_not_present",
"vulnerable_code_not_in_execute_path",
"vulnerable_code_cannot_be_controlled_by_adversary",
"inline_mitigations_already_exist"
};
public static bool IsValidTransition(string from, string to)
{
if (!ValidStatuses.Contains(from) || !ValidStatuses.Contains(to))
return false;
// All transitions are valid in this simple model
// A more complex implementation might restrict certain paths
return true;
}
public static bool IsValidJustification(string status, string justification)
{
if (!string.Equals(status, "not_affected", StringComparison.OrdinalIgnoreCase))
return true; // Justification only required for not_affected
return ValidJustifications.Contains(justification);
}
}

View File

@@ -0,0 +1,329 @@
// =============================================================================
// ScoreReplayEndpointsTests.cs
// Sprint: SPRINT_3401_0002_0001_score_replay_proof_bundle
// Task: SCORE-REPLAY-013 - Integration tests for score replay endpoint
// =============================================================================
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Integration tests for score replay endpoints.
/// Per Sprint 3401.0002.0001 - Score Replay & Proof Bundle.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Sprint", "3401.0002")]
public sealed class ScoreReplayEndpointsTests : IDisposable
{
private readonly TestSurfaceSecretsScope _secrets;
private readonly ScannerApplicationFactory _factory;
private readonly HttpClient _client;
public ScoreReplayEndpointsTests()
{
_secrets = new TestSurfaceSecretsScope();
_factory = new ScannerApplicationFactory(cfg =>
{
cfg["scanner:authority:enabled"] = "false";
cfg["scanner:scoreReplay:enabled"] = "true";
});
_client = _factory.CreateClient();
}
public void Dispose()
{
_client.Dispose();
_factory.Dispose();
_secrets.Dispose();
}
#region POST /score/{scanId}/replay Tests
[Fact(DisplayName = "POST /score/{scanId}/replay returns 404 for unknown scan")]
public async Task ReplayScore_UnknownScan_Returns404()
{
// Arrange
var unknownScanId = Guid.NewGuid().ToString();
// Act
var response = await _client.PostAsync($"/api/v1/score/{unknownScanId}/replay", null);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact(DisplayName = "POST /score/{scanId}/replay returns result for valid scan")]
public async Task ReplayScore_ValidScan_ReturnsResult()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Act
var response = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ScoreReplayResponse>();
result.Should().NotBeNull();
result!.Score.Should().BeInRange(0.0, 1.0);
result.RootHash.Should().StartWith("sha256:");
result.BundleUri.Should().NotBeNullOrEmpty();
result.Deterministic.Should().BeTrue();
}
[Fact(DisplayName = "POST /score/{scanId}/replay is deterministic")]
public async Task ReplayScore_IsDeterministic()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Act - replay twice
var response1 = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
var response2 = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
// Assert
response1.StatusCode.Should().Be(HttpStatusCode.OK);
response2.StatusCode.Should().Be(HttpStatusCode.OK);
var result1 = await response1.Content.ReadFromJsonAsync<ScoreReplayResponse>();
var result2 = await response2.Content.ReadFromJsonAsync<ScoreReplayResponse>();
result1!.Score.Should().Be(result2!.Score, "Score should be deterministic");
result1.RootHash.Should().Be(result2.RootHash, "RootHash should be deterministic");
}
[Fact(DisplayName = "POST /score/{scanId}/replay with specific manifest hash")]
public async Task ReplayScore_WithManifestHash_UsesSpecificManifest()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Get the manifest hash from the first replay
var firstResponse = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
var firstResult = await firstResponse.Content.ReadFromJsonAsync<ScoreReplayResponse>();
var manifestHash = firstResult!.ManifestHash;
// Act - replay with specific manifest hash
var response = await _client.PostAsJsonAsync(
$"/api/v1/score/{scanId}/replay",
new { manifestHash });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ScoreReplayResponse>();
result!.ManifestHash.Should().Be(manifestHash);
}
#endregion
#region GET /score/{scanId}/bundle Tests
[Fact(DisplayName = "GET /score/{scanId}/bundle returns 404 for unknown scan")]
public async Task GetBundle_UnknownScan_Returns404()
{
// Arrange
var unknownScanId = Guid.NewGuid().ToString();
// Act
var response = await _client.GetAsync($"/api/v1/score/{unknownScanId}/bundle");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact(DisplayName = "GET /score/{scanId}/bundle returns bundle after replay")]
public async Task GetBundle_AfterReplay_ReturnsBundle()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Create a replay first
var replayResponse = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
replayResponse.EnsureSuccessStatusCode();
var replayResult = await replayResponse.Content.ReadFromJsonAsync<ScoreReplayResponse>();
// Act
var response = await _client.GetAsync($"/api/v1/score/{scanId}/bundle");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var bundle = await response.Content.ReadFromJsonAsync<ProofBundleResponse>();
bundle.Should().NotBeNull();
bundle!.RootHash.Should().Be(replayResult!.RootHash);
bundle.ManifestDsseValid.Should().BeTrue();
}
[Fact(DisplayName = "GET /score/{scanId}/bundle with specific rootHash")]
public async Task GetBundle_WithRootHash_ReturnsSpecificBundle()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Create a replay to get a root hash
var replayResponse = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
var replayResult = await replayResponse.Content.ReadFromJsonAsync<ScoreReplayResponse>();
var rootHash = replayResult!.RootHash;
// Act
var response = await _client.GetAsync($"/api/v1/score/{scanId}/bundle?rootHash={rootHash}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var bundle = await response.Content.ReadFromJsonAsync<ProofBundleResponse>();
bundle!.RootHash.Should().Be(rootHash);
}
#endregion
#region POST /score/{scanId}/verify Tests
[Fact(DisplayName = "POST /score/{scanId}/verify returns valid for correct root hash")]
public async Task VerifyBundle_CorrectRootHash_ReturnsValid()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Create a replay
var replayResponse = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
var replayResult = await replayResponse.Content.ReadFromJsonAsync<ScoreReplayResponse>();
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/score/{scanId}/verify",
new { expectedRootHash = replayResult!.RootHash });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<BundleVerifyResponse>();
result!.Valid.Should().BeTrue();
result.ComputedRootHash.Should().Be(replayResult.RootHash);
}
[Fact(DisplayName = "POST /score/{scanId}/verify returns invalid for wrong root hash")]
public async Task VerifyBundle_WrongRootHash_ReturnsInvalid()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Create a replay first
await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/score/{scanId}/verify",
new { expectedRootHash = "sha256:wrong_hash_value" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<BundleVerifyResponse>();
result!.Valid.Should().BeFalse();
}
[Fact(DisplayName = "POST /score/{scanId}/verify validates manifest signature")]
public async Task VerifyBundle_ValidatesManifestSignature()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Create a replay
var replayResponse = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
var replayResult = await replayResponse.Content.ReadFromJsonAsync<ScoreReplayResponse>();
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/score/{scanId}/verify",
new { expectedRootHash = replayResult!.RootHash });
// Assert
var result = await response.Content.ReadFromJsonAsync<BundleVerifyResponse>();
result!.ManifestValid.Should().BeTrue();
}
#endregion
#region Concurrency Tests
[Fact(DisplayName = "Concurrent replays produce same result")]
public async Task ConcurrentReplays_ProduceSameResult()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Act - concurrent replays
var tasks = Enumerable.Range(0, 5)
.Select(_ => _client.PostAsync($"/api/v1/score/{scanId}/replay", null))
.ToList();
var responses = await Task.WhenAll(tasks);
// Assert
var results = new List<ScoreReplayResponse>();
foreach (var response in responses)
{
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ScoreReplayResponse>();
results.Add(result!);
}
// All results should have the same score and root hash
var firstResult = results[0];
foreach (var result in results.Skip(1))
{
result.Score.Should().Be(firstResult.Score);
result.RootHash.Should().Be(firstResult.RootHash);
}
}
#endregion
#region Helper Methods
private async Task<string> CreateTestScanAsync()
{
var submitResponse = await _client.PostAsJsonAsync("/api/v1/scans", new
{
image = new { digest = "sha256:test_" + Guid.NewGuid().ToString("N")[..8] }
});
submitResponse.EnsureSuccessStatusCode();
var submitPayload = await submitResponse.Content.ReadFromJsonAsync<ScanSubmitResponse>();
return submitPayload!.ScanId;
}
#endregion
#region Response Models
private sealed record ScoreReplayResponse(
double Score,
string RootHash,
string BundleUri,
string ManifestHash,
DateTimeOffset ReplayedAt,
bool Deterministic);
private sealed record ProofBundleResponse(
string ScanId,
string RootHash,
string BundleUri,
bool ManifestDsseValid,
DateTimeOffset CreatedAt);
private sealed record BundleVerifyResponse(
bool Valid,
string ComputedRootHash,
bool ManifestValid,
string? ErrorMessage);
private sealed record ScanSubmitResponse(string ScanId);
#endregion
}

View File

@@ -0,0 +1,295 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_3600_0002_0001
// Task: UNK-RANK-010 - Integration tests for unknowns API
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Integration tests for the Unknowns API endpoints.
/// </summary>
public sealed class UnknownsEndpointsTests : IClassFixture<ScannerApplicationFactory>
{
private readonly HttpClient _client;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public UnknownsEndpointsTests(ScannerApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetUnknowns_ReturnsOk_WhenValidRequest()
{
// Arrange
var request = "/api/v1/unknowns?limit=10";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetUnknowns_SupportsPagination()
{
// Arrange
var request = "/api/v1/unknowns?limit=5&offset=0";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetUnknowns_SupportsBandFilter()
{
// Arrange - filter by HOT band
var request = "/api/v1/unknowns?band=HOT&limit=10";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetUnknowns_SupportsSortByScore()
{
// Arrange
var request = "/api/v1/unknowns?sortBy=score&sortOrder=desc&limit=10";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetUnknowns_SupportsSortByLastSeen()
{
// Arrange
var request = "/api/v1/unknowns?sortBy=lastSeen&sortOrder=desc&limit=10";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetUnknownById_ReturnsNotFound_WhenUnknownDoesNotExist()
{
// Arrange
var request = "/api/v1/unknowns/unk-nonexistent-12345";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetUnknownEvidence_ReturnsNotFound_WhenUnknownDoesNotExist()
{
// Arrange
var request = "/api/v1/unknowns/unk-nonexistent-12345/evidence";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetUnknownHistory_ReturnsNotFound_WhenUnknownDoesNotExist()
{
// Arrange
var request = "/api/v1/unknowns/unk-nonexistent-12345/history";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetUnknownsStats_ReturnsOk()
{
// Arrange
var request = "/api/v1/unknowns/stats";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetUnknownsBandDistribution_ReturnsOk()
{
// Arrange
var request = "/api/v1/unknowns/bands";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetUnknowns_BadRequest_WhenInvalidBand()
{
// Arrange
var request = "/api/v1/unknowns?band=INVALID&limit=10";
// Act
var response = await _client.GetAsync(request);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.OK, HttpStatusCode.NotFound);
}
[Fact]
public async Task GetUnknowns_BadRequest_WhenLimitTooLarge()
{
// Arrange
var request = "/api/v1/unknowns?limit=10000";
// Act
var response = await _client.GetAsync(request);
// Assert
// Should either reject or cap at max
response.StatusCode.Should().BeOneOf(HttpStatusCode.BadRequest, HttpStatusCode.OK, HttpStatusCode.NotFound);
}
}
/// <summary>
/// Tests for unknowns scoring algorithm.
/// </summary>
public sealed class UnknownsScoringTests
{
[Theory]
[InlineData(0.9, 0.8, 0.7, 0.6, 0.5, 0.7)] // High score expected
[InlineData(0.1, 0.2, 0.3, 0.2, 0.1, 0.18)] // Low score expected
public void ComputeScore_ShouldWeightFactors(
double epss, double cvss, double reachability, double freshness, double frequency,
double expectedScore)
{
// Arrange
var factors = new UnknownScoringFactors
{
EpssScore = epss,
CvssNormalized = cvss,
ReachabilityScore = reachability,
FreshnessScore = freshness,
FrequencyScore = frequency
};
// Act
var score = UnknownsScorer.ComputeScore(factors);
// Assert
score.Should().BeApproximately(expectedScore, 0.1);
}
[Theory]
[InlineData(0.75, "HOT")]
[InlineData(0.50, "WARM")]
[InlineData(0.25, "COLD")]
public void AssignBand_ShouldMapScoreToBand(double score, string expectedBand)
{
// Act
var band = UnknownsScorer.AssignBand(score);
// Assert
band.Should().Be(expectedBand);
}
[Fact]
public void DecayScore_ShouldReduceOverTime()
{
// Arrange
var initialScore = 0.8;
var daysSinceLastSeen = 7;
var decayRate = 0.05; // 5% per day
// Act
var decayedScore = UnknownsScorer.ApplyDecay(initialScore, daysSinceLastSeen, decayRate);
// Assert
decayedScore.Should().BeLessThan(initialScore);
decayedScore.Should().BeGreaterThan(0);
}
}
/// <summary>
/// Scoring factors for unknowns ranking.
/// </summary>
public record UnknownScoringFactors
{
public double EpssScore { get; init; }
public double CvssNormalized { get; init; }
public double ReachabilityScore { get; init; }
public double FreshnessScore { get; init; }
public double FrequencyScore { get; init; }
}
/// <summary>
/// Unknowns scoring algorithm.
/// </summary>
public static class UnknownsScorer
{
// Weights for 5-factor scoring model
private const double EpssWeight = 0.25;
private const double CvssWeight = 0.20;
private const double ReachabilityWeight = 0.25;
private const double FreshnessWeight = 0.15;
private const double FrequencyWeight = 0.15;
public static double ComputeScore(UnknownScoringFactors factors)
{
return (factors.EpssScore * EpssWeight) +
(factors.CvssNormalized * CvssWeight) +
(factors.ReachabilityScore * ReachabilityWeight) +
(factors.FreshnessScore * FreshnessWeight) +
(factors.FrequencyScore * FrequencyWeight);
}
public static string AssignBand(double score)
{
return score switch
{
>= 0.7 => "HOT",
>= 0.4 => "WARM",
_ => "COLD"
};
}
public static double ApplyDecay(double score, int daysSinceLastSeen, double decayRate)
{
var decayFactor = Math.Pow(1 - decayRate, daysSinceLastSeen);
return score * decayFactor;
}
}