save checkpoint: save features
This commit is contained in:
332
src/Replay/StellaOps.Replay.WebService/FeedSnapshotSupport.cs
Normal file
332
src/Replay/StellaOps.Replay.WebService/FeedSnapshotSupport.cs
Normal file
@@ -0,0 +1,332 @@
|
||||
// <copyright file="FeedSnapshotSupport.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Replay.Core.FeedSnapshots;
|
||||
|
||||
namespace StellaOps.Replay.WebService;
|
||||
|
||||
internal sealed class InMemoryFeedSnapshotBlobStore : IFeedSnapshotBlobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, FeedSnapshotBlob> _blobs = new(StringComparer.Ordinal);
|
||||
|
||||
public Task StoreAsync(FeedSnapshotBlob blob, CancellationToken ct = default)
|
||||
{
|
||||
_blobs[blob.Digest] = blob;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<FeedSnapshotBlob?> GetByDigestAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
_blobs.TryGetValue(digest, out var blob);
|
||||
return Task.FromResult(blob);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(_blobs.ContainsKey(digest));
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
_blobs.TryRemove(digest, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryFeedSnapshotIndexStore : IFeedSnapshotIndexStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, List<FeedSnapshotIndexEntry>> _index = new(StringComparer.Ordinal);
|
||||
|
||||
public Task IndexSnapshotAsync(FeedSnapshotIndexEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
var entries = _index.GetOrAdd(entry.ProviderId, static _ => []);
|
||||
lock (entries)
|
||||
{
|
||||
entries.Add(entry);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<FeedSnapshotIndexEntry?> FindSnapshotAtTimeAsync(
|
||||
string providerId,
|
||||
DateTimeOffset pointInTime,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_index.TryGetValue(providerId, out var entries))
|
||||
{
|
||||
return Task.FromResult<FeedSnapshotIndexEntry?>(null);
|
||||
}
|
||||
|
||||
lock (entries)
|
||||
{
|
||||
var found = entries
|
||||
.Where(e => e.CapturedAt <= pointInTime)
|
||||
.OrderByDescending(e => e.CapturedAt)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult(found);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<FeedSnapshotIndexEntry>> ListSnapshotsAsync(
|
||||
string providerId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_index.TryGetValue(providerId, out var entries))
|
||||
{
|
||||
return Task.FromResult(ImmutableArray<FeedSnapshotIndexEntry>.Empty);
|
||||
}
|
||||
|
||||
lock (entries)
|
||||
{
|
||||
IEnumerable<FeedSnapshotIndexEntry> query = entries
|
||||
.Where(e => e.CapturedAt >= from && e.CapturedAt <= to)
|
||||
.OrderBy(e => e.CapturedAt);
|
||||
|
||||
if (limit > 0)
|
||||
{
|
||||
query = query.Take(limit);
|
||||
}
|
||||
|
||||
return Task.FromResult(query.ToImmutableArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class JsonAdvisoryExtractor : IAdvisoryExtractor
|
||||
{
|
||||
public Task<AdvisoryData?> ExtractAdvisoryAsync(
|
||||
string cveId,
|
||||
byte[] content,
|
||||
FeedSnapshotFormat format,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cveId) || content.Length == 0)
|
||||
{
|
||||
return Task.FromResult<AdvisoryData?>(null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var advisory = TryExtract(cveId, doc.RootElement);
|
||||
return Task.FromResult(advisory);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Task.FromResult<AdvisoryData?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static AdvisoryData? TryExtract(string cveId, JsonElement root)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (TryParseAdvisoryObject(cveId, root, out var direct))
|
||||
{
|
||||
return direct;
|
||||
}
|
||||
|
||||
if (TryGetPropertyIgnoreCase(root, "advisories", out var advisories) &&
|
||||
advisories.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in advisories.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Object &&
|
||||
TryParseAdvisoryObject(cveId, item, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (TryGetPropertyIgnoreCase(root, "cves", out var cves))
|
||||
{
|
||||
if (cves.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in cves.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, cveId, StringComparison.OrdinalIgnoreCase) &&
|
||||
prop.Value.ValueKind == JsonValueKind.Object &&
|
||||
TryParseAdvisoryObject(cveId, prop.Value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cves.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in cves.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Object &&
|
||||
TryParseAdvisoryObject(cveId, item, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in root.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Object &&
|
||||
TryParseAdvisoryObject(cveId, item, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseAdvisoryObject(string requestedCveId, JsonElement obj, out AdvisoryData? advisory)
|
||||
{
|
||||
advisory = null;
|
||||
var cveId = GetString(obj, "cveId", "cve", "cve_id", "id");
|
||||
if (string.IsNullOrWhiteSpace(cveId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(cveId, requestedCveId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
advisory = new AdvisoryData
|
||||
{
|
||||
CveId = cveId,
|
||||
Severity = GetString(obj, "severity"),
|
||||
CvssScore = GetDecimal(obj, "cvssScore", "cvss", "score"),
|
||||
CvssVector = GetString(obj, "cvssVector", "vector"),
|
||||
Description = GetString(obj, "description", "summary"),
|
||||
FixStatus = GetString(obj, "fixStatus", "fix_state", "status"),
|
||||
AffectedProducts = GetStringArray(obj, "affectedProducts", "affected_products", "packages"),
|
||||
References = GetStringArray(obj, "references", "refs", "links"),
|
||||
PublishedAt = GetDateTimeOffset(obj, "publishedAt", "published_at"),
|
||||
LastModifiedAt = GetDateTimeOffset(obj, "lastModifiedAt", "last_modified_at")
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement obj, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (TryGetPropertyIgnoreCase(obj, name, out var value))
|
||||
{
|
||||
if (value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return value.GetString();
|
||||
}
|
||||
|
||||
if (value.ValueKind != JsonValueKind.Null && value.ValueKind != JsonValueKind.Undefined)
|
||||
{
|
||||
return value.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static decimal? GetDecimal(JsonElement obj, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(obj, name, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetDecimal(out var num))
|
||||
{
|
||||
return num;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String &&
|
||||
decimal.TryParse(value.GetString(), out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetDateTimeOffset(JsonElement obj, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(obj, name, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String &&
|
||||
DateTimeOffset.TryParse(value.GetString(), out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> GetStringArray(JsonElement obj, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!TryGetPropertyIgnoreCase(obj, name, out var value) ||
|
||||
value.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var values = new List<string>();
|
||||
foreach (var item in value.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var str = item.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(str))
|
||||
{
|
||||
values.Add(str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values.ToImmutableArray();
|
||||
}
|
||||
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyIgnoreCase(JsonElement obj, string name, out JsonElement value)
|
||||
{
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,12 @@ using Serilog.Events;
|
||||
using StellaOps.Audit.ReplayToken;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.DependencyInjection;
|
||||
using StellaOps.Replay.Core.FeedSnapshots;
|
||||
using StellaOps.Replay.WebService;
|
||||
using StellaOps.Telemetry.Core;
|
||||
|
||||
const string ReplayReadPolicy = "replay.token.read";
|
||||
@@ -48,6 +51,17 @@ builder.Services.AddOptions<ReplayServiceOptions>()
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ICryptoHash, DefaultCryptoHash>();
|
||||
builder.Services.AddSingleton<IReplayTokenGenerator, Sha256ReplayTokenGenerator>();
|
||||
builder.Services.AddSingleton<IReplayExecutor, ReplayExecutor>();
|
||||
builder.Services.AddSingleton<IAuditBundleReader, AuditBundleReader>();
|
||||
builder.Services.AddSingleton<IVerdictReplayPredicate, VerdictReplayPredicate>();
|
||||
|
||||
builder.Services.AddSingleton<IFeedSnapshotBlobStore, InMemoryFeedSnapshotBlobStore>();
|
||||
builder.Services.AddSingleton<IFeedSnapshotIndexStore, InMemoryFeedSnapshotIndexStore>();
|
||||
builder.Services.AddSingleton(new FeedSnapshotServiceOptions());
|
||||
builder.Services.AddSingleton<FeedSnapshotService>();
|
||||
builder.Services.AddSingleton<IAdvisoryExtractor, JsonAdvisoryExtractor>();
|
||||
builder.Services.AddSingleton<PointInTimeAdvisoryResolver>();
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddHealthChecks();
|
||||
@@ -125,6 +139,8 @@ app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapVerdictReplayEndpoints();
|
||||
app.MapPointInTimeQueryEndpoints();
|
||||
|
||||
// POST /v1/replay/tokens - Generate a new replay token
|
||||
app.MapPost("/v1/replay/tokens", Task<Results<Created<GenerateTokenResponse>, ProblemHttpResult>> (
|
||||
@@ -484,3 +500,5 @@ public class AuthorityConfig
|
||||
public List<string> Audiences { get; set; } = new() { "stella-ops-api" };
|
||||
public List<string> RequiredScopes { get; set; } = new() { "vuln.operate" };
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed class PointInTimeAdvisoryResolverTests
|
||||
private readonly TestAdvisoryExtractor _advisoryExtractor = new();
|
||||
|
||||
private FeedSnapshotService CreateSnapshotService() => new(
|
||||
_blobStore,
|
||||
ResetState(),
|
||||
_indexStore,
|
||||
_timeProvider,
|
||||
new FeedSnapshotServiceOptions(),
|
||||
@@ -32,6 +32,14 @@ public sealed class PointInTimeAdvisoryResolverTests
|
||||
_advisoryExtractor,
|
||||
NullLogger<PointInTimeAdvisoryResolver>.Instance);
|
||||
|
||||
private InMemoryFeedSnapshotBlobStore ResetState()
|
||||
{
|
||||
_blobStore.Clear();
|
||||
_indexStore.Clear();
|
||||
_advisoryExtractor.Clear();
|
||||
return _blobStore;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAdvisoryAsync_ReturnsAdvisory_WhenFoundInSnapshot()
|
||||
{
|
||||
@@ -207,7 +215,7 @@ public sealed class PointInTimeAdvisoryResolverTests
|
||||
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(baseTime);
|
||||
|
||||
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
|
||||
_advisoryExtractor.SetVersionedAdvisory("CVE-2024-1234", 1, new AdvisoryData
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
Severity = "MEDIUM",
|
||||
@@ -224,7 +232,7 @@ public sealed class PointInTimeAdvisoryResolverTests
|
||||
_timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
|
||||
// Update the advisory
|
||||
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
|
||||
_advisoryExtractor.SetVersionedAdvisory("CVE-2024-1234", 2, new AdvisoryData
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
Severity = "HIGH",
|
||||
@@ -269,7 +277,7 @@ public sealed class PointInTimeAdvisoryResolverTests
|
||||
_timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
|
||||
// Add advisory
|
||||
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
|
||||
_advisoryExtractor.SetVersionedAdvisory("CVE-2024-1234", 2, new AdvisoryData
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
Severity = "HIGH"
|
||||
@@ -400,6 +408,8 @@ internal sealed class InMemoryFeedSnapshotBlobStore : IFeedSnapshotBlobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, FeedSnapshotBlob> _blobs = new();
|
||||
|
||||
public void Clear() => _blobs.Clear();
|
||||
|
||||
public Task StoreAsync(FeedSnapshotBlob blob, CancellationToken ct = default)
|
||||
{
|
||||
_blobs[blob.Digest] = blob;
|
||||
@@ -428,6 +438,8 @@ internal sealed class InMemoryFeedSnapshotIndexStore : IFeedSnapshotIndexStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, List<FeedSnapshotIndexEntry>> _index = new();
|
||||
|
||||
public void Clear() => _index.Clear();
|
||||
|
||||
public Task IndexSnapshotAsync(FeedSnapshotIndexEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
var entries = _index.GetOrAdd(entry.ProviderId, _ => new List<FeedSnapshotIndexEntry>());
|
||||
@@ -486,6 +498,14 @@ internal sealed class TestAdvisoryExtractor : IAdvisoryExtractor
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AdvisoryData> _advisories = new();
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, AdvisoryData>> _providerAdvisories = new();
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, AdvisoryData>> _versionedAdvisories = new();
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_advisories.Clear();
|
||||
_providerAdvisories.Clear();
|
||||
_versionedAdvisories.Clear();
|
||||
}
|
||||
|
||||
public void SetAdvisory(string cveId, AdvisoryData advisory)
|
||||
{
|
||||
@@ -498,6 +518,12 @@ internal sealed class TestAdvisoryExtractor : IAdvisoryExtractor
|
||||
providerDict[cveId] = advisory;
|
||||
}
|
||||
|
||||
public void SetVersionedAdvisory(string cveId, int version, AdvisoryData advisory)
|
||||
{
|
||||
var versionMap = _versionedAdvisories.GetOrAdd(cveId, _ => new ConcurrentDictionary<int, AdvisoryData>());
|
||||
versionMap[version] = advisory;
|
||||
}
|
||||
|
||||
public Task<AdvisoryData?> ExtractAdvisoryAsync(
|
||||
string cveId,
|
||||
byte[] content,
|
||||
@@ -518,6 +544,19 @@ internal sealed class TestAdvisoryExtractor : IAdvisoryExtractor
|
||||
return Task.FromResult<AdvisoryData?>(providerAdvisory);
|
||||
}
|
||||
}
|
||||
|
||||
// Versioned replay fixtures (version/v fields) resolve advisory state
|
||||
// pinned to snapshot version.
|
||||
if (TryGetVersion(json, out var version) &&
|
||||
_versionedAdvisories.TryGetValue(cveId, out var versionMap))
|
||||
{
|
||||
if (versionMap.TryGetValue(version, out var versionedAdvisory))
|
||||
{
|
||||
return Task.FromResult<AdvisoryData?>(versionedAdvisory);
|
||||
}
|
||||
|
||||
return Task.FromResult<AdvisoryData?>(null);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -527,6 +566,27 @@ internal sealed class TestAdvisoryExtractor : IAdvisoryExtractor
|
||||
_advisories.TryGetValue(cveId, out var advisory);
|
||||
return Task.FromResult(advisory);
|
||||
}
|
||||
|
||||
private static bool TryGetVersion(JsonElement json, out int version)
|
||||
{
|
||||
version = 0;
|
||||
|
||||
if (json.TryGetProperty("version", out var versionElement) &&
|
||||
versionElement.ValueKind == JsonValueKind.Number &&
|
||||
versionElement.TryGetInt32(out version))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (json.TryGetProperty("v", out var shortVersionElement) &&
|
||||
shortVersionElement.ValueKind == JsonValueKind.Number &&
|
||||
shortVersionElement.TryGetInt32(out version))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// <copyright file="PointInTimeQueryApiIntegrationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Replay.WebService;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.FeedSnapshots;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Intent", "Operational")]
|
||||
public sealed class PointInTimeQueryApiIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PointInTimeEndpoints_ExposeSnapshotQueryAndDiffFlow()
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var cveId = "CVE-2024-7777";
|
||||
var providerId = "nvd-e2e";
|
||||
|
||||
var health = await client.GetAsync("/healthz");
|
||||
health.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var captureOneResponse = await client.PostAsJsonAsync(
|
||||
"/v1/pit/snapshots/",
|
||||
new SnapshotCaptureRequest
|
||||
{
|
||||
ProviderId = providerId,
|
||||
ProviderName = "NVD",
|
||||
FeedType = "cve",
|
||||
FeedData = new
|
||||
{
|
||||
advisories = new[]
|
||||
{
|
||||
new { cveId, severity = "MEDIUM", fixStatus = "unfixed" }
|
||||
}
|
||||
}
|
||||
});
|
||||
captureOneResponse.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
var captureOne = await captureOneResponse.Content.ReadFromJsonAsync<SnapshotCaptureResponse>();
|
||||
captureOne.Should().NotBeNull();
|
||||
|
||||
await Task.Delay(20);
|
||||
|
||||
var captureTwoResponse = await client.PostAsJsonAsync(
|
||||
"/v1/pit/snapshots/",
|
||||
new SnapshotCaptureRequest
|
||||
{
|
||||
ProviderId = providerId,
|
||||
ProviderName = "NVD",
|
||||
FeedType = "cve",
|
||||
FeedData = new
|
||||
{
|
||||
advisories = new[]
|
||||
{
|
||||
new { cveId, severity = "HIGH", fixStatus = "fixed" }
|
||||
}
|
||||
}
|
||||
});
|
||||
captureTwoResponse.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
var captureTwo = await captureTwoResponse.Content.ReadFromJsonAsync<SnapshotCaptureResponse>();
|
||||
captureTwo.Should().NotBeNull();
|
||||
|
||||
var earlyPoint = Uri.EscapeDataString(captureOne!.CapturedAt.AddMilliseconds(10).ToString("o"));
|
||||
var latePoint = Uri.EscapeDataString(captureTwo!.CapturedAt.AddMilliseconds(10).ToString("o"));
|
||||
|
||||
var earlyQueryResponse = await client.GetAsync(
|
||||
$"/v1/pit/advisory/{cveId}?providerId={providerId}&pointInTime={earlyPoint}");
|
||||
earlyQueryResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var earlyQuery = await earlyQueryResponse.Content.ReadFromJsonAsync<AdvisoryQueryResponse>();
|
||||
earlyQuery.Should().NotBeNull();
|
||||
earlyQuery!.Status.Should().Be("Found");
|
||||
earlyQuery.Advisory.Should().NotBeNull();
|
||||
earlyQuery.Advisory!.Severity.Should().Be("MEDIUM");
|
||||
|
||||
var lateQueryResponse = await client.GetAsync(
|
||||
$"/v1/pit/advisory/{cveId}?providerId={providerId}&pointInTime={latePoint}");
|
||||
lateQueryResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var lateQuery = await lateQueryResponse.Content.ReadFromJsonAsync<AdvisoryQueryResponse>();
|
||||
lateQuery.Should().NotBeNull();
|
||||
lateQuery!.Status.Should().Be("Found");
|
||||
lateQuery.Advisory.Should().NotBeNull();
|
||||
lateQuery.Advisory!.Severity.Should().Be("HIGH");
|
||||
|
||||
var diffResponse = await client.PostAsJsonAsync(
|
||||
"/v1/pit/advisory/diff",
|
||||
new AdvisoryDiffRequest
|
||||
{
|
||||
CveId = cveId,
|
||||
ProviderId = providerId,
|
||||
Time1 = captureOne.CapturedAt.AddMilliseconds(10),
|
||||
Time2 = captureTwo.CapturedAt.AddMilliseconds(10)
|
||||
});
|
||||
diffResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var diff = await diffResponse.Content.ReadFromJsonAsync<AdvisoryDiffResponse>();
|
||||
diff.Should().NotBeNull();
|
||||
diff!.DiffType.Should().Be("Modified");
|
||||
diff.Changes.Should().Contain(c => c.Field == "Severity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiffEndpoint_ReturnsBadRequest_WhenProviderIsEmpty()
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/v1/pit/advisory/diff",
|
||||
new
|
||||
{
|
||||
cveId = "CVE-2024-7777",
|
||||
providerId = string.Empty,
|
||||
time1 = "2026-01-01T00:00:00Z",
|
||||
time2 = "2026-01-02T00:00:00Z"
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
doc.RootElement.TryGetProperty("title", out var title).Should().BeTrue();
|
||||
title.GetString().Should().Be("missing_required_fields");
|
||||
}
|
||||
|
||||
private static WebApplicationFactory<Program> CreateFactory()
|
||||
{
|
||||
return new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
|
||||
{
|
||||
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Replay:Authority:Issuer"] = "https://authority.stella-ops.local",
|
||||
["Replay:Authority:MetadataAddress"] = "https://authority.stella-ops.local/.well-known/openid-configuration",
|
||||
["Replay:Authority:RequireHttpsMetadata"] = "false",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public sealed class PointInTimeQueryEndpointsTests
|
||||
private readonly TestAdvisoryExtractor _advisoryExtractor = new();
|
||||
|
||||
private FeedSnapshotService CreateSnapshotService() => new(
|
||||
_blobStore,
|
||||
ResetState(),
|
||||
_indexStore,
|
||||
_timeProvider,
|
||||
new FeedSnapshotServiceOptions(),
|
||||
@@ -34,6 +34,14 @@ public sealed class PointInTimeQueryEndpointsTests
|
||||
_advisoryExtractor,
|
||||
NullLogger<PointInTimeAdvisoryResolver>.Instance);
|
||||
|
||||
private InMemoryFeedSnapshotBlobStore ResetState()
|
||||
{
|
||||
_blobStore.Clear();
|
||||
_indexStore.Clear();
|
||||
_advisoryExtractor.Clear();
|
||||
return _blobStore;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAdvisoryAsync_ReturnsAdvisory_WhenFound()
|
||||
{
|
||||
@@ -153,7 +161,7 @@ public sealed class PointInTimeQueryEndpointsTests
|
||||
var baseTime = new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
_timeProvider.SetUtcNow(baseTime);
|
||||
|
||||
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
|
||||
_advisoryExtractor.SetVersionedAdvisory("CVE-2024-1234", 1, new AdvisoryData
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
Severity = "MEDIUM"
|
||||
@@ -167,7 +175,7 @@ public sealed class PointInTimeQueryEndpointsTests
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
|
||||
_advisoryExtractor.SetAdvisory("CVE-2024-1234", new AdvisoryData
|
||||
_advisoryExtractor.SetVersionedAdvisory("CVE-2024-1234", 2, new AdvisoryData
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
Severity = "HIGH"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
@@ -20,4 +21,4 @@
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.Replay.WebService/StellaOps.Replay.WebService.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user