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:
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user