// ============================================================================= // 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(); services.AddSingleton(_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() }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); 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 }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); 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(TestContext.Current.CancellationToken); 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(); 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(TestContext.Current.CancellationToken); 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(); 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(TestContext.Current.CancellationToken); 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(TestContext.Current.CancellationToken); 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(); 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(); 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 _current = new(StringComparer.Ordinal); private readonly Dictionary> _history = new(StringComparer.Ordinal); public bool Available { get; set; } = true; public DateOnly? LatestModelDate { get; set; } public Task GetCurrentAsync(string cveId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(cveId)) { return Task.FromResult(null); } var key = NormalizeCveId(cveId); return Task.FromResult(_current.TryGetValue(key, out var evidence) ? evidence : null); } public Task GetCurrentBatchAsync(IEnumerable cveIds, CancellationToken cancellationToken = default) { var found = new List(); var notFound = new List(); foreach (var raw in cveIds ?? Array.Empty()) { 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 GetAsOfDateAsync(string cveId, DateOnly asOfDate, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(cveId)) { return Task.FromResult(null); } var key = NormalizeCveId(cveId); if (!_history.TryGetValue(key, out var list)) { return Task.FromResult(null); } var match = list .Where(e => e.ModelDate <= asOfDate) .OrderByDescending(e => e.ModelDate) .FirstOrDefault(); return Task.FromResult(match); } public Task> GetHistoryAsync( string cveId, DateOnly startDate, DateOnly endDate, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(cveId)) { return Task.FromResult>(Array.Empty()); } var key = NormalizeCveId(cveId); if (!_history.TryGetValue(key, out var list)) { return Task.FromResult>(Array.Empty()); } var filtered = list .Where(e => e.ModelDate >= startDate && e.ModelDate <= endDate) .OrderBy(e => e.ModelDate) .ToList(); return Task.FromResult>(filtered); } public Task GetLatestModelDateAsync(CancellationToken cancellationToken = default) => Task.FromResult(LatestModelDate); public Task 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 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(); } }