feat: Add VEX Status Chip component and integration tests for reachability drift detection
- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips. - Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling. - Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation. - Updated project references to include the new Reachability Drift library.
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
// =============================================================================
|
||||
// ApprovalEndpointsTests.cs
|
||||
// Sprint: SPRINT_3801_0001_0005_approvals_api
|
||||
// Task: API-005 - Integration tests for approval endpoints
|
||||
// =============================================================================
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Sprint", "3801.0001")]
|
||||
public sealed class ApprovalEndpointsTests : IDisposable
|
||||
{
|
||||
private readonly TestSurfaceSecretsScope _secrets;
|
||||
private readonly ScannerApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ApprovalEndpointsTests()
|
||||
{
|
||||
_secrets = new TestSurfaceSecretsScope();
|
||||
|
||||
_factory = new ScannerApplicationFactory().WithOverrides(
|
||||
configureConfiguration: config => config["scanner:authority:enabled"] = "false");
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
_factory.Dispose();
|
||||
_secrets.Dispose();
|
||||
}
|
||||
|
||||
#region POST /approvals Tests
|
||||
|
||||
[Fact(DisplayName = "POST /approvals creates approval successfully")]
|
||||
public async Task CreateApproval_ValidRequest_Returns201()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var request = new
|
||||
{
|
||||
finding_id = "CVE-2024-12345",
|
||||
decision = "AcceptRisk",
|
||||
justification = "Risk accepted for testing purposes"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
|
||||
Assert.NotNull(approval);
|
||||
Assert.Equal("CVE-2024-12345", approval!.FindingId);
|
||||
Assert.Equal("AcceptRisk", approval.Decision);
|
||||
Assert.NotNull(approval.AttestationId);
|
||||
Assert.True(approval.AttestationId.StartsWith("sha256:"));
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /approvals rejects empty finding_id")]
|
||||
public async Task CreateApproval_EmptyFindingId_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var request = new
|
||||
{
|
||||
finding_id = "",
|
||||
decision = "AcceptRisk",
|
||||
justification = "Test justification"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /approvals rejects empty justification")]
|
||||
public async Task CreateApproval_EmptyJustification_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var request = new
|
||||
{
|
||||
finding_id = "CVE-2024-12345",
|
||||
decision = "AcceptRisk",
|
||||
justification = ""
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /approvals rejects invalid decision")]
|
||||
public async Task CreateApproval_InvalidDecision_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var request = new
|
||||
{
|
||||
finding_id = "CVE-2024-12345",
|
||||
decision = "InvalidDecision",
|
||||
justification = "Test justification"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("Invalid decision value", problem!.Title);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "POST /approvals rejects invalid scanId")]
|
||||
public async Task CreateApproval_InvalidScanId_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new
|
||||
{
|
||||
finding_id = "CVE-2024-12345",
|
||||
decision = "AcceptRisk",
|
||||
justification = "Test justification"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scans/invalid-scan-id/approvals", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "POST /approvals accepts all valid decision types")]
|
||||
[InlineData("AcceptRisk")]
|
||||
[InlineData("Defer")]
|
||||
[InlineData("Reject")]
|
||||
[InlineData("Suppress")]
|
||||
[InlineData("Escalate")]
|
||||
public async Task CreateApproval_AllDecisionTypes_Accepted(string decision)
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var request = new
|
||||
{
|
||||
finding_id = $"CVE-2024-{Guid.NewGuid():N}",
|
||||
decision,
|
||||
justification = "Test justification for decision type test"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
|
||||
Assert.NotNull(approval);
|
||||
Assert.Equal(decision, approval!.Decision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /approvals Tests
|
||||
|
||||
[Fact(DisplayName = "GET /approvals returns empty list for new scan")]
|
||||
public async Task ListApprovals_NewScan_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ApprovalListResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(scanId, result!.ScanId);
|
||||
Assert.Empty(result.Approvals);
|
||||
Assert.Equal(0, result.TotalCount);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /approvals returns created approvals")]
|
||||
public async Task ListApprovals_WithApprovals_ReturnsAll()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
|
||||
// Create two approvals
|
||||
await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", new
|
||||
{
|
||||
finding_id = "CVE-2024-0001",
|
||||
decision = "AcceptRisk",
|
||||
justification = "First approval"
|
||||
});
|
||||
await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", new
|
||||
{
|
||||
finding_id = "CVE-2024-0002",
|
||||
decision = "Defer",
|
||||
justification = "Second approval"
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ApprovalListResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(2, result!.Approvals.Count);
|
||||
Assert.Equal(2, result.TotalCount);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /approvals/{findingId} returns specific approval")]
|
||||
public async Task GetApproval_Existing_ReturnsApproval()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var findingId = "CVE-2024-99999";
|
||||
|
||||
await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", new
|
||||
{
|
||||
finding_id = findingId,
|
||||
decision = "Suppress",
|
||||
justification = "False positive for testing"
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals/{findingId}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
|
||||
Assert.NotNull(approval);
|
||||
Assert.Equal(findingId, approval!.FindingId);
|
||||
Assert.Equal("Suppress", approval.Decision);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "GET /approvals/{findingId} returns 404 for non-existent")]
|
||||
public async Task GetApproval_NonExistent_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals/CVE-2024-99999");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DELETE /approvals Tests
|
||||
|
||||
[Fact(DisplayName = "DELETE /approvals/{findingId} revokes existing approval")]
|
||||
public async Task RevokeApproval_Existing_Returns204()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var findingId = "CVE-2024-88888";
|
||||
|
||||
await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", new
|
||||
{
|
||||
finding_id = findingId,
|
||||
decision = "AcceptRisk",
|
||||
justification = "Test approval to be revoked"
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _client.DeleteAsync($"/api/v1/scans/{scanId}/approvals/{findingId}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DELETE /approvals/{findingId} returns 404 for non-existent")]
|
||||
public async Task RevokeApproval_NonExistent_Returns404()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
|
||||
// Act
|
||||
var response = await _client.DeleteAsync($"/api/v1/scans/{scanId}/approvals/CVE-2024-nonexistent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Revoked approval excluded from list")]
|
||||
public async Task RevokeApproval_ExcludedFromList()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var findingId = "CVE-2024-77777";
|
||||
|
||||
await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", new
|
||||
{
|
||||
finding_id = findingId,
|
||||
decision = "AcceptRisk",
|
||||
justification = "Test approval"
|
||||
});
|
||||
|
||||
// Revoke
|
||||
await _client.DeleteAsync($"/api/v1/scans/{scanId}/approvals/{findingId}");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ApprovalListResponse>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result!.Approvals);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Revoked approval still retrievable with revoked flag")]
|
||||
public async Task RevokeApproval_StillRetrievable()
|
||||
{
|
||||
// Arrange
|
||||
var scanId = await CreateTestScanAsync();
|
||||
var findingId = "CVE-2024-66666";
|
||||
|
||||
await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", new
|
||||
{
|
||||
finding_id = findingId,
|
||||
decision = "AcceptRisk",
|
||||
justification = "Test approval"
|
||||
});
|
||||
|
||||
// Revoke
|
||||
await _client.DeleteAsync($"/api/v1/scans/{scanId}/approvals/{findingId}");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals/{findingId}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
|
||||
Assert.NotNull(approval);
|
||||
Assert.True(approval!.IsRevoked);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task<string> CreateTestScanAsync()
|
||||
{
|
||||
// Generate a valid scan ID
|
||||
var scanId = Guid.NewGuid().ToString();
|
||||
return scanId;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user