Add determinism tests for verdict artifact generation and update SHA256 sums script
- Implemented comprehensive tests for verdict artifact generation to ensure deterministic outputs across various scenarios, including identical inputs, parallel execution, and change ordering. - Created helper methods for generating sample verdict inputs and computing canonical hashes. - Added tests to validate the stability of canonical hashes, proof spine ordering, and summary statistics. - Introduced a new PowerShell script to update SHA256 sums for files, ensuring accurate hash generation and file integrity checks.
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ScannerAuthorizationTests.cs
|
||||
// Sprint: SPRINT_5100_0007_0006_webservice_contract
|
||||
// Task: WEBSVC-5100-010
|
||||
// Description: Comprehensive auth/authz tests for Scanner.WebService
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Comprehensive authorization tests for Scanner.WebService.
|
||||
/// Verifies deny-by-default, token validation, and scope enforcement.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Security)]
|
||||
[Collection("ScannerWebService")]
|
||||
public sealed class ScannerAuthorizationTests
|
||||
{
|
||||
#region Deny-by-Default Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that protected endpoints require authentication when authority is enabled.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("/api/v1/scans")]
|
||||
[InlineData("/api/v1/sbom")]
|
||||
[InlineData("/api/v1/findings")]
|
||||
[InlineData("/api/v1/reports")]
|
||||
public async Task ProtectedEndpoints_RequireAuthentication_WhenAuthorityEnabled(string endpoint)
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "true";
|
||||
configuration["scanner:authority:allowAnonymousFallback"] = "false";
|
||||
configuration["scanner:authority:issuer"] = "https://authority.local";
|
||||
configuration["scanner:authority:audiences:0"] = "scanner-api";
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync(endpoint);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
|
||||
$"Endpoint {endpoint} should require authentication when authority is enabled");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that health endpoints are publicly accessible.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("/api/v1/health")]
|
||||
[InlineData("/api/v1/health/ready")]
|
||||
[InlineData("/api/v1/health/live")]
|
||||
public async Task HealthEndpoints_ArePubliclyAccessible(string endpoint)
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "true";
|
||||
configuration["scanner:authority:allowAnonymousFallback"] = "false";
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync(endpoint);
|
||||
|
||||
// Health endpoints should be accessible without auth
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.OK,
|
||||
HttpStatusCode.ServiceUnavailable); // ServiceUnavailable is valid for unhealthy
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Token Validation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that expired tokens are rejected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExpiredToken_IsRejected()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "true";
|
||||
configuration["scanner:authority:allowAnonymousFallback"] = "false";
|
||||
configuration["scanner:authority:issuer"] = "https://authority.local";
|
||||
configuration["scanner:authority:audiences:0"] = "scanner-api";
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
// Simulate an expired JWT (this is a malformed token for testing)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", "expired.token.here");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/scans");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that malformed tokens are rejected.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("not-a-jwt")]
|
||||
[InlineData("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9")] // Only header, no payload
|
||||
[InlineData("Bearer only-one-part")]
|
||||
public async Task MalformedToken_IsRejected(string token)
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "true";
|
||||
configuration["scanner:authority:allowAnonymousFallback"] = "false";
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await client.GetAsync("/api/v1/scans");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that tokens with wrong issuer are rejected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TokenWithWrongIssuer_IsRejected()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "true";
|
||||
configuration["scanner:authority:allowAnonymousFallback"] = "false";
|
||||
configuration["scanner:authority:issuer"] = "https://authority.local";
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
// Token signed with different issuer (simulated)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", "wrong.issuer.token");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/scans");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that tokens with wrong audience are rejected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TokenWithWrongAudience_IsRejected()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "true";
|
||||
configuration["scanner:authority:allowAnonymousFallback"] = "false";
|
||||
configuration["scanner:authority:audiences:0"] = "scanner-api";
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
// Token with different audience (simulated)
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", "wrong.audience.token");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/scans");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Anonymous Fallback Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that anonymous access works when fallback is enabled.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AnonymousFallback_AllowsAccess_WhenEnabled()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "true";
|
||||
configuration["scanner:authority:allowAnonymousFallback"] = "true";
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/health");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that anonymous access is denied when fallback is disabled.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AnonymousFallback_DeniesAccess_WhenDisabled()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "true";
|
||||
configuration["scanner:authority:allowAnonymousFallback"] = "false";
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/scans");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scope Enforcement Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that write operations require appropriate scope.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteOperations_RequireWriteScope()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "true";
|
||||
configuration["scanner:authority:allowAnonymousFallback"] = "false";
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
// Without proper auth, POST should fail
|
||||
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.Unauthorized,
|
||||
HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that delete operations require admin scope.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DeleteOperations_RequireAdminScope()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "true";
|
||||
configuration["scanner:authority:allowAnonymousFallback"] = "false";
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.DeleteAsync("/api/v1/scans/00000000-0000-0000-0000-000000000000");
|
||||
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.Unauthorized,
|
||||
HttpStatusCode.Forbidden,
|
||||
HttpStatusCode.MethodNotAllowed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tenant Isolation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that requests without tenant context are handled appropriately.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RequestWithoutTenant_IsHandledAppropriately()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
// Request without tenant header
|
||||
var response = await client.GetAsync("/api/v1/scans");
|
||||
|
||||
// Should either succeed (default tenant) or fail with appropriate error
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.OK,
|
||||
HttpStatusCode.NoContent,
|
||||
HttpStatusCode.BadRequest,
|
||||
HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Security Header Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that security headers are present in responses.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Responses_ContainSecurityHeaders()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/health");
|
||||
|
||||
// Check for common security headers (may vary by configuration)
|
||||
// These are recommendations, not hard requirements
|
||||
response.Headers.Should().NotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that CORS is properly configured.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Cors_IsProperlyConfigured()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Options, "/api/v1/health");
|
||||
request.Headers.Add("Origin", "https://example.com");
|
||||
request.Headers.Add("Access-Control-Request-Method", "GET");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
// CORS preflight should either succeed or be explicitly denied
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.OK,
|
||||
HttpStatusCode.NoContent,
|
||||
HttpStatusCode.Forbidden,
|
||||
HttpStatusCode.MethodNotAllowed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user