save work
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user