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,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);
}
}