// ============================================================================= // 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(); // Use default factory without auth overrides - same pattern as ManifestEndpointsTests // The factory defaults to anonymous auth which allows all policy assertions _factory = new ScannerApplicationFactory(); _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(); Assert.NotNull(approval); Assert.Equal("CVE-2024-12345", approval!.FindingId); Assert.Equal("AcceptRisk", approval.Decision); Assert.NotNull(approval.AttestationId); Assert.StartsWith("sha256:", approval.AttestationId); } [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(); Assert.NotNull(problem); Assert.Equal("Invalid decision value", problem!.Title); } [Fact(DisplayName = "POST /approvals rejects whitespace-only scanId")] public async Task CreateApproval_WhitespaceScanId_Returns400() { // Arrange - ScanId.TryParse accepts any non-empty string, // but rejects whitespace-only or empty strings var request = new { finding_id = "CVE-2024-12345", decision = "AcceptRisk", justification = "Test justification" }; // Act - using whitespace-only scan ID which should be rejected var response = await _client.PostAsJsonAsync("/api/v1/scans/ /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(); 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(); 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(); 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(); 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(); 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(); Assert.NotNull(approval); Assert.True(approval!.IsRevoked); } #endregion #region Helper Methods private async Task CreateTestScanAsync() { // Generate a valid scan ID var scanId = Guid.NewGuid().ToString(); return scanId; } #endregion }