// -----------------------------------------------------------------------------
// ScannerNegativeTests.cs
// Sprint: SPRINT_5100_0007_0006_webservice_contract
// Task: WEBSVC-5100-009
// Description: Negative tests for Scanner.WebService (error handling validation)
// -----------------------------------------------------------------------------
using System.Net;
using System.Text;
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests.Negative;
///
/// Negative tests for Scanner.WebService.
/// Verifies proper error handling for invalid requests.
///
[Trait("Category", TestCategories.Integration)]
[Collection("ScannerWebService")]
public sealed class ScannerNegativeTests : IClassFixture
{
private readonly ScannerApplicationFactory _factory;
public ScannerNegativeTests(ScannerApplicationFactory factory)
{
_factory = factory;
}
#region Content-Type Tests (415 Unsupported Media Type)
///
/// Verifies that POST with wrong content type returns 415.
///
[Theory]
[InlineData("text/plain")]
[InlineData("text/html")]
[InlineData("application/xml")]
public async Task Post_WithWrongContentType_Returns415(string contentType)
{
using var client = _factory.CreateClient();
var content = new StringContent("{\"test\": true}", Encoding.UTF8, contentType);
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType,
$"POST with content-type '{contentType}' should return 415");
}
///
/// Verifies that missing content type returns appropriate error.
///
[Fact]
public async Task Post_WithMissingContentType_ReturnsError()
{
using var client = _factory.CreateClient();
var content = new StringContent("{\"test\": true}", Encoding.UTF8);
content.Headers.ContentType = null;
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
// Should be either 415 or 400 depending on implementation
response.StatusCode.Should().BeOneOf(
HttpStatusCode.UnsupportedMediaType,
HttpStatusCode.BadRequest,
HttpStatusCode.Unauthorized);
}
#endregion
#region Payload Size Tests (413 Payload Too Large)
///
/// Verifies that oversized payload returns 413.
///
[Fact]
public async Task Post_WithOversizedPayload_Returns413()
{
using var client = _factory.CreateClient();
// Create a 50MB payload (assuming limit is lower)
var largeContent = new string('x', 50 * 1024 * 1024);
var content = new StringContent($"{{\"data\": \"{largeContent}\"}}", Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
// Should be 413 or the request might timeout/fail
response.StatusCode.Should().BeOneOf(
HttpStatusCode.RequestEntityTooLarge,
HttpStatusCode.BadRequest,
HttpStatusCode.Unauthorized);
}
#endregion
#region Method Mismatch Tests (405 Method Not Allowed)
///
/// Verifies that wrong HTTP method returns 405.
///
[Theory]
[InlineData("DELETE", "/healthz")]
[InlineData("PUT", "/healthz")]
[InlineData("PATCH", "/healthz")]
public async Task WrongMethod_Returns405(string method, string endpoint)
{
using var client = _factory.CreateClient();
var request = new HttpRequestMessage(new HttpMethod(method), endpoint);
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed,
$"{method} {endpoint} should return 405");
}
#endregion
#region Malformed Request Tests (400 Bad Request)
///
/// Verifies that malformed JSON returns 400.
///
[Fact]
public async Task Post_WithMalformedJson_Returns400()
{
using var client = _factory.CreateClient();
var content = new StringContent("{ invalid json }", Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.Unauthorized);
}
///
/// Verifies that empty body returns 400.
///
[Fact]
public async Task Post_WithEmptyBody_Returns400()
{
using var client = _factory.CreateClient();
var content = new StringContent(string.Empty, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.Unauthorized);
}
///
/// Verifies that missing required fields returns 400.
///
[Fact]
public async Task Post_WithMissingRequiredFields_Returns400()
{
using var client = _factory.CreateClient();
var content = new StringContent("{}", Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.Unauthorized,
HttpStatusCode.UnprocessableEntity);
}
#endregion
#region Not Found Tests (404)
///
/// Verifies that non-existent resource returns 404.
///
[Theory]
[InlineData("/api/v1/scans/00000000-0000-0000-0000-000000000000")]
[InlineData("/api/v1/sbom/00000000-0000-0000-0000-000000000000")]
public async Task Get_NonExistentResource_Returns404(string endpoint)
{
using var client = _factory.CreateClient();
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(
HttpStatusCode.NotFound,
HttpStatusCode.Unauthorized);
}
///
/// Verifies that non-existent endpoint returns 404.
///
[Fact]
public async Task Get_NonExistentEndpoint_Returns404()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/nonexistent", TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
#endregion
#region Invalid Parameter Tests
///
/// Verifies that invalid GUID format returns 400.
///
[Theory]
[InlineData("/api/v1/scans/not-a-guid")]
[InlineData("/api/v1/scans/12345")]
public async Task Get_WithInvalidGuid_Returns400Or404(string endpoint)
{
using var client = _factory.CreateClient();
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
HttpStatusCode.NotFound,
HttpStatusCode.Unauthorized);
}
///
/// Verifies that SQL injection attempts are rejected.
///
[Theory]
[InlineData("/api/v1/scans?filter=1'; DROP TABLE scans;--")]
[InlineData("/api/v1/scans?search=")]
public async Task Get_WithInjectionAttempt_ReturnsSafeResponse(string endpoint)
{
using var client = _factory.CreateClient();
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
// Should not cause server error (500)
response.StatusCode.Should().NotBe(HttpStatusCode.InternalServerError,
"Injection attempts should not cause server errors");
}
#endregion
#region Rate Limiting Tests (429)
///
/// Verifies that rapid requests are rate limited.
///
[Fact(Skip = "Rate limiting may not be enabled in test environment")]
public async Task RapidRequests_AreRateLimited()
{
using var client = _factory.CreateClient();
var tasks = Enumerable.Range(0, 100)
.Select(_ => client.GetAsync("/healthz", TestContext.Current.CancellationToken));
var responses = await Task.WhenAll(tasks);
var tooManyRequests = responses.Count(r =>
r.StatusCode == HttpStatusCode.TooManyRequests);
// Some requests should be rate limited
tooManyRequests.Should().BeGreaterThan(0,
"Rate limiting should kick in for rapid requests");
}
#endregion
}