save work

This commit is contained in:
StellaOps Bot
2025-12-19 07:28:23 +02:00
parent 6410a6d082
commit 2eafe98d44
97 changed files with 5040 additions and 1443 deletions

View File

@@ -0,0 +1,360 @@
// =============================================================================
// EpssEndpointsTests.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-011 - Integration tests for EPSS 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.Core.Epss;
using StellaOps.Scanner.WebService.Endpoints;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
[Trait("Category", "Integration")]
[Trait("Sprint", "3410.0002")]
public sealed class EpssEndpointsTests : IDisposable
{
private readonly TestSurfaceSecretsScope _secrets;
private readonly InMemoryEpssProvider _epssProvider;
private readonly ScannerApplicationFactory _factory;
private readonly HttpClient _client;
public EpssEndpointsTests()
{
_secrets = new TestSurfaceSecretsScope();
_epssProvider = new InMemoryEpssProvider();
_factory = new ScannerApplicationFactory().WithOverrides(
configureConfiguration: config => config["scanner:authority:enabled"] = "false",
configureServices: services =>
{
services.RemoveAll<IEpssProvider>();
services.AddSingleton<IEpssProvider>(_epssProvider);
});
_client = _factory.CreateClient();
}
public void Dispose()
{
_client.Dispose();
_factory.Dispose();
_secrets.Dispose();
}
[Fact(DisplayName = "POST /epss/current rejects empty CVE list")]
public async Task PostCurrentBatch_EmptyList_ReturnsBadRequest()
{
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds = Array.Empty<string>() });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.NotNull(problem);
Assert.Equal("Invalid request", problem!.Title);
}
[Fact(DisplayName = "POST /epss/current rejects >1000 CVEs")]
public async Task PostCurrentBatch_OverLimit_ReturnsBadRequest()
{
var cveIds = Enumerable.Range(1, 1001).Select(i => $"CVE-2025-{i:D5}").ToArray();
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.NotNull(problem);
Assert.Equal("Batch size exceeded", problem!.Title);
}
[Fact(DisplayName = "POST /epss/current returns 503 when EPSS unavailable")]
public async Task PostCurrentBatch_WhenUnavailable_Returns503()
{
_epssProvider.Available = false;
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds = new[] { "CVE-2021-44228" } });
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.NotNull(problem);
Assert.Equal(503, problem!.Status);
Assert.Contains("EPSS data is not available", problem.Detail, StringComparison.Ordinal);
}
[Fact(DisplayName = "POST /epss/current returns found + notFound results")]
public async Task PostCurrentBatch_ReturnsBatchResponse()
{
_epssProvider.LatestModelDate = new DateOnly(2025, 12, 17);
_epssProvider.SetCurrent(EpssEvidence.CreateWithTimestamp(
cveId: "CVE-2021-44228",
score: 0.97,
percentile: 0.99,
modelDate: _epssProvider.LatestModelDate.Value,
capturedAt: new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero),
source: "test",
fromCache: false));
_epssProvider.SetCurrent(EpssEvidence.CreateWithTimestamp(
cveId: "CVE-2022-22965",
score: 0.95,
percentile: 0.98,
modelDate: _epssProvider.LatestModelDate.Value,
capturedAt: new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero),
source: "test",
fromCache: false));
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new
{
cveIds = new[] { "CVE-2021-44228", "CVE-2022-22965", "CVE-1999-0001" }
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var batch = await response.Content.ReadFromJsonAsync<EpssBatchResponse>();
Assert.NotNull(batch);
Assert.Equal("2025-12-17", batch!.ModelDate);
Assert.Equal(2, batch.Found.Count);
Assert.Single(batch.NotFound);
Assert.Contains("CVE-1999-0001", batch.NotFound);
Assert.Contains(batch.Found, e => e.CveId == "CVE-2021-44228" && Math.Abs(e.Score - 0.97) < 0.0001);
}
[Fact(DisplayName = "GET /epss/current/{cveId} returns 404 when not found")]
public async Task GetCurrentSingle_NotFound_Returns404()
{
var response = await _client.GetAsync("/api/v1/epss/current/CVE-1999-0001");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.NotNull(problem);
Assert.Equal("CVE not found", problem!.Title);
}
[Fact(DisplayName = "GET /epss/current/{cveId} returns evidence when found")]
public async Task GetCurrentSingle_Found_ReturnsEvidence()
{
_epssProvider.LatestModelDate = new DateOnly(2025, 12, 17);
_epssProvider.SetCurrent(EpssEvidence.CreateWithTimestamp(
cveId: "CVE-2021-44228",
score: 0.97,
percentile: 0.99,
modelDate: _epssProvider.LatestModelDate.Value,
capturedAt: new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero),
source: "test"));
var response = await _client.GetAsync("/api/v1/epss/current/CVE-2021-44228");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var evidence = await response.Content.ReadFromJsonAsync<EpssEvidence>();
Assert.NotNull(evidence);
Assert.Equal("CVE-2021-44228", evidence!.CveId);
Assert.Equal(0.97, evidence.Score, 5);
Assert.Equal(new DateOnly(2025, 12, 17), evidence.ModelDate);
}
[Fact(DisplayName = "GET /epss/history/{cveId} rejects invalid date formats")]
public async Task GetHistory_InvalidDates_ReturnsBadRequest()
{
var response = await _client.GetAsync("/api/v1/epss/history/CVE-2021-44228?startDate=2025-99-99&endDate=2025-12-17");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.NotNull(problem);
Assert.Equal("Invalid date format", problem!.Title);
}
[Fact(DisplayName = "GET /epss/history/{cveId} returns 404 when no history exists")]
public async Task GetHistory_NoHistory_Returns404()
{
var response = await _client.GetAsync("/api/v1/epss/history/CVE-2021-44228?startDate=2025-12-15&endDate=2025-12-17");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
Assert.NotNull(problem);
Assert.Equal("No history found", problem!.Title);
}
[Fact(DisplayName = "GET /epss/history/{cveId} returns history for date range")]
public async Task GetHistory_ReturnsHistoryResponse()
{
var cveId = "CVE-2021-44228";
var capturedAt = new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero);
_epssProvider.SetHistory(
cveId,
new[]
{
EpssEvidence.CreateWithTimestamp(cveId, 0.10, 0.20, new DateOnly(2025, 12, 15), capturedAt, source: "test"),
EpssEvidence.CreateWithTimestamp(cveId, 0.11, 0.21, new DateOnly(2025, 12, 16), capturedAt, source: "test"),
EpssEvidence.CreateWithTimestamp(cveId, 0.12, 0.22, new DateOnly(2025, 12, 17), capturedAt, source: "test"),
});
var response = await _client.GetAsync($"/api/v1/epss/history/{cveId}?startDate=2025-12-15&endDate=2025-12-17");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var history = await response.Content.ReadFromJsonAsync<EpssHistoryResponse>();
Assert.NotNull(history);
Assert.Equal(cveId, history!.CveId);
Assert.Equal("2025-12-15", history.StartDate);
Assert.Equal("2025-12-17", history.EndDate);
Assert.Equal(3, history.History.Count);
Assert.Equal(new DateOnly(2025, 12, 15), history.History[0].ModelDate);
Assert.Equal(new DateOnly(2025, 12, 17), history.History[^1].ModelDate);
}
[Fact(DisplayName = "GET /epss/status returns provider availability + model date")]
public async Task GetStatus_ReturnsStatus()
{
_epssProvider.Available = true;
_epssProvider.LatestModelDate = new DateOnly(2025, 12, 17);
var response = await _client.GetAsync("/api/v1/epss/status");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var status = await response.Content.ReadFromJsonAsync<EpssStatusResponse>();
Assert.NotNull(status);
Assert.True(status!.Available);
Assert.Equal("2025-12-17", status.LatestModelDate);
Assert.NotEqual(default, status.LastCheckedUtc);
}
private sealed class InMemoryEpssProvider : IEpssProvider
{
private readonly Dictionary<string, EpssEvidence> _current = new(StringComparer.Ordinal);
private readonly Dictionary<string, List<EpssEvidence>> _history = new(StringComparer.Ordinal);
public bool Available { get; set; } = true;
public DateOnly? LatestModelDate { get; set; }
public Task<EpssEvidence?> GetCurrentAsync(string cveId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(cveId))
{
return Task.FromResult<EpssEvidence?>(null);
}
var key = NormalizeCveId(cveId);
return Task.FromResult(_current.TryGetValue(key, out var evidence) ? evidence : null);
}
public Task<EpssBatchResult> GetCurrentBatchAsync(IEnumerable<string> cveIds, CancellationToken cancellationToken = default)
{
var found = new List<EpssEvidence>();
var notFound = new List<string>();
foreach (var raw in cveIds ?? Array.Empty<string>())
{
if (string.IsNullOrWhiteSpace(raw))
{
continue;
}
var key = NormalizeCveId(raw);
if (_current.TryGetValue(key, out var evidence))
{
found.Add(evidence);
}
else
{
notFound.Add(raw);
}
}
var modelDate = LatestModelDate
?? found.Select(static e => e.ModelDate).FirstOrDefault();
return Task.FromResult(new EpssBatchResult
{
Found = found,
NotFound = notFound,
ModelDate = modelDate == default ? new DateOnly(1970, 1, 1) : modelDate,
LookupTimeMs = 0,
PartiallyFromCache = false
});
}
public Task<EpssEvidence?> GetAsOfDateAsync(string cveId, DateOnly asOfDate, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(cveId))
{
return Task.FromResult<EpssEvidence?>(null);
}
var key = NormalizeCveId(cveId);
if (!_history.TryGetValue(key, out var list))
{
return Task.FromResult<EpssEvidence?>(null);
}
var match = list
.Where(e => e.ModelDate <= asOfDate)
.OrderByDescending(e => e.ModelDate)
.FirstOrDefault();
return Task.FromResult<EpssEvidence?>(match);
}
public Task<IReadOnlyList<EpssEvidence>> GetHistoryAsync(
string cveId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(cveId))
{
return Task.FromResult<IReadOnlyList<EpssEvidence>>(Array.Empty<EpssEvidence>());
}
var key = NormalizeCveId(cveId);
if (!_history.TryGetValue(key, out var list))
{
return Task.FromResult<IReadOnlyList<EpssEvidence>>(Array.Empty<EpssEvidence>());
}
var filtered = list
.Where(e => e.ModelDate >= startDate && e.ModelDate <= endDate)
.OrderBy(e => e.ModelDate)
.ToList();
return Task.FromResult<IReadOnlyList<EpssEvidence>>(filtered);
}
public Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(LatestModelDate);
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Available);
public void SetCurrent(EpssEvidence evidence)
{
ArgumentNullException.ThrowIfNull(evidence);
_current[NormalizeCveId(evidence.CveId)] = evidence;
}
public void SetHistory(string cveId, IEnumerable<EpssEvidence> history)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentNullException.ThrowIfNull(history);
_history[NormalizeCveId(cveId)] = history
.OrderBy(e => e.ModelDate)
.ToList();
}
private static string NormalizeCveId(string cveId)
=> cveId.Trim().ToUpperInvariant();
}
}