382 lines
13 KiB
C#
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
|
|
}
|