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