Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ApprovalEndpointsTests.cs
StellaOps Bot 37e11918e0 save progress
2026-01-06 09:42:20 +02:00

382 lines
13 KiB
C#

// =============================================================================
// 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, TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>(TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken);
// 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, TestContext.Current.CancellationToken);
// 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, TestContext.Current.CancellationToken);
// 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 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, TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>(TestContext.Current.CancellationToken);
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", TestContext.Current.CancellationToken);
// 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", TestContext.Current.CancellationToken);
// 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>(TestContext.Current.CancellationToken);
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", TestContext.Current.CancellationToken);
// 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>(TestContext.Current.CancellationToken);
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
}