up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -4,7 +4,7 @@ using System.Net;
using System.Net.Http;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Acsc;
using StellaOps.Concelier.Connector.Acsc.Configuration;
using StellaOps.Concelier.Connector.Common;
@@ -13,9 +13,9 @@ using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.Acsc.Tests.Acsc;
namespace StellaOps.Concelier.Connector.Acsc.Tests.Acsc;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class AcscConnectorFetchTests
{
@@ -51,24 +51,24 @@ public sealed class AcscConnectorFetchTests
var state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.Equal("Direct", state!.Cursor.GetValue("preferredEndpoint").AsString);
var feeds = state.Cursor.GetValue("feeds").AsBsonDocument;
Assert.True(feeds.TryGetValue("alerts", out var published));
Assert.Equal(DateTime.Parse("2025-10-11T05:30:00Z").ToUniversalTime(), published.ToUniversalTime());
var pendingDocuments = state.Cursor.GetValue("pendingDocuments").AsBsonArray;
var feeds = state.Cursor.GetValue("feeds").AsDocumentObject;
Assert.True(feeds.TryGetValue("alerts", out var published));
Assert.Equal(DateTime.Parse("2025-10-11T05:30:00Z").ToUniversalTime(), published.ToUniversalTime());
var pendingDocuments = state.Cursor.GetValue("pendingDocuments").AsDocumentArray;
Assert.Single(pendingDocuments);
var documentStore = harness.ServiceProvider.GetRequiredService<IDocumentStore>();
var documentId = Guid.Parse(pendingDocuments[0]!.AsString);
var document = await documentStore.FindAsync(documentId, CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.PendingParse, document!.Status);
var directMetadata = document.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal);
Assert.True(directMetadata.TryGetValue("acsc.fetch.mode", out var mode));
Assert.Equal("direct", mode);
}
Assert.Equal(DocumentStatuses.PendingParse, document!.Status);
var directMetadata = document.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal);
Assert.True(directMetadata.TryGetValue("acsc.fetch.mode", out var mode));
Assert.Equal("direct", mode);
}
[Fact]
public async Task FetchAsync_DirectFailureFallsBackToRelay()
{
@@ -90,20 +90,20 @@ public sealed class AcscConnectorFetchTests
var state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.Equal("Relay", state!.Cursor.GetValue("preferredEndpoint").AsString);
var feeds = state.Cursor.GetValue("feeds").AsBsonDocument;
Assert.True(feeds.TryGetValue("alerts", out var published));
Assert.Equal(DateTime.Parse("2025-10-11T00:00:00Z").ToUniversalTime(), published.ToUniversalTime());
var pendingDocuments = state.Cursor.GetValue("pendingDocuments").AsBsonArray;
var feeds = state.Cursor.GetValue("feeds").AsDocumentObject;
Assert.True(feeds.TryGetValue("alerts", out var published));
Assert.Equal(DateTime.Parse("2025-10-11T00:00:00Z").ToUniversalTime(), published.ToUniversalTime());
var pendingDocuments = state.Cursor.GetValue("pendingDocuments").AsDocumentArray;
Assert.Single(pendingDocuments);
var documentStore = harness.ServiceProvider.GetRequiredService<IDocumentStore>();
var documentId = Guid.Parse(pendingDocuments[0]!.AsString);
var document = await documentStore.FindAsync(documentId, CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.PendingParse, document!.Status);
var metadata = document.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal);
Assert.Equal(DocumentStatuses.PendingParse, document!.Status);
var metadata = document.Metadata ?? new Dictionary<string, string>(StringComparer.Ordinal);
Assert.True(metadata.TryGetValue("acsc.fetch.mode", out var mode));
Assert.Equal("relay", mode);
@@ -112,10 +112,10 @@ public sealed class AcscConnectorFetchTests
{
Assert.Equal(HttpMethod.Get, request.Method);
Assert.Equal(AlertsDirectUri, request.Uri);
},
request =>
{
Assert.Equal(HttpMethod.Get, request.Method);
},
request =>
{
Assert.Equal(HttpMethod.Get, request.Method);
Assert.Equal(AlertsRelayUri, request.Uri);
});
}
@@ -160,34 +160,34 @@ public sealed class AcscConnectorFetchTests
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/rss+xml"),
};
response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue($"\"{mode}-etag\"");
response.Content.Headers.LastModified = second;
return response;
});
}
private static string CreateRssPayload(DateTimeOffset first, DateTimeOffset second)
{
return $$"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Alerts</title>
<link>https://origin.example/feeds/alerts</link>
<item>
<title>First</title>
<link>https://origin.example/alerts/first</link>
<pubDate>{{first.ToString("r", CultureInfo.InvariantCulture)}}</pubDate>
</item>
<item>
<title>Second</title>
<link>https://origin.example/alerts/second</link>
<pubDate>{{second.ToString("r", CultureInfo.InvariantCulture)}}</pubDate>
</item>
</channel>
</rss>
""";
}
}
};
response.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue($"\"{mode}-etag\"");
response.Content.Headers.LastModified = second;
return response;
});
}
private static string CreateRssPayload(DateTimeOffset first, DateTimeOffset second)
{
return $$"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Alerts</title>
<link>https://origin.example/feeds/alerts</link>
<item>
<title>First</title>
<link>https://origin.example/alerts/first</link>
<pubDate>{{first.ToString("r", CultureInfo.InvariantCulture)}}</pubDate>
</item>
<item>
<title>Second</title>
<link>https://origin.example/alerts/second</link>
<pubDate>{{second.ToString("r", CultureInfo.InvariantCulture)}}</pubDate>
</item>
</channel>
</rss>
""";
}
}

View File

@@ -1,10 +1,10 @@
using System.Linq;
using System.Net;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Acsc;
using StellaOps.Concelier.Connector.Acsc.Configuration;
@@ -15,9 +15,9 @@ using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.Acsc.Tests.Acsc;
namespace StellaOps.Concelier.Connector.Acsc.Tests.Acsc;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class AcscConnectorParseTests
{
@@ -29,7 +29,7 @@ public sealed class AcscConnectorParseTests
{
_fixture = fixture;
}
[Fact]
public async Task ParseAsync_PersistsDtoAndAdvancesCursor()
{
@@ -56,41 +56,41 @@ public sealed class AcscConnectorParseTests
var dtoRecord = await dtoStore.FindByDocumentIdAsync(document.Id, CancellationToken.None);
Assert.NotNull(dtoRecord);
Assert.Equal("acsc.feed.v1", dtoRecord!.SchemaVersion);
var payload = dtoRecord.Payload;
Assert.NotNull(payload);
var payload = dtoRecord.Payload;
Assert.NotNull(payload);
Assert.Equal("alerts", payload.GetValue("feedSlug").AsString);
Assert.Single(payload.GetValue("entries").AsBsonArray);
Assert.Single(payload.GetValue("entries").AsDocumentArray);
var stateRepository = harness.ServiceProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.DoesNotContain(document.Id.ToString(), state!.Cursor.GetValue("pendingDocuments").AsBsonArray.Select(v => v.AsString));
Assert.Contains(document.Id.ToString(), state.Cursor.GetValue("pendingMappings").AsBsonArray.Select(v => v.AsString));
Assert.DoesNotContain(document.Id.ToString(), state!.Cursor.GetValue("pendingDocuments").AsDocumentArray.Select(v => v.AsString));
Assert.Contains(document.Id.ToString(), state.Cursor.GetValue("pendingMappings").AsDocumentArray.Select(v => v.AsString));
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
var advisoriesStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoriesStore.GetRecentAsync(10, CancellationToken.None);
Assert.Single(advisories);
var ordered = advisories
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
.ToArray();
WriteOrAssertSnapshot(
SnapshotSerializer.ToSnapshot(ordered),
"acsc-advisories.snapshot.json");
var mappedDocument = await documentStore.FindAsync(document.Id, CancellationToken.None);
Assert.NotNull(mappedDocument);
Assert.Equal(DocumentStatuses.Mapped, mappedDocument!.Status);
state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.GetValue("pendingMappings").AsBsonArray.Count == 0);
}
var ordered = advisories
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
.ToArray();
WriteOrAssertSnapshot(
SnapshotSerializer.ToSnapshot(ordered),
"acsc-advisories.snapshot.json");
var mappedDocument = await documentStore.FindAsync(document.Id, CancellationToken.None);
Assert.NotNull(mappedDocument);
Assert.Equal(DocumentStatuses.Mapped, mappedDocument!.Status);
state = await stateRepository.TryGetAsync(AcscConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.GetValue("pendingMappings").AsDocumentArray.Count == 0);
}
[Fact]
public async Task MapAsync_MultiEntryFeedProducesExpectedSnapshot()
{
@@ -117,12 +117,12 @@ public sealed class AcscConnectorParseTests
var document = await documentStore.FindBySourceAndUriAsync(AcscConnectorPlugin.SourceName, feedUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
var dtoRecord = await dtoStore.FindByDocumentIdAsync(document!.Id, CancellationToken.None);
Assert.NotNull(dtoRecord);
var payload = dtoRecord!.Payload;
Assert.NotNull(payload);
var entries = payload.GetValue("entries").AsBsonArray;
Assert.Equal(2, entries.Count);
var fields = entries[0].AsBsonDocument.GetValue("fields").AsBsonDocument;
Assert.NotNull(dtoRecord);
var payload = dtoRecord!.Payload;
Assert.NotNull(payload);
var entries = payload.GetValue("entries").AsDocumentArray;
Assert.Equal(2, entries.Count);
var fields = entries[0].AsDocumentObject.GetValue("fields").AsDocumentObject;
Assert.Equal("Critical", fields.GetValue("severity").AsString);
Assert.Equal("ExampleCo Router X, ExampleCo Router Y", fields.GetValue("systemsAffected").AsString);
@@ -131,20 +131,20 @@ public sealed class AcscConnectorParseTests
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var ordered = advisories
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
.ToArray();
WriteOrAssertSnapshot(
SnapshotSerializer.ToSnapshot(ordered),
"acsc-advisories-multi.snapshot.json");
var affected = ordered.First(advisory => advisory.AffectedPackages.Any());
Assert.Contains("ExampleCo Router X", affected.AffectedPackages[0].Identifier);
Assert.Equal("critical", ordered.First(a => a.Severity is not null).Severity, StringComparer.OrdinalIgnoreCase);
}
var ordered = advisories
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
.ToArray();
WriteOrAssertSnapshot(
SnapshotSerializer.ToSnapshot(ordered),
"acsc-advisories-multi.snapshot.json");
var affected = ordered.First(advisory => advisory.AffectedPackages.Any());
Assert.Contains("ExampleCo Router X", affected.AffectedPackages[0].Identifier);
Assert.Equal("critical", ordered.First(a => a.Severity is not null).Severity, StringComparer.OrdinalIgnoreCase);
}
private async Task<ConnectorTestHarness> BuildHarnessAsync(Action<AcscOptions>? configure = null)
{
var initialTime = new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero);
@@ -177,29 +177,29 @@ public sealed class AcscConnectorParseTests
});
return harness;
}
private static void SeedRssResponse(CannedHttpMessageHandler handler, Uri uri)
{
const string payload = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>ACSC Alerts</title>
<link>https://origin.example/feeds/alerts</link>
<lastBuildDate>Sun, 12 Oct 2025 04:20:00 GMT</lastBuildDate>
<item>
<title>ACSC-2025-001 Example Advisory</title>
<link>https://origin.example/advisories/example</link>
<guid>https://origin.example/advisories/example</guid>
<pubDate>Sun, 12 Oct 2025 03:00:00 GMT</pubDate>
<content:encoded><![CDATA[
<p><strong>Serial number:</strong> ACSC-2025-001</p>
<p><strong>Advisory type:</strong> Alert</p>
<p>First paragraph describing issue.</p>
<p>Second paragraph with <a href="https://vendor.example/patch">Vendor patch</a>.</p>
]]></content:encoded>
</item>
</channel>
<channel>
<title>ACSC Alerts</title>
<link>https://origin.example/feeds/alerts</link>
<lastBuildDate>Sun, 12 Oct 2025 04:20:00 GMT</lastBuildDate>
<item>
<title>ACSC-2025-001 Example Advisory</title>
<link>https://origin.example/advisories/example</link>
<guid>https://origin.example/advisories/example</guid>
<pubDate>Sun, 12 Oct 2025 03:00:00 GMT</pubDate>
<content:encoded><![CDATA[
<p><strong>Serial number:</strong> ACSC-2025-001</p>
<p><strong>Advisory type:</strong> Alert</p>
<p>First paragraph describing issue.</p>
<p>Second paragraph with <a href="https://vendor.example/patch">Vendor patch</a>.</p>
]]></content:encoded>
</item>
</channel>
</rss>
""";
@@ -208,9 +208,9 @@ public sealed class AcscConnectorParseTests
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/rss+xml"),
};
response.Headers.ETag = new EntityTagHeaderValue("\"parse-etag\"");
response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 12, 4, 20, 0, TimeSpan.Zero);
};
response.Headers.ETag = new EntityTagHeaderValue("\"parse-etag\"");
response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 12, 4, 20, 0, TimeSpan.Zero);
return response;
});
}
@@ -220,35 +220,35 @@ public sealed class AcscConnectorParseTests
const string payload = """
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>ACSC Advisories</title>
<link>https://origin.example/feeds/advisories</link>
<lastBuildDate>Sun, 12 Oct 2025 05:00:00 GMT</lastBuildDate>
<item>
<title>Critical router vulnerability</title>
<link>https://origin.example/advisories/router-critical</link>
<guid>https://origin.example/advisories/router-critical</guid>
<pubDate>Sun, 12 Oct 2025 04:45:00 GMT</pubDate>
<content:encoded><![CDATA[
<p><strong>Serial number:</strong> ACSC-2025-010</p>
<p><strong>Severity:</strong> Critical</p>
<p><strong>Systems affected:</strong> ExampleCo Router X, ExampleCo Router Y</p>
<p>Remote code execution on ExampleCo routers. See <a href="https://vendor.example/router/patch">vendor patch</a>.</p>
<p>CVE references: CVE-2025-0001</p>
]]></content:encoded>
</item>
<item>
<title>Information bulletin</title>
<link>https://origin.example/advisories/info-bulletin</link>
<guid>https://origin.example/advisories/info-bulletin</guid>
<pubDate>Sun, 12 Oct 2025 02:30:00 GMT</pubDate>
<content:encoded><![CDATA[
<p><strong>Serial number:</strong> ACSC-2025-011</p>
<p><strong>Advisory type:</strong> Bulletin</p>
<p>General guidance bulletin.</p>
]]></content:encoded>
</item>
</channel>
<channel>
<title>ACSC Advisories</title>
<link>https://origin.example/feeds/advisories</link>
<lastBuildDate>Sun, 12 Oct 2025 05:00:00 GMT</lastBuildDate>
<item>
<title>Critical router vulnerability</title>
<link>https://origin.example/advisories/router-critical</link>
<guid>https://origin.example/advisories/router-critical</guid>
<pubDate>Sun, 12 Oct 2025 04:45:00 GMT</pubDate>
<content:encoded><![CDATA[
<p><strong>Serial number:</strong> ACSC-2025-010</p>
<p><strong>Severity:</strong> Critical</p>
<p><strong>Systems affected:</strong> ExampleCo Router X, ExampleCo Router Y</p>
<p>Remote code execution on ExampleCo routers. See <a href="https://vendor.example/router/patch">vendor patch</a>.</p>
<p>CVE references: CVE-2025-0001</p>
]]></content:encoded>
</item>
<item>
<title>Information bulletin</title>
<link>https://origin.example/advisories/info-bulletin</link>
<guid>https://origin.example/advisories/info-bulletin</guid>
<pubDate>Sun, 12 Oct 2025 02:30:00 GMT</pubDate>
<content:encoded><![CDATA[
<p><strong>Serial number:</strong> ACSC-2025-011</p>
<p><strong>Advisory type:</strong> Bulletin</p>
<p>General guidance bulletin.</p>
]]></content:encoded>
</item>
</channel>
</rss>
""";
@@ -257,82 +257,82 @@ public sealed class AcscConnectorParseTests
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/rss+xml"),
};
response.Headers.ETag = new EntityTagHeaderValue("\"multi-etag\"");
response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 12, 5, 0, 0, TimeSpan.Zero);
return response;
});
}
private static void WriteOrAssertSnapshot(string snapshot, string filename)
{
if (ShouldUpdateFixtures() || !FixtureExists(filename))
{
var writable = GetWritableFixturePath(filename);
Directory.CreateDirectory(Path.GetDirectoryName(writable)!);
File.WriteAllText(writable, Normalize(snapshot));
return;
}
var expected = Normalize(File.ReadAllText(GetExistingFixturePath(filename)));
var actual = Normalize(snapshot);
if (!string.Equals(expected, actual, StringComparison.Ordinal))
{
var actualPath = Path.Combine(Path.GetDirectoryName(GetWritableFixturePath(filename))!, Path.GetFileNameWithoutExtension(filename) + ".actual.json");
File.WriteAllText(actualPath, actual);
}
Assert.Equal(expected, actual);
}
private static bool ShouldUpdateFixtures()
{
var value = Environment.GetEnvironmentVariable("UPDATE_ACSC_FIXTURES");
return string.Equals(value, "1", StringComparison.Ordinal)
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
private static string GetExistingFixturePath(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primary = Path.Combine(baseDir, "Acsc", "Fixtures", filename);
if (File.Exists(primary))
{
return primary;
}
var secondary = Path.Combine(baseDir, "Fixtures", filename);
if (File.Exists(secondary))
{
return secondary;
}
var projectRelative = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Acsc", "Fixtures", filename);
if (File.Exists(projectRelative))
{
return Path.GetFullPath(projectRelative);
}
throw new FileNotFoundException($"Fixture '{filename}' not found.", filename);
}
private static string GetWritableFixturePath(string filename)
=> Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Acsc", "Fixtures", filename);
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).Trim();
private static bool FixtureExists(string filename)
{
try
{
_ = GetExistingFixturePath(filename);
return true;
}
catch (FileNotFoundException)
{
return false;
}
}
}
};
response.Headers.ETag = new EntityTagHeaderValue("\"multi-etag\"");
response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 12, 5, 0, 0, TimeSpan.Zero);
return response;
});
}
private static void WriteOrAssertSnapshot(string snapshot, string filename)
{
if (ShouldUpdateFixtures() || !FixtureExists(filename))
{
var writable = GetWritableFixturePath(filename);
Directory.CreateDirectory(Path.GetDirectoryName(writable)!);
File.WriteAllText(writable, Normalize(snapshot));
return;
}
var expected = Normalize(File.ReadAllText(GetExistingFixturePath(filename)));
var actual = Normalize(snapshot);
if (!string.Equals(expected, actual, StringComparison.Ordinal))
{
var actualPath = Path.Combine(Path.GetDirectoryName(GetWritableFixturePath(filename))!, Path.GetFileNameWithoutExtension(filename) + ".actual.json");
File.WriteAllText(actualPath, actual);
}
Assert.Equal(expected, actual);
}
private static bool ShouldUpdateFixtures()
{
var value = Environment.GetEnvironmentVariable("UPDATE_ACSC_FIXTURES");
return string.Equals(value, "1", StringComparison.Ordinal)
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
private static string GetExistingFixturePath(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primary = Path.Combine(baseDir, "Acsc", "Fixtures", filename);
if (File.Exists(primary))
{
return primary;
}
var secondary = Path.Combine(baseDir, "Fixtures", filename);
if (File.Exists(secondary))
{
return secondary;
}
var projectRelative = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Acsc", "Fixtures", filename);
if (File.Exists(projectRelative))
{
return Path.GetFullPath(projectRelative);
}
throw new FileNotFoundException($"Fixture '{filename}' not found.", filename);
}
private static string GetWritableFixturePath(string filename)
=> Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Acsc", "Fixtures", filename);
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).Trim();
private static bool FixtureExists(string filename)
{
try
{
_ = GetExistingFixturePath(filename);
return true;
}
catch (FileNotFoundException)
{
return false;
}
}
}

View File

@@ -1,43 +1,43 @@
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.Acsc.Configuration;
using StellaOps.Concelier.Connector.Common.Http;
using Xunit;
namespace StellaOps.Concelier.Connector.Acsc.Tests.Acsc;
public sealed class AcscHttpClientConfigurationTests
{
[Fact]
public void AddAcscConnector_ConfiguresHttpClientOptions()
{
var services = new ServiceCollection();
services.AddAcscConnector(options =>
{
options.BaseEndpoint = new Uri("https://origin.example/");
options.RelayEndpoint = new Uri("https://relay.example/");
options.RequestTimeout = TimeSpan.FromSeconds(42);
options.Feeds.Clear();
options.Feeds.Add(new AcscFeedOptions
{
Slug = "alerts",
RelativePath = "/feeds/alerts/rss",
Enabled = true,
});
});
var provider = services.BuildServiceProvider();
var monitor = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>();
var options = monitor.Get(AcscOptions.HttpClientName);
Assert.Equal("StellaOps/Concelier (+https://stella-ops.org)", options.UserAgent);
Assert.Equal(HttpVersion.Version20, options.RequestVersion);
Assert.Equal(HttpVersionPolicy.RequestVersionOrLower, options.VersionPolicy);
Assert.Equal(TimeSpan.FromSeconds(42), options.Timeout);
Assert.Contains("origin.example", options.AllowedHosts, StringComparer.OrdinalIgnoreCase);
Assert.Contains("relay.example", options.AllowedHosts, StringComparer.OrdinalIgnoreCase);
Assert.Equal("application/rss+xml, application/atom+xml;q=0.9, application/xml;q=0.8, text/xml;q=0.7", options.DefaultRequestHeaders["Accept"]);
}
}
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.Acsc.Configuration;
using StellaOps.Concelier.Connector.Common.Http;
using Xunit;
namespace StellaOps.Concelier.Connector.Acsc.Tests.Acsc;
public sealed class AcscHttpClientConfigurationTests
{
[Fact]
public void AddAcscConnector_ConfiguresHttpClientOptions()
{
var services = new ServiceCollection();
services.AddAcscConnector(options =>
{
options.BaseEndpoint = new Uri("https://origin.example/");
options.RelayEndpoint = new Uri("https://relay.example/");
options.RequestTimeout = TimeSpan.FromSeconds(42);
options.Feeds.Clear();
options.Feeds.Add(new AcscFeedOptions
{
Slug = "alerts",
RelativePath = "/feeds/alerts/rss",
Enabled = true,
});
});
var provider = services.BuildServiceProvider();
var monitor = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>();
var options = monitor.Get(AcscOptions.HttpClientName);
Assert.Equal("StellaOps/Concelier (+https://stella-ops.org)", options.UserAgent);
Assert.Equal(HttpVersion.Version20, options.RequestVersion);
Assert.Equal(HttpVersionPolicy.RequestVersionOrLower, options.VersionPolicy);
Assert.Equal(TimeSpan.FromSeconds(42), options.Timeout);
Assert.Contains("origin.example", options.AllowedHosts, StringComparer.OrdinalIgnoreCase);
Assert.Contains("relay.example", options.AllowedHosts, StringComparer.OrdinalIgnoreCase);
Assert.Equal("application/rss+xml, application/atom+xml;q=0.9, application/xml;q=0.8, text/xml;q=0.7", options.DefaultRequestHeaders["Accept"]);
}
}

View File

@@ -1,12 +1,12 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Cccs;
using StellaOps.Concelier.Connector.Cccs.Configuration;
using StellaOps.Concelier.Connector.Common;
@@ -15,13 +15,13 @@ using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.Cccs.Tests;
namespace StellaOps.Concelier.Connector.Cccs.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class CccsConnectorTests
{
private static readonly Uri FeedUri = new("https://test.local/api/cccs/threats/v1/get?lang=en&content_type=cccs_threat");
private static readonly Uri FeedUri = new("https://test.local/api/cccs/threats/v1/get?lang=en&content_type=cccs_threat");
private static readonly Uri TaxonomyUri = new("https://test.local/api/cccs/taxonomy/v1/get?lang=en&vocabulary=cccs_alert_type");
private readonly ConcelierPostgresFixture _fixture;
@@ -30,7 +30,7 @@ public sealed class CccsConnectorTests
{
_fixture = fixture;
}
[Fact]
public async Task FetchParseMap_ProducesCanonicalAdvisory()
{
@@ -45,26 +45,26 @@ public sealed class CccsConnectorTests
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
advisories.Should().HaveCount(1);
var advisory = advisories[0];
advisory.AdvisoryKey.Should().Be("TEST-001");
advisory.Title.Should().Be("Test Advisory Title");
advisory.Aliases.Should().Contain(new[] { "TEST-001", "CVE-2020-1234", "CVE-2021-9999" });
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details");
advisory.References.Should().Contain(reference => reference.Url == "https://www.cyber.gc.ca/en/contact-cyber-centre?lang=en");
advisory.AffectedPackages.Should().ContainSingle(pkg => pkg.Identifier == "Vendor Widget 1.0");
advisory.AffectedPackages.Should().Contain(pkg => pkg.Identifier == "Vendor Widget 2.0");
var advisory = advisories[0];
advisory.AdvisoryKey.Should().Be("TEST-001");
advisory.Title.Should().Be("Test Advisory Title");
advisory.Aliases.Should().Contain(new[] { "TEST-001", "CVE-2020-1234", "CVE-2021-9999" });
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details");
advisory.References.Should().Contain(reference => reference.Url == "https://www.cyber.gc.ca/en/contact-cyber-centre?lang=en");
advisory.AffectedPackages.Should().ContainSingle(pkg => pkg.Identifier == "Vendor Widget 1.0");
advisory.AffectedPackages.Should().Contain(pkg => pkg.Identifier == "Vendor Widget 2.0");
var stateRepository = harness.ServiceProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CccsConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
pendingDocs!.AsBsonArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();
pendingMappings!.AsBsonArray.Should().BeEmpty();
}
state!.Cursor.Should().NotBeNull();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
pendingDocs!.AsDocumentArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();
pendingMappings!.AsDocumentArray.Should().BeEmpty();
}
[Fact]
public async Task Fetch_PersistsRawDocumentWithMetadata()
{
@@ -79,10 +79,10 @@ public sealed class CccsConnectorTests
document.Should().NotBeNull();
document!.Status.Should().Be(DocumentStatuses.PendingParse);
document.Metadata.Should().ContainKey("cccs.language").WhoseValue.Should().Be("en");
document.Metadata.Should().ContainKey("cccs.serialNumber").WhoseValue.Should().Be("TEST-001");
document.ContentType.Should().Be("application/json");
}
document.Metadata.Should().ContainKey("cccs.serialNumber").WhoseValue.Should().Be("TEST-001");
document.ContentType.Should().Be("application/json");
}
private async Task<ConnectorTestHarness> BuildHarnessAsync()
{
var initialTime = new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero);
@@ -114,11 +114,11 @@ public sealed class CccsConnectorTests
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
if (!string.IsNullOrWhiteSpace(etag))
{
response.Headers.ETag = new EntityTagHeaderValue(etag);
}
};
if (!string.IsNullOrWhiteSpace(etag))
{
response.Headers.ETag = new EntityTagHeaderValue(etag);
}
return response;
});

View File

@@ -1,92 +1,92 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Concelier.Connector.Cccs.Internal;
using StellaOps.Concelier.Connector.Common.Html;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Connector.Cccs.Tests.Internal;
public sealed class CccsHtmlParserTests
{
private readonly ITestOutputHelper _output;
private static readonly HtmlContentSanitizer Sanitizer = new();
private static readonly CccsHtmlParser Parser = new(Sanitizer);
public CccsHtmlParserTests(ITestOutputHelper output)
{
_output = output ?? throw new ArgumentNullException(nameof(output));
}
public static IEnumerable<object[]> ParserCases()
{
yield return new object[]
{
"cccs-raw-advisory.json",
"TEST-001",
"en",
new[] { "Vendor Widget 1.0", "Vendor Widget 2.0" },
new[]
{
"https://example.com/details",
"https://www.cyber.gc.ca/en/contact-cyber-centre?lang=en"
},
new[] { "CVE-2020-1234", "CVE-2021-9999" }
};
yield return new object[]
{
"cccs-raw-advisory-fr.json",
"TEST-002-FR",
"fr",
new[] { "Produit Exemple 3.1", "Produit Exemple 3.2", "Variante 3.2.1" },
new[]
{
"https://exemple.ca/details",
"https://www.cyber.gc.ca/fr/contact-centre-cyber"
},
new[] { "CVE-2024-1111" }
};
}
[Theory]
[MemberData(nameof(ParserCases))]
public void Parse_ExtractsExpectedFields(
string fixtureName,
string expectedSerial,
string expectedLanguage,
string[] expectedProducts,
string[] expectedReferenceUrls,
string[] expectedCves)
{
var raw = LoadFixture<CccsRawAdvisoryDocument>(fixtureName);
var dto = Parser.Parse(raw);
_output.WriteLine("Products: {0}", string.Join("|", dto.Products));
_output.WriteLine("References: {0}", string.Join("|", dto.References.Select(r => $"{r.Url} ({r.Label})")));
_output.WriteLine("CVEs: {0}", string.Join("|", dto.CveIds));
dto.SerialNumber.Should().Be(expectedSerial);
dto.Language.Should().Be(expectedLanguage);
dto.Products.Should().BeEquivalentTo(expectedProducts);
foreach (var url in expectedReferenceUrls)
{
dto.References.Should().Contain(reference => reference.Url == url);
}
dto.CveIds.Should().BeEquivalentTo(expectedCves);
dto.ContentHtml.Should().Contain("<ul>").And.Contain("<li>");
dto.ContentHtml.Should().Contain("<h2", because: "heading structure must survive sanitisation for UI rendering");
}
internal static T LoadFixture<T>(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
}
}
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Concelier.Connector.Cccs.Internal;
using StellaOps.Concelier.Connector.Common.Html;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Connector.Cccs.Tests.Internal;
public sealed class CccsHtmlParserTests
{
private readonly ITestOutputHelper _output;
private static readonly HtmlContentSanitizer Sanitizer = new();
private static readonly CccsHtmlParser Parser = new(Sanitizer);
public CccsHtmlParserTests(ITestOutputHelper output)
{
_output = output ?? throw new ArgumentNullException(nameof(output));
}
public static IEnumerable<object[]> ParserCases()
{
yield return new object[]
{
"cccs-raw-advisory.json",
"TEST-001",
"en",
new[] { "Vendor Widget 1.0", "Vendor Widget 2.0" },
new[]
{
"https://example.com/details",
"https://www.cyber.gc.ca/en/contact-cyber-centre?lang=en"
},
new[] { "CVE-2020-1234", "CVE-2021-9999" }
};
yield return new object[]
{
"cccs-raw-advisory-fr.json",
"TEST-002-FR",
"fr",
new[] { "Produit Exemple 3.1", "Produit Exemple 3.2", "Variante 3.2.1" },
new[]
{
"https://exemple.ca/details",
"https://www.cyber.gc.ca/fr/contact-centre-cyber"
},
new[] { "CVE-2024-1111" }
};
}
[Theory]
[MemberData(nameof(ParserCases))]
public void Parse_ExtractsExpectedFields(
string fixtureName,
string expectedSerial,
string expectedLanguage,
string[] expectedProducts,
string[] expectedReferenceUrls,
string[] expectedCves)
{
var raw = LoadFixture<CccsRawAdvisoryDocument>(fixtureName);
var dto = Parser.Parse(raw);
_output.WriteLine("Products: {0}", string.Join("|", dto.Products));
_output.WriteLine("References: {0}", string.Join("|", dto.References.Select(r => $"{r.Url} ({r.Label})")));
_output.WriteLine("CVEs: {0}", string.Join("|", dto.CveIds));
dto.SerialNumber.Should().Be(expectedSerial);
dto.Language.Should().Be(expectedLanguage);
dto.Products.Should().BeEquivalentTo(expectedProducts);
foreach (var url in expectedReferenceUrls)
{
dto.References.Should().Contain(reference => reference.Url == url);
}
dto.CveIds.Should().BeEquivalentTo(expectedCves);
dto.ContentHtml.Should().Contain("<ul>").And.Contain("<li>");
dto.ContentHtml.Should().Contain("<h2", because: "heading structure must survive sanitisation for UI rendering");
}
internal static T LoadFixture<T>(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
}
}

View File

@@ -1,12 +1,12 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.CertBund.Configuration;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Testing;
@@ -15,14 +15,14 @@ using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.CertBund.Tests;
namespace StellaOps.Concelier.Connector.CertBund.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class CertBundConnectorTests
{
private static readonly Uri FeedUri = new("https://test.local/content/public/securityAdvisory/rss");
private static readonly Uri PortalUri = new("https://test.local/portal/");
private static readonly Uri FeedUri = new("https://test.local/content/public/securityAdvisory/rss");
private static readonly Uri PortalUri = new("https://test.local/portal/");
private static readonly Uri DetailUri = new("https://test.local/portal/api/securityadvisory?name=WID-SEC-2025-2264");
private readonly ConcelierPostgresFixture _fixture;
@@ -31,7 +31,7 @@ public sealed class CertBundConnectorTests
{
_fixture = fixture;
}
[Fact]
public async Task FetchParseMap_ProducesCanonicalAdvisory()
{
@@ -46,35 +46,35 @@ public sealed class CertBundConnectorTests
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
advisories.Should().HaveCount(1);
var advisory = advisories[0];
advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264");
advisory.Aliases.Should().Contain("CVE-2025-1234");
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti"));
advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString());
advisory.Language.Should().Be("de");
var endpoint = advisory.AffectedPackages.Should().ContainSingle(p => p.Identifier.Contains("Endpoint Manager") && !p.Identifier.Contains("Cloud"))
.Subject;
endpoint.VersionRanges.Should().ContainSingle(range =>
range.RangeKind == NormalizedVersionSchemes.SemVer &&
range.IntroducedVersion == "2023.1" &&
range.FixedVersion == "2024.2");
endpoint.NormalizedVersions.Should().ContainSingle(rule =>
rule.Min == "2023.1" &&
rule.Max == "2024.2" &&
rule.Notes == "certbund:WID-SEC-2025-2264:ivanti");
var advisory = advisories[0];
advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264");
advisory.Aliases.Should().Contain("CVE-2025-1234");
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti"));
advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString());
advisory.Language.Should().Be("de");
var endpoint = advisory.AffectedPackages.Should().ContainSingle(p => p.Identifier.Contains("Endpoint Manager") && !p.Identifier.Contains("Cloud"))
.Subject;
endpoint.VersionRanges.Should().ContainSingle(range =>
range.RangeKind == NormalizedVersionSchemes.SemVer &&
range.IntroducedVersion == "2023.1" &&
range.FixedVersion == "2024.2");
endpoint.NormalizedVersions.Should().ContainSingle(rule =>
rule.Min == "2023.1" &&
rule.Max == "2024.2" &&
rule.Notes == "certbund:WID-SEC-2025-2264:ivanti");
var stateRepository = harness.ServiceProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
pendingDocs!.AsBsonArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();
pendingMappings!.AsBsonArray.Should().BeEmpty();
}
state!.Cursor.Should().NotBeNull();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
pendingDocs!.AsDocumentArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();
pendingMappings!.AsDocumentArray.Should().BeEmpty();
}
[Fact]
public async Task Fetch_PersistsDocumentWithMetadata()
{
@@ -87,17 +87,17 @@ public sealed class CertBundConnectorTests
var documentStore = harness.ServiceProvider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(CertBundConnectorPlugin.SourceName, DetailUri.ToString(), CancellationToken.None);
document.Should().NotBeNull();
document!.Metadata.Should().ContainKey("certbund.advisoryId").WhoseValue.Should().Be("WID-SEC-2025-2264");
document.Metadata.Should().ContainKey("certbund.category");
document!.Metadata.Should().ContainKey("certbund.advisoryId").WhoseValue.Should().Be("WID-SEC-2025-2264");
document.Metadata.Should().ContainKey("certbund.category");
document.Metadata.Should().ContainKey("certbund.published");
document.Status.Should().Be(DocumentStatuses.PendingParse);
var stateRepository = harness.ServiceProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
pendingDocs!.AsBsonArray.Should().HaveCount(1);
pendingDocs!.AsDocumentArray.Should().HaveCount(1);
}
private async Task<ConnectorTestHarness> BuildHarnessAsync()
@@ -133,13 +133,13 @@ public sealed class CertBundConnectorTests
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
if (!string.IsNullOrWhiteSpace(etag))
{
response.Headers.ETag = new EntityTagHeaderValue(etag);
}
return response;
};
if (!string.IsNullOrWhiteSpace(etag))
{
response.Headers.ETag = new EntityTagHeaderValue(etag);
}
return response;
});
}
@@ -158,7 +158,7 @@ public sealed class CertBundConnectorTests
Content = new StringContent(html, Encoding.UTF8, "text/html"),
});
}
private static string ReadFixture(string fileName)
=> System.IO.File.ReadAllText(System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName));
}

View File

@@ -1,20 +1,20 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.CertCc;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.CertCc.Internal;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.CertCc.Internal;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Cursors;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage;
@@ -22,238 +22,238 @@ using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class CertCcConnectorFetchTests : IAsyncLifetime
{
private const string TestNoteId = "294418";
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private ServiceProvider? _serviceProvider;
public CertCcConnectorFetchTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 11, 8, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact(Skip = "Superseded by snapshot regression coverage (FEEDCONN-CERTCC-02-005).")]
public async Task FetchAsync_PersistsSummaryAndDetailDocumentsAndUpdatesCursor()
{
var template = new CertCcOptions
{
BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute),
SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(5),
InitialBackfill = TimeSpan.FromDays(60),
MinimumWindowSize = TimeSpan.FromDays(1),
},
MaxMonthlySummaries = 3,
MaxNotesPerFetch = 3,
DetailRequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(template);
var provider = _serviceProvider!;
_handler.Clear();
var planner = provider.GetRequiredService<CertCcSummaryPlanner>();
var plan = planner.CreatePlan(state: null);
Assert.NotEmpty(plan.Requests);
foreach (var request in plan.Requests)
{
_handler.AddJsonResponse(request.Uri, BuildSummaryPayload());
}
RegisterDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
foreach (var request in plan.Requests)
{
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, request.Uri.ToString(), CancellationToken.None);
Assert.NotNull(record);
Assert.Equal(DocumentStatuses.PendingParse, record!.Status);
Assert.NotNull(record.Metadata);
Assert.Equal(request.Scope.ToString().ToLowerInvariant(), record.Metadata!["certcc.scope"]);
Assert.Equal(request.Year.ToString("D4"), record.Metadata["certcc.year"]);
if (request.Month.HasValue)
{
Assert.Equal(request.Month.Value.ToString("D2"), record.Metadata["certcc.month"]);
}
else
{
Assert.False(record.Metadata.ContainsKey("certcc.month"));
}
}
foreach (var uri in EnumerateDetailUris())
{
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
Assert.NotNull(record);
Assert.Equal(DocumentStatuses.PendingParse, record!.Status);
Assert.NotNull(record.Metadata);
Assert.Equal(TestNoteId, record.Metadata!["certcc.noteId"]);
}
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
BsonValue summaryValue;
Assert.True(state!.Cursor.TryGetValue("summary", out summaryValue));
var summaryDocument = Assert.IsType<BsonDocument>(summaryValue);
Assert.True(summaryDocument.TryGetValue("start", out _));
Assert.True(summaryDocument.TryGetValue("end", out _));
var pendingNotesCount = state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue.AsBsonArray.Count
: 0;
Assert.Equal(0, pendingNotesCount);
var pendingSummariesCount = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue.AsBsonArray.Count
: 0;
Assert.Equal(0, pendingSummariesCount);
Assert.True(state.Cursor.TryGetValue("lastRun", out _));
Assert.True(_handler.Requests.Count >= plan.Requests.Count);
foreach (var request in _handler.Requests)
{
if (request.Headers.TryGetValue("Accept", out var accept))
{
Assert.Contains("application/json", accept, StringComparison.OrdinalIgnoreCase);
}
}
}
private static string BuildSummaryPayload()
{
return $$"""
{
"count": 1,
"notes": [
"VU#{TestNoteId}"
]
}
""";
}
private void RegisterDetailResponses()
{
foreach (var uri in EnumerateDetailUris())
{
var fixtureName = uri.AbsolutePath.EndsWith("/vendors/", StringComparison.OrdinalIgnoreCase)
? "vu-294418-vendors.json"
: uri.AbsolutePath.EndsWith("/vuls/", StringComparison.OrdinalIgnoreCase)
? "vu-294418-vuls.json"
: "vu-294418.json";
_handler.AddJsonResponse(uri, ReadFixture(fixtureName));
}
}
private static IEnumerable<Uri> EnumerateDetailUris()
{
var baseUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute);
yield return new Uri(baseUri, $"{TestNoteId}/");
yield return new Uri(baseUri, $"{TestNoteId}/vendors/");
yield return new Uri(baseUri, $"{TestNoteId}/vuls/");
}
public sealed class CertCcConnectorFetchTests : IAsyncLifetime
{
private const string TestNoteId = "294418";
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private ServiceProvider? _serviceProvider;
public CertCcConnectorFetchTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 11, 8, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact(Skip = "Superseded by snapshot regression coverage (FEEDCONN-CERTCC-02-005).")]
public async Task FetchAsync_PersistsSummaryAndDetailDocumentsAndUpdatesCursor()
{
var template = new CertCcOptions
{
BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute),
SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(5),
InitialBackfill = TimeSpan.FromDays(60),
MinimumWindowSize = TimeSpan.FromDays(1),
},
MaxMonthlySummaries = 3,
MaxNotesPerFetch = 3,
DetailRequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(template);
var provider = _serviceProvider!;
_handler.Clear();
var planner = provider.GetRequiredService<CertCcSummaryPlanner>();
var plan = planner.CreatePlan(state: null);
Assert.NotEmpty(plan.Requests);
foreach (var request in plan.Requests)
{
_handler.AddJsonResponse(request.Uri, BuildSummaryPayload());
}
RegisterDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
foreach (var request in plan.Requests)
{
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, request.Uri.ToString(), CancellationToken.None);
Assert.NotNull(record);
Assert.Equal(DocumentStatuses.PendingParse, record!.Status);
Assert.NotNull(record.Metadata);
Assert.Equal(request.Scope.ToString().ToLowerInvariant(), record.Metadata!["certcc.scope"]);
Assert.Equal(request.Year.ToString("D4"), record.Metadata["certcc.year"]);
if (request.Month.HasValue)
{
Assert.Equal(request.Month.Value.ToString("D2"), record.Metadata["certcc.month"]);
}
else
{
Assert.False(record.Metadata.ContainsKey("certcc.month"));
}
}
foreach (var uri in EnumerateDetailUris())
{
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
Assert.NotNull(record);
Assert.Equal(DocumentStatuses.PendingParse, record!.Status);
Assert.NotNull(record.Metadata);
Assert.Equal(TestNoteId, record.Metadata!["certcc.noteId"]);
}
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
DocumentValue summaryValue;
Assert.True(state!.Cursor.TryGetValue("summary", out summaryValue));
var summaryDocument = Assert.IsType<DocumentObject>(summaryValue);
Assert.True(summaryDocument.TryGetValue("start", out _));
Assert.True(summaryDocument.TryGetValue("end", out _));
var pendingNotesCount = state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue.AsDocumentArray.Count
: 0;
Assert.Equal(0, pendingNotesCount);
var pendingSummariesCount = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue.AsDocumentArray.Count
: 0;
Assert.Equal(0, pendingSummariesCount);
Assert.True(state.Cursor.TryGetValue("lastRun", out _));
Assert.True(_handler.Requests.Count >= plan.Requests.Count);
foreach (var request in _handler.Requests)
{
if (request.Headers.TryGetValue("Accept", out var accept))
{
Assert.Contains("application/json", accept, StringComparison.OrdinalIgnoreCase);
}
}
}
private static string BuildSummaryPayload()
{
return $$"""
{
"count": 1,
"notes": [
"VU#{TestNoteId}"
]
}
""";
}
private void RegisterDetailResponses()
{
foreach (var uri in EnumerateDetailUris())
{
var fixtureName = uri.AbsolutePath.EndsWith("/vendors/", StringComparison.OrdinalIgnoreCase)
? "vu-294418-vendors.json"
: uri.AbsolutePath.EndsWith("/vuls/", StringComparison.OrdinalIgnoreCase)
? "vu-294418-vuls.json"
: "vu-294418.json";
_handler.AddJsonResponse(uri, ReadFixture(fixtureName));
}
}
private static IEnumerable<Uri> EnumerateDetailUris()
{
var baseUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute);
yield return new Uri(baseUri, $"{TestNoteId}/");
yield return new Uri(baseUri, $"{TestNoteId}/vendors/");
yield return new Uri(baseUri, $"{TestNoteId}/vuls/");
}
private async Task EnsureServiceProviderAsync(CertCcOptions template)
{
await DisposeServiceProviderAsync();
await _fixture.TruncateAllTablesAsync();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddCertCcConnector(options =>
{
options.BaseApiUri = template.BaseApiUri;
options.SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = template.SummaryWindow.WindowSize,
Overlap = template.SummaryWindow.Overlap,
InitialBackfill = template.SummaryWindow.InitialBackfill,
MinimumWindowSize = template.SummaryWindow.MinimumWindowSize,
};
options.MaxMonthlySummaries = template.MaxMonthlySummaries;
options.MaxNotesPerFetch = template.MaxNotesPerFetch;
options.DetailRequestDelay = template.DetailRequestDelay;
options.EnableDetailMapping = template.EnableDetailMapping;
});
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
services.AddSourceCommon();
services.AddCertCcConnector(options =>
{
options.BaseApiUri = template.BaseApiUri;
options.SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = template.SummaryWindow.WindowSize,
Overlap = template.SummaryWindow.Overlap,
InitialBackfill = template.SummaryWindow.InitialBackfill,
MinimumWindowSize = template.SummaryWindow.MinimumWindowSize,
};
options.MaxMonthlySummaries = template.MaxMonthlySummaries;
options.MaxNotesPerFetch = template.MaxNotesPerFetch;
options.DetailRequestDelay = template.DetailRequestDelay;
options.EnableDetailMapping = template.EnableDetailMapping;
});
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
});
_serviceProvider = services.BuildServiceProvider();
}
private async Task DisposeServiceProviderAsync()
{
if (_serviceProvider is null)
{
return;
}
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
_serviceProvider.Dispose();
}
_serviceProvider = null;
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
return File.ReadAllText(Path.Combine(baseDirectory, filename));
}
public Task InitializeAsync()
{
_handler.Clear();
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
await DisposeServiceProviderAsync();
}
}
private async Task DisposeServiceProviderAsync()
{
if (_serviceProvider is null)
{
return;
}
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
_serviceProvider.Dispose();
}
_serviceProvider = null;
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
return File.ReadAllText(Path.Combine(baseDirectory, filename));
}
public Task InitializeAsync()
{
_handler.Clear();
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
await DisposeServiceProviderAsync();
}
}

View File

@@ -1,410 +1,410 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.CertCc;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Cursors;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.CertCc;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Cursors;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class CertCcConnectorSnapshotTests : IAsyncLifetime
{
private static readonly Uri SeptemberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/09/summary/");
private static readonly Uri OctoberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/10/summary/");
private static readonly Uri NovemberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/11/summary/");
private static readonly Uri NoteDetailUri = new("https://www.kb.cert.org/vuls/api/294418/");
private static readonly Uri VendorsDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/");
private static readonly Uri VulsDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vuls/");
private static readonly Uri VendorStatusesDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/vuls/");
private static readonly Uri YearlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/summary/");
private readonly ConcelierPostgresFixture _fixture;
private ConnectorTestHarness? _harness;
public CertCcConnectorSnapshotTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task FetchSummaryAndDetails_ProducesDeterministicSnapshots()
{
var initialTime = new DateTimeOffset(2025, 11, 1, 8, 0, 0, TimeSpan.Zero);
var harness = await EnsureHarnessAsync(initialTime);
RegisterSummaryResponses(harness.Handler);
RegisterDetailResponses(harness.Handler);
var connector = harness.ServiceProvider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
var documentsSnapshot = await BuildDocumentsSnapshotAsync(harness.ServiceProvider);
WriteOrAssertSnapshot(documentsSnapshot, "certcc-documents.snapshot.json");
var stateSnapshot = await BuildStateSnapshotAsync(harness.ServiceProvider);
WriteOrAssertSnapshot(stateSnapshot, "certcc-state.snapshot.json");
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
var advisoriesSnapshot = await BuildAdvisoriesSnapshotAsync(harness.ServiceProvider);
WriteOrAssertSnapshot(advisoriesSnapshot, "certcc-advisories.snapshot.json");
harness.TimeProvider.Advance(TimeSpan.FromMinutes(30));
RegisterSummaryNotModifiedResponses(harness.Handler);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
var recordedRequests = harness.Handler.Requests
.Select(request => request.Uri.ToString())
.ToArray();
recordedRequests.Should().Equal(new[]
{
SeptemberSummaryUri.ToString(),
OctoberSummaryUri.ToString(),
NoteDetailUri.ToString(),
VendorsDetailUri.ToString(),
VulsDetailUri.ToString(),
VendorStatusesDetailUri.ToString(),
YearlySummaryUri.ToString(),
OctoberSummaryUri.ToString(),
NovemberSummaryUri.ToString(),
YearlySummaryUri.ToString(),
});
harness.Handler.AssertNoPendingResponses();
var requestsSnapshot = BuildRequestsSnapshot(harness.Handler.Requests);
WriteOrAssertSnapshot(requestsSnapshot, "certcc-requests.snapshot.json");
}
private async Task<ConnectorTestHarness> EnsureHarnessAsync(DateTimeOffset initialTime)
{
if (_harness is not null)
{
return _harness;
}
var harness = new ConnectorTestHarness(_fixture, initialTime, CertCcOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddCertCcConnector(options =>
{
options.BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute);
options.SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(3),
InitialBackfill = TimeSpan.FromDays(45),
MinimumWindowSize = TimeSpan.FromDays(1),
};
options.MaxMonthlySummaries = 2;
options.MaxNotesPerFetch = 1;
options.DetailRequestDelay = TimeSpan.Zero;
options.EnableDetailMapping = true;
});
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, options =>
{
options.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = harness.Handler);
});
});
_harness = harness;
return harness;
}
private static async Task<string> BuildDocumentsSnapshotAsync(IServiceProvider provider)
{
var documentStore = provider.GetRequiredService<IDocumentStore>();
var uris = new[]
{
SeptemberSummaryUri,
OctoberSummaryUri,
NovemberSummaryUri,
YearlySummaryUri,
NoteDetailUri,
VendorsDetailUri,
VulsDetailUri,
VendorStatusesDetailUri,
};
var records = new List<object>(uris.Length);
foreach (var uri in uris)
{
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
if (record is null)
{
continue;
}
var lastModified = record.Headers is not null
&& record.Headers.TryGetValue("Last-Modified", out var lastModifiedHeader)
&& DateTimeOffset.TryParse(lastModifiedHeader, out var parsedLastModified)
? parsedLastModified.ToUniversalTime().ToString("O")
: record.LastModified?.ToUniversalTime().ToString("O");
records.Add(new
{
record.Uri,
record.Status,
record.Sha256,
record.ContentType,
LastModified = lastModified,
Metadata = record.Metadata is null
? null
: record.Metadata
.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase),
record.Etag,
});
}
var ordered = records
.OrderBy(static entry => entry.GetType().GetProperty("Uri")?.GetValue(entry)?.ToString(), StringComparer.Ordinal)
.ToArray();
return SnapshotSerializer.ToSnapshot(ordered);
}
private static async Task<string> BuildStateSnapshotAsync(IServiceProvider provider)
{
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = state!.Cursor ?? new BsonDocument();
BsonDocument? summaryDocument = null;
if (cursor.TryGetValue("summary", out var summaryValue) && summaryValue is BsonDocument summaryDoc)
{
summaryDocument = summaryDoc;
}
var summary = summaryDocument is null
? null
: new
{
Start = summaryDocument.TryGetValue("start", out var startValue) ? ToIsoString(startValue) : null,
End = summaryDocument.TryGetValue("end", out var endValue) ? ToIsoString(endValue) : null,
};
var snapshot = new
{
Summary = summary,
PendingNotes = cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue.AsBsonArray.Select(static value => value.ToString()).OrderBy(static note => note, StringComparer.OrdinalIgnoreCase).ToArray()
: Array.Empty<string>(),
PendingSummaries = cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue.AsBsonArray.Select(static value => value.ToString()).OrderBy(static item => item, StringComparer.OrdinalIgnoreCase).ToArray()
: Array.Empty<string>(),
LastRun = cursor.TryGetValue("lastRun", out var lastRunValue) ? ToIsoString(lastRunValue) : null,
state.LastSuccess,
state.LastFailure,
state.FailCount,
state.BackoffUntil,
};
return SnapshotSerializer.ToSnapshot(snapshot);
}
private static async Task<string> BuildAdvisoriesSnapshotAsync(IServiceProvider provider)
{
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = new List<Advisory>();
await foreach (var advisory in advisoryStore.StreamAsync(CancellationToken.None))
{
advisories.Add(advisory);
}
var ordered = advisories
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal)
.ToArray();
return SnapshotSerializer.ToSnapshot(ordered);
}
private static string BuildRequestsSnapshot(IReadOnlyCollection<CannedHttpMessageHandler.CannedRequestRecord> requests)
{
var ordered = requests
.OrderBy(static request => request.Timestamp)
.Select(static request => new
{
request.Method.Method,
Uri = request.Uri.ToString(),
Headers = new
{
Accept = TryGetHeader(request.Headers, "Accept"),
IfNoneMatch = TryGetHeader(request.Headers, "If-None-Match"),
IfModifiedSince = TryGetHeader(request.Headers, "If-Modified-Since"),
},
})
.ToArray();
return SnapshotSerializer.ToSnapshot(ordered);
}
private static void RegisterSummaryResponses(CannedHttpMessageHandler handler)
{
AddJsonResponse(handler, SeptemberSummaryUri, "summary-2025-09.json", "\"certcc-summary-2025-09\"", new DateTimeOffset(2025, 9, 30, 12, 0, 0, TimeSpan.Zero));
AddJsonResponse(handler, OctoberSummaryUri, "summary-2025-10.json", "\"certcc-summary-2025-10\"", new DateTimeOffset(2025, 10, 31, 12, 0, 0, TimeSpan.Zero));
AddJsonResponse(handler, YearlySummaryUri, "summary-2025.json", "\"certcc-summary-2025\"", new DateTimeOffset(2025, 10, 31, 12, 1, 0, TimeSpan.Zero));
}
private static void RegisterSummaryNotModifiedResponses(CannedHttpMessageHandler handler)
{
AddNotModified(handler, OctoberSummaryUri, "\"certcc-summary-2025-10\"");
AddNotModified(handler, NovemberSummaryUri, "\"certcc-summary-2025-11\"");
AddNotModified(handler, YearlySummaryUri, "\"certcc-summary-2025\"");
}
private static void RegisterDetailResponses(CannedHttpMessageHandler handler)
{
AddJsonResponse(handler, NoteDetailUri, "vu-294418.json", "\"certcc-note-294418\"", new DateTimeOffset(2025, 10, 9, 16, 52, 0, TimeSpan.Zero));
AddJsonResponse(handler, VendorsDetailUri, "vu-294418-vendors.json", "\"certcc-vendors-294418\"", new DateTimeOffset(2025, 10, 9, 17, 5, 0, TimeSpan.Zero));
AddJsonResponse(handler, VulsDetailUri, "vu-294418-vuls.json", "\"certcc-vuls-294418\"", new DateTimeOffset(2025, 10, 9, 17, 10, 0, TimeSpan.Zero));
AddJsonResponse(handler, VendorStatusesDetailUri, "vendor-statuses-294418.json", "\"certcc-vendor-statuses-294418\"", new DateTimeOffset(2025, 10, 9, 17, 12, 0, TimeSpan.Zero));
}
private static void AddJsonResponse(CannedHttpMessageHandler handler, Uri uri, string fixtureName, string etag, DateTimeOffset lastModified)
{
var payload = ReadFixture(fixtureName);
handler.AddResponse(HttpMethod.Get, uri, _ =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue(etag);
response.Headers.TryAddWithoutValidation("Last-Modified", lastModified.ToString("R"));
response.Content.Headers.LastModified = lastModified;
return response;
});
}
private static void AddNotModified(CannedHttpMessageHandler handler, Uri uri, string etag)
{
handler.AddResponse(HttpMethod.Get, uri, _ =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private static string ReadFixture(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primary = Path.Combine(baseDir, "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var fallback = Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
if (File.Exists(fallback))
{
return File.ReadAllText(fallback);
}
throw new FileNotFoundException($"Missing CERT/CC fixture '{filename}'.");
}
private static string? TryGetHeader(IReadOnlyDictionary<string, string> headers, string key)
=> headers.TryGetValue(key, out var value) ? value : null;
private static string? ToIsoString(BsonValue value)
{
return value.BsonType switch
{
BsonType.DateTime => value.ToUniversalTime().ToString("O"),
BsonType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime().ToString("O"),
_ => null,
};
}
private static void WriteOrAssertSnapshot(string snapshot, string filename)
{
var normalizedSnapshot = Normalize(snapshot);
if (ShouldUpdateFixtures() || !FixtureExists(filename))
{
var path = GetWritablePath(filename);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, normalizedSnapshot);
return;
}
var expected = ReadFixture(filename);
var normalizedExpected = Normalize(expected);
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(Path.GetDirectoryName(GetWritablePath(filename))!, Path.GetFileNameWithoutExtension(filename) + ".actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, normalizedSnapshot);
}
Assert.Equal(normalizedExpected, normalizedSnapshot);
}
private static string GetWritablePath(string filename)
{
var baseDir = AppContext.BaseDirectory;
return Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
}
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
private static bool ShouldUpdateFixtures()
{
var flag = Environment.GetEnvironmentVariable("UPDATE_CERTCC_FIXTURES");
return string.Equals(flag, "1", StringComparison.Ordinal) || string.Equals(flag, "true", StringComparison.OrdinalIgnoreCase);
}
private static bool FixtureExists(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primary = Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
if (File.Exists(primary))
{
return true;
}
var fallback = Path.Combine(baseDir, "Fixtures", filename);
return File.Exists(fallback);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
if (_harness is not null)
{
await _harness.DisposeAsync();
}
}
}
public sealed class CertCcConnectorSnapshotTests : IAsyncLifetime
{
private static readonly Uri SeptemberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/09/summary/");
private static readonly Uri OctoberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/10/summary/");
private static readonly Uri NovemberSummaryUri = new("https://www.kb.cert.org/vuls/api/2025/11/summary/");
private static readonly Uri NoteDetailUri = new("https://www.kb.cert.org/vuls/api/294418/");
private static readonly Uri VendorsDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/");
private static readonly Uri VulsDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vuls/");
private static readonly Uri VendorStatusesDetailUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/vuls/");
private static readonly Uri YearlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/summary/");
private readonly ConcelierPostgresFixture _fixture;
private ConnectorTestHarness? _harness;
public CertCcConnectorSnapshotTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task FetchSummaryAndDetails_ProducesDeterministicSnapshots()
{
var initialTime = new DateTimeOffset(2025, 11, 1, 8, 0, 0, TimeSpan.Zero);
var harness = await EnsureHarnessAsync(initialTime);
RegisterSummaryResponses(harness.Handler);
RegisterDetailResponses(harness.Handler);
var connector = harness.ServiceProvider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
var documentsSnapshot = await BuildDocumentsSnapshotAsync(harness.ServiceProvider);
WriteOrAssertSnapshot(documentsSnapshot, "certcc-documents.snapshot.json");
var stateSnapshot = await BuildStateSnapshotAsync(harness.ServiceProvider);
WriteOrAssertSnapshot(stateSnapshot, "certcc-state.snapshot.json");
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
var advisoriesSnapshot = await BuildAdvisoriesSnapshotAsync(harness.ServiceProvider);
WriteOrAssertSnapshot(advisoriesSnapshot, "certcc-advisories.snapshot.json");
harness.TimeProvider.Advance(TimeSpan.FromMinutes(30));
RegisterSummaryNotModifiedResponses(harness.Handler);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
var recordedRequests = harness.Handler.Requests
.Select(request => request.Uri.ToString())
.ToArray();
recordedRequests.Should().Equal(new[]
{
SeptemberSummaryUri.ToString(),
OctoberSummaryUri.ToString(),
NoteDetailUri.ToString(),
VendorsDetailUri.ToString(),
VulsDetailUri.ToString(),
VendorStatusesDetailUri.ToString(),
YearlySummaryUri.ToString(),
OctoberSummaryUri.ToString(),
NovemberSummaryUri.ToString(),
YearlySummaryUri.ToString(),
});
harness.Handler.AssertNoPendingResponses();
var requestsSnapshot = BuildRequestsSnapshot(harness.Handler.Requests);
WriteOrAssertSnapshot(requestsSnapshot, "certcc-requests.snapshot.json");
}
private async Task<ConnectorTestHarness> EnsureHarnessAsync(DateTimeOffset initialTime)
{
if (_harness is not null)
{
return _harness;
}
var harness = new ConnectorTestHarness(_fixture, initialTime, CertCcOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddCertCcConnector(options =>
{
options.BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/", UriKind.Absolute);
options.SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(3),
InitialBackfill = TimeSpan.FromDays(45),
MinimumWindowSize = TimeSpan.FromDays(1),
};
options.MaxMonthlySummaries = 2;
options.MaxNotesPerFetch = 1;
options.DetailRequestDelay = TimeSpan.Zero;
options.EnableDetailMapping = true;
});
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, options =>
{
options.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = harness.Handler);
});
});
_harness = harness;
return harness;
}
private static async Task<string> BuildDocumentsSnapshotAsync(IServiceProvider provider)
{
var documentStore = provider.GetRequiredService<IDocumentStore>();
var uris = new[]
{
SeptemberSummaryUri,
OctoberSummaryUri,
NovemberSummaryUri,
YearlySummaryUri,
NoteDetailUri,
VendorsDetailUri,
VulsDetailUri,
VendorStatusesDetailUri,
};
var records = new List<object>(uris.Length);
foreach (var uri in uris)
{
var record = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, uri.ToString(), CancellationToken.None);
if (record is null)
{
continue;
}
var lastModified = record.Headers is not null
&& record.Headers.TryGetValue("Last-Modified", out var lastModifiedHeader)
&& DateTimeOffset.TryParse(lastModifiedHeader, out var parsedLastModified)
? parsedLastModified.ToUniversalTime().ToString("O")
: record.LastModified?.ToUniversalTime().ToString("O");
records.Add(new
{
record.Uri,
record.Status,
record.Sha256,
record.ContentType,
LastModified = lastModified,
Metadata = record.Metadata is null
? null
: record.Metadata
.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase),
record.Etag,
});
}
var ordered = records
.OrderBy(static entry => entry.GetType().GetProperty("Uri")?.GetValue(entry)?.ToString(), StringComparer.Ordinal)
.ToArray();
return SnapshotSerializer.ToSnapshot(ordered);
}
private static async Task<string> BuildStateSnapshotAsync(IServiceProvider provider)
{
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = state!.Cursor ?? new DocumentObject();
DocumentObject? summaryDocument = null;
if (cursor.TryGetValue("summary", out var summaryValue) && summaryValue is DocumentObject summaryDoc)
{
summaryDocument = summaryDoc;
}
var summary = summaryDocument is null
? null
: new
{
Start = summaryDocument.TryGetValue("start", out var startValue) ? ToIsoString(startValue) : null,
End = summaryDocument.TryGetValue("end", out var endValue) ? ToIsoString(endValue) : null,
};
var snapshot = new
{
Summary = summary,
PendingNotes = cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue.AsDocumentArray.Select(static value => value.ToString()).OrderBy(static note => note, StringComparer.OrdinalIgnoreCase).ToArray()
: Array.Empty<string>(),
PendingSummaries = cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue.AsDocumentArray.Select(static value => value.ToString()).OrderBy(static item => item, StringComparer.OrdinalIgnoreCase).ToArray()
: Array.Empty<string>(),
LastRun = cursor.TryGetValue("lastRun", out var lastRunValue) ? ToIsoString(lastRunValue) : null,
state.LastSuccess,
state.LastFailure,
state.FailCount,
state.BackoffUntil,
};
return SnapshotSerializer.ToSnapshot(snapshot);
}
private static async Task<string> BuildAdvisoriesSnapshotAsync(IServiceProvider provider)
{
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = new List<Advisory>();
await foreach (var advisory in advisoryStore.StreamAsync(CancellationToken.None))
{
advisories.Add(advisory);
}
var ordered = advisories
.OrderBy(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal)
.ToArray();
return SnapshotSerializer.ToSnapshot(ordered);
}
private static string BuildRequestsSnapshot(IReadOnlyCollection<CannedHttpMessageHandler.CannedRequestRecord> requests)
{
var ordered = requests
.OrderBy(static request => request.Timestamp)
.Select(static request => new
{
request.Method.Method,
Uri = request.Uri.ToString(),
Headers = new
{
Accept = TryGetHeader(request.Headers, "Accept"),
IfNoneMatch = TryGetHeader(request.Headers, "If-None-Match"),
IfModifiedSince = TryGetHeader(request.Headers, "If-Modified-Since"),
},
})
.ToArray();
return SnapshotSerializer.ToSnapshot(ordered);
}
private static void RegisterSummaryResponses(CannedHttpMessageHandler handler)
{
AddJsonResponse(handler, SeptemberSummaryUri, "summary-2025-09.json", "\"certcc-summary-2025-09\"", new DateTimeOffset(2025, 9, 30, 12, 0, 0, TimeSpan.Zero));
AddJsonResponse(handler, OctoberSummaryUri, "summary-2025-10.json", "\"certcc-summary-2025-10\"", new DateTimeOffset(2025, 10, 31, 12, 0, 0, TimeSpan.Zero));
AddJsonResponse(handler, YearlySummaryUri, "summary-2025.json", "\"certcc-summary-2025\"", new DateTimeOffset(2025, 10, 31, 12, 1, 0, TimeSpan.Zero));
}
private static void RegisterSummaryNotModifiedResponses(CannedHttpMessageHandler handler)
{
AddNotModified(handler, OctoberSummaryUri, "\"certcc-summary-2025-10\"");
AddNotModified(handler, NovemberSummaryUri, "\"certcc-summary-2025-11\"");
AddNotModified(handler, YearlySummaryUri, "\"certcc-summary-2025\"");
}
private static void RegisterDetailResponses(CannedHttpMessageHandler handler)
{
AddJsonResponse(handler, NoteDetailUri, "vu-294418.json", "\"certcc-note-294418\"", new DateTimeOffset(2025, 10, 9, 16, 52, 0, TimeSpan.Zero));
AddJsonResponse(handler, VendorsDetailUri, "vu-294418-vendors.json", "\"certcc-vendors-294418\"", new DateTimeOffset(2025, 10, 9, 17, 5, 0, TimeSpan.Zero));
AddJsonResponse(handler, VulsDetailUri, "vu-294418-vuls.json", "\"certcc-vuls-294418\"", new DateTimeOffset(2025, 10, 9, 17, 10, 0, TimeSpan.Zero));
AddJsonResponse(handler, VendorStatusesDetailUri, "vendor-statuses-294418.json", "\"certcc-vendor-statuses-294418\"", new DateTimeOffset(2025, 10, 9, 17, 12, 0, TimeSpan.Zero));
}
private static void AddJsonResponse(CannedHttpMessageHandler handler, Uri uri, string fixtureName, string etag, DateTimeOffset lastModified)
{
var payload = ReadFixture(fixtureName);
handler.AddResponse(HttpMethod.Get, uri, _ =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue(etag);
response.Headers.TryAddWithoutValidation("Last-Modified", lastModified.ToString("R"));
response.Content.Headers.LastModified = lastModified;
return response;
});
}
private static void AddNotModified(CannedHttpMessageHandler handler, Uri uri, string etag)
{
handler.AddResponse(HttpMethod.Get, uri, _ =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private static string ReadFixture(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primary = Path.Combine(baseDir, "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var fallback = Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
if (File.Exists(fallback))
{
return File.ReadAllText(fallback);
}
throw new FileNotFoundException($"Missing CERT/CC fixture '{filename}'.");
}
private static string? TryGetHeader(IReadOnlyDictionary<string, string> headers, string key)
=> headers.TryGetValue(key, out var value) ? value : null;
private static string? ToIsoString(DocumentValue value)
{
return value.DocumentType switch
{
DocumentType.DateTime => value.ToUniversalTime().ToString("O"),
DocumentType.String when DateTimeOffset.TryParse(value.AsString, out var parsed) => parsed.ToUniversalTime().ToString("O"),
_ => null,
};
}
private static void WriteOrAssertSnapshot(string snapshot, string filename)
{
var normalizedSnapshot = Normalize(snapshot);
if (ShouldUpdateFixtures() || !FixtureExists(filename))
{
var path = GetWritablePath(filename);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, normalizedSnapshot);
return;
}
var expected = ReadFixture(filename);
var normalizedExpected = Normalize(expected);
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(Path.GetDirectoryName(GetWritablePath(filename))!, Path.GetFileNameWithoutExtension(filename) + ".actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, normalizedSnapshot);
}
Assert.Equal(normalizedExpected, normalizedSnapshot);
}
private static string GetWritablePath(string filename)
{
var baseDir = AppContext.BaseDirectory;
return Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
}
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
private static bool ShouldUpdateFixtures()
{
var flag = Environment.GetEnvironmentVariable("UPDATE_CERTCC_FIXTURES");
return string.Equals(flag, "1", StringComparison.Ordinal) || string.Equals(flag, "true", StringComparison.OrdinalIgnoreCase);
}
private static bool FixtureExists(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primary = Path.Combine(baseDir, "Source", "CertCc", "Fixtures", filename);
if (File.Exists(primary))
{
return true;
}
var fallback = Path.Combine(baseDir, "Fixtures", filename);
return File.Exists(fallback);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
if (_harness is not null)
{
await _harness.DisposeAsync();
}
}
}

View File

@@ -1,474 +1,474 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Connector.CertCc;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Cursors;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.CertCc;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class CertCcConnectorTests : IAsyncLifetime
{
private static readonly Uri MonthlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/10/summary/");
private static readonly Uri YearlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/summary/");
private static readonly Uri NoteDetailUri = new("https://www.kb.cert.org/vuls/api/294418/");
private static readonly Uri VendorsUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/");
private static readonly Uri VulsUri = new("https://www.kb.cert.org/vuls/api/294418/vuls/");
private static readonly Uri VendorStatusesUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/vuls/");
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
public CertCcConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 11, 9, 30, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesCanonicalAdvisory()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
advisories.Should().NotBeNull();
advisories.Should().HaveCountGreaterThan(0);
var advisory = advisories.FirstOrDefault(a => a.AdvisoryKey == "certcc/vu-294418");
advisory.Should().NotBeNull();
advisory!.Title.Should().ContainEquivalentOf("DrayOS");
advisory.Summary.Should().NotBeNullOrWhiteSpace();
advisory.Aliases.Should().Contain("VU#294418");
advisory.Aliases.Should().Contain("CVE-2025-10547");
advisory.AffectedPackages.Should().NotBeNull();
advisory.AffectedPackages.Should().HaveCountGreaterThan(0);
advisory.AffectedPackages![0].NormalizedVersions.Should().NotBeNull();
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue!.AsBsonArray.Count
: 0;
pendingDocuments.Should().Be(0);
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue!.AsBsonArray.Count
: 0;
pendingMappings.Should().Be(0);
}
[Fact]
public async Task Fetch_PersistsSummaryAndDetailDocuments()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var summaryDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, MonthlySummaryUri.ToString(), CancellationToken.None);
summaryDocument.Should().NotBeNull();
summaryDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
noteDocument.Should().NotBeNull();
noteDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
noteDocument.Metadata.Should().NotBeNull();
noteDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("note");
noteDocument.Metadata.Should().ContainKey("certcc.noteId").WhoseValue.Should().Be("294418");
noteDocument.Metadata.Should().ContainKey("certcc.vuid").WhoseValue.Should().Be("VU#294418");
var vendorsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorsUri.ToString(), CancellationToken.None);
vendorsDocument.Should().NotBeNull();
vendorsDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
vendorsDocument.Metadata.Should().NotBeNull();
vendorsDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vendors");
var vulsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VulsUri.ToString(), CancellationToken.None);
vulsDocument.Should().NotBeNull();
vulsDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
vulsDocument.Metadata.Should().NotBeNull();
vulsDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vuls");
var vendorStatusesDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorStatusesUri.ToString(), CancellationToken.None);
vendorStatusesDocument.Should().NotBeNull();
vendorStatusesDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
vendorStatusesDocument.Metadata.Should().NotBeNull();
vendorStatusesDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vendors-vuls");
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
var pendingNotesCount = state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue!.AsBsonArray.Count
: 0;
pendingNotesCount.Should().Be(0);
var pendingSummariesCount = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue!.AsBsonArray.Count
: 0;
pendingSummariesCount.Should().Be(0);
var pendingDocumentsCount = state.Cursor.TryGetValue("pendingDocuments", out var pendingDocumentsValue)
? pendingDocumentsValue!.AsBsonArray.Count
: 0;
pendingDocumentsCount.Should().Be(4);
var pendingMappingsCount = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue!.AsBsonArray.Count
: 0;
pendingMappingsCount.Should().Be(0);
_handler.Requests.Should().Contain(request => request.Uri == NoteDetailUri);
}
[Fact]
public async Task Fetch_ReusesConditionalRequestsOnSubsequentRun()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses(summaryEtag: "\"summary-oct\"", yearlyEtag: "\"summary-year\"");
SeedDetailResponses(detailEtag: "\"note-etag\"", vendorsEtag: "\"vendors-etag\"", vulsEtag: "\"vuls-etag\"", vendorStatusesEtag: "\"vendor-statuses-etag\"");
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_handler.Clear();
SeedSummaryNotModifiedResponses("\"summary-oct\"", "\"summary-year\"");
SeedDetailNotModifiedResponses("\"note-etag\"", "\"vendors-etag\"", "\"vuls-etag\"", "\"vendor-statuses-etag\"");
_timeProvider.Advance(TimeSpan.FromMinutes(15));
await connector.FetchAsync(provider, CancellationToken.None);
var requests = _handler.Requests.ToArray();
requests.Should().OnlyContain(r =>
r.Uri == MonthlySummaryUri
|| r.Uri == YearlySummaryUri
|| r.Uri == NoteDetailUri
|| r.Uri == VendorsUri
|| r.Uri == VulsUri
|| r.Uri == VendorStatusesUri);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
var pendingNotesCount = state!.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue!.AsBsonArray.Count
: 0;
pendingNotesCount.Should().Be(0);
var pendingSummaries = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue!.AsBsonArray.Count
: 0;
pendingSummaries.Should().Be(0);
var pendingDocuments = state.Cursor.TryGetValue("pendingDocuments", out var pendingDocumentsValue)
? pendingDocumentsValue!.AsBsonArray.Count
: 0;
pendingDocuments.Should().BeGreaterThan(0);
}
[Fact]
public async Task Fetch_DetailFailureRecordsBackoffAndKeepsPendingNote()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses(vendorsStatus: HttpStatusCode.InternalServerError);
var connector = provider.GetRequiredService<CertCcConnector>();
var failure = await Assert.ThrowsAnyAsync<Exception>(() => connector.FetchAsync(provider, CancellationToken.None));
Assert.True(failure is HttpRequestException || failure is InvalidOperationException);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.FailCount.Should().BeGreaterThan(0);
state.BackoffUntil.Should().NotBeNull();
state.BackoffUntil.Should().BeAfter(_timeProvider.GetUtcNow());
state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue).Should().BeTrue();
pendingNotesValue!.AsBsonArray.Should().Contain(value => value.AsString == "294418");
var pendingSummaries = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue!.AsBsonArray.Count
: 0;
pendingSummaries.Should().Be(0);
}
[Fact]
public async Task Fetch_PartialDetailEndpointsMissing_CompletesAndMaps()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses(
vulsStatus: HttpStatusCode.NotFound,
vendorStatusesStatus: HttpStatusCode.NotFound);
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
advisories.Should().NotBeNull();
advisories!.Should().Contain(advisory => advisory.AdvisoryKey == "certcc/vu-294418");
var documentStore = provider.GetRequiredService<IDocumentStore>();
var vendorsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorsUri.ToString(), CancellationToken.None);
vendorsDocument.Should().NotBeNull();
vendorsDocument!.Status.Should().Be(DocumentStatuses.Mapped);
var vulsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VulsUri.ToString(), CancellationToken.None);
vulsDocument.Should().BeNull();
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
noteDocument.Should().NotBeNull();
noteDocument!.Status.Should().Be(DocumentStatuses.Mapped);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue).Should().BeTrue();
pendingNotesValue!.AsBsonArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue).Should().BeTrue();
pendingDocsValue!.AsBsonArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue).Should().BeTrue();
pendingMappingsValue!.AsBsonArray.Should().BeEmpty();
}
public Task InitializeAsync() => Task.CompletedTask;
public sealed class CertCcConnectorTests : IAsyncLifetime
{
private static readonly Uri MonthlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/10/summary/");
private static readonly Uri YearlySummaryUri = new("https://www.kb.cert.org/vuls/api/2025/summary/");
private static readonly Uri NoteDetailUri = new("https://www.kb.cert.org/vuls/api/294418/");
private static readonly Uri VendorsUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/");
private static readonly Uri VulsUri = new("https://www.kb.cert.org/vuls/api/294418/vuls/");
private static readonly Uri VendorStatusesUri = new("https://www.kb.cert.org/vuls/api/294418/vendors/vuls/");
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
public CertCcConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 11, 9, 30, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesCanonicalAdvisory()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
advisories.Should().NotBeNull();
advisories.Should().HaveCountGreaterThan(0);
var advisory = advisories.FirstOrDefault(a => a.AdvisoryKey == "certcc/vu-294418");
advisory.Should().NotBeNull();
advisory!.Title.Should().ContainEquivalentOf("DrayOS");
advisory.Summary.Should().NotBeNullOrWhiteSpace();
advisory.Aliases.Should().Contain("VU#294418");
advisory.Aliases.Should().Contain("CVE-2025-10547");
advisory.AffectedPackages.Should().NotBeNull();
advisory.AffectedPackages.Should().HaveCountGreaterThan(0);
advisory.AffectedPackages![0].NormalizedVersions.Should().NotBeNull();
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue!.AsDocumentArray.Count
: 0;
pendingDocuments.Should().Be(0);
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue!.AsDocumentArray.Count
: 0;
pendingMappings.Should().Be(0);
}
[Fact]
public async Task Fetch_PersistsSummaryAndDetailDocuments()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var summaryDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, MonthlySummaryUri.ToString(), CancellationToken.None);
summaryDocument.Should().NotBeNull();
summaryDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
noteDocument.Should().NotBeNull();
noteDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
noteDocument.Metadata.Should().NotBeNull();
noteDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("note");
noteDocument.Metadata.Should().ContainKey("certcc.noteId").WhoseValue.Should().Be("294418");
noteDocument.Metadata.Should().ContainKey("certcc.vuid").WhoseValue.Should().Be("VU#294418");
var vendorsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorsUri.ToString(), CancellationToken.None);
vendorsDocument.Should().NotBeNull();
vendorsDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
vendorsDocument.Metadata.Should().NotBeNull();
vendorsDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vendors");
var vulsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VulsUri.ToString(), CancellationToken.None);
vulsDocument.Should().NotBeNull();
vulsDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
vulsDocument.Metadata.Should().NotBeNull();
vulsDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vuls");
var vendorStatusesDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorStatusesUri.ToString(), CancellationToken.None);
vendorStatusesDocument.Should().NotBeNull();
vendorStatusesDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
vendorStatusesDocument.Metadata.Should().NotBeNull();
vendorStatusesDocument.Metadata!.Should().ContainKey("certcc.endpoint").WhoseValue.Should().Be("vendors-vuls");
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
var pendingNotesCount = state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue!.AsDocumentArray.Count
: 0;
pendingNotesCount.Should().Be(0);
var pendingSummariesCount = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue!.AsDocumentArray.Count
: 0;
pendingSummariesCount.Should().Be(0);
var pendingDocumentsCount = state.Cursor.TryGetValue("pendingDocuments", out var pendingDocumentsValue)
? pendingDocumentsValue!.AsDocumentArray.Count
: 0;
pendingDocumentsCount.Should().Be(4);
var pendingMappingsCount = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue!.AsDocumentArray.Count
: 0;
pendingMappingsCount.Should().Be(0);
_handler.Requests.Should().Contain(request => request.Uri == NoteDetailUri);
}
[Fact]
public async Task Fetch_ReusesConditionalRequestsOnSubsequentRun()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses(summaryEtag: "\"summary-oct\"", yearlyEtag: "\"summary-year\"");
SeedDetailResponses(detailEtag: "\"note-etag\"", vendorsEtag: "\"vendors-etag\"", vulsEtag: "\"vuls-etag\"", vendorStatusesEtag: "\"vendor-statuses-etag\"");
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_handler.Clear();
SeedSummaryNotModifiedResponses("\"summary-oct\"", "\"summary-year\"");
SeedDetailNotModifiedResponses("\"note-etag\"", "\"vendors-etag\"", "\"vuls-etag\"", "\"vendor-statuses-etag\"");
_timeProvider.Advance(TimeSpan.FromMinutes(15));
await connector.FetchAsync(provider, CancellationToken.None);
var requests = _handler.Requests.ToArray();
requests.Should().OnlyContain(r =>
r.Uri == MonthlySummaryUri
|| r.Uri == YearlySummaryUri
|| r.Uri == NoteDetailUri
|| r.Uri == VendorsUri
|| r.Uri == VulsUri
|| r.Uri == VendorStatusesUri);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
var pendingNotesCount = state!.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue)
? pendingNotesValue!.AsDocumentArray.Count
: 0;
pendingNotesCount.Should().Be(0);
var pendingSummaries = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue!.AsDocumentArray.Count
: 0;
pendingSummaries.Should().Be(0);
var pendingDocuments = state.Cursor.TryGetValue("pendingDocuments", out var pendingDocumentsValue)
? pendingDocumentsValue!.AsDocumentArray.Count
: 0;
pendingDocuments.Should().BeGreaterThan(0);
}
[Fact]
public async Task Fetch_DetailFailureRecordsBackoffAndKeepsPendingNote()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses(vendorsStatus: HttpStatusCode.InternalServerError);
var connector = provider.GetRequiredService<CertCcConnector>();
var failure = await Assert.ThrowsAnyAsync<Exception>(() => connector.FetchAsync(provider, CancellationToken.None));
Assert.True(failure is HttpRequestException || failure is InvalidOperationException);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.FailCount.Should().BeGreaterThan(0);
state.BackoffUntil.Should().NotBeNull();
state.BackoffUntil.Should().BeAfter(_timeProvider.GetUtcNow());
state.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue).Should().BeTrue();
pendingNotesValue!.AsDocumentArray.Should().Contain(value => value.AsString == "294418");
var pendingSummaries = state.Cursor.TryGetValue("pendingSummaries", out var pendingSummariesValue)
? pendingSummariesValue!.AsDocumentArray.Count
: 0;
pendingSummaries.Should().Be(0);
}
[Fact]
public async Task Fetch_PartialDetailEndpointsMissing_CompletesAndMaps()
{
await using var provider = await BuildServiceProviderAsync();
SeedSummaryResponses();
SeedDetailResponses(
vulsStatus: HttpStatusCode.NotFound,
vendorStatusesStatus: HttpStatusCode.NotFound);
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
advisories.Should().NotBeNull();
advisories!.Should().Contain(advisory => advisory.AdvisoryKey == "certcc/vu-294418");
var documentStore = provider.GetRequiredService<IDocumentStore>();
var vendorsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VendorsUri.ToString(), CancellationToken.None);
vendorsDocument.Should().NotBeNull();
vendorsDocument!.Status.Should().Be(DocumentStatuses.Mapped);
var vulsDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, VulsUri.ToString(), CancellationToken.None);
vulsDocument.Should().BeNull();
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
noteDocument.Should().NotBeNull();
noteDocument!.Status.Should().Be(DocumentStatuses.Mapped);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.TryGetValue("pendingNotes", out var pendingNotesValue).Should().BeTrue();
pendingNotesValue!.AsDocumentArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue).Should().BeTrue();
pendingDocsValue!.AsDocumentArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue).Should().BeTrue();
pendingMappingsValue!.AsDocumentArray.Should().BeEmpty();
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await _fixture.TruncateAllTablesAsync();
}
[Fact]
public async Task ParseAndMap_SkipWhenDetailMappingDisabled()
{
await using var provider = await BuildServiceProviderAsync(enableDetailMapping: false);
SeedSummaryResponses();
SeedDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
advisories.Should().BeNullOrEmpty();
var documentStore = provider.GetRequiredService<IDocumentStore>();
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
noteDocument.Should().NotBeNull();
noteDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue!.AsBsonArray.Count
: 0;
pendingDocuments.Should().BeGreaterThan(0);
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue!.AsBsonArray.Count
: 0;
pendingMappings.Should().Be(0);
}
[Fact]
public async Task ParseAndMap_SkipWhenDetailMappingDisabled()
{
await using var provider = await BuildServiceProviderAsync(enableDetailMapping: false);
SeedSummaryResponses();
SeedDetailResponses();
var connector = provider.GetRequiredService<CertCcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
advisories.Should().BeNullOrEmpty();
var documentStore = provider.GetRequiredService<IDocumentStore>();
var noteDocument = await documentStore.FindBySourceAndUriAsync(CertCcConnectorPlugin.SourceName, NoteDetailUri.ToString(), CancellationToken.None);
noteDocument.Should().NotBeNull();
noteDocument!.Status.Should().Be(DocumentStatuses.PendingParse);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertCcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue!.AsDocumentArray.Count
: 0;
pendingDocuments.Should().BeGreaterThan(0);
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue!.AsDocumentArray.Count
: 0;
pendingMappings.Should().Be(0);
}
private async Task<ServiceProvider> BuildServiceProviderAsync(bool enableDetailMapping = true)
{
await _fixture.TruncateAllTablesAsync();
_handler.Clear();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddCertCcConnector(options =>
{
options.BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/");
options.SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(1),
Overlap = TimeSpan.Zero,
InitialBackfill = TimeSpan.FromDays(1),
MinimumWindowSize = TimeSpan.FromHours(6),
};
options.MaxMonthlySummaries = 1;
options.MaxNotesPerFetch = 5;
options.DetailRequestDelay = TimeSpan.Zero;
options.EnableDetailMapping = enableDetailMapping;
});
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
services.AddSourceCommon();
services.AddCertCcConnector(options =>
{
options.BaseApiUri = new Uri("https://www.kb.cert.org/vuls/api/");
options.SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(1),
Overlap = TimeSpan.Zero,
InitialBackfill = TimeSpan.FromDays(1),
MinimumWindowSize = TimeSpan.FromHours(6),
};
options.MaxMonthlySummaries = 1;
options.MaxNotesPerFetch = 5;
options.DetailRequestDelay = TimeSpan.Zero;
options.EnableDetailMapping = enableDetailMapping;
});
services.Configure<HttpClientFactoryOptions>(CertCcOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
return services.BuildServiceProvider();
}
private void SeedSummaryResponses(string summaryEtag = "\"summary-oct\"", string yearlyEtag = "\"summary-year\"")
{
AddJsonResponse(MonthlySummaryUri, ReadFixture("summary-2025-10.json"), summaryEtag);
AddJsonResponse(YearlySummaryUri, ReadFixture("summary-2025.json"), yearlyEtag);
}
private void SeedSummaryNotModifiedResponses(string summaryEtag, string yearlyEtag)
{
AddNotModifiedResponse(MonthlySummaryUri, summaryEtag);
AddNotModifiedResponse(YearlySummaryUri, yearlyEtag);
}
private void SeedDetailResponses(
string detailEtag = "\"note-etag\"",
string vendorsEtag = "\"vendors-etag\"",
string vulsEtag = "\"vuls-etag\"",
string vendorStatusesEtag = "\"vendor-statuses-etag\"",
HttpStatusCode vendorsStatus = HttpStatusCode.OK,
HttpStatusCode vulsStatus = HttpStatusCode.OK,
HttpStatusCode vendorStatusesStatus = HttpStatusCode.OK)
{
AddJsonResponse(NoteDetailUri, ReadFixture("vu-294418.json"), detailEtag);
if (vendorsStatus == HttpStatusCode.OK)
{
AddJsonResponse(VendorsUri, ReadFixture("vu-294418-vendors.json"), vendorsEtag);
}
else
{
_handler.AddResponse(VendorsUri, () =>
{
var response = new HttpResponseMessage(vendorsStatus)
{
Content = new StringContent("vendors error", Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(vendorsEtag);
return response;
});
}
if (vulsStatus == HttpStatusCode.OK)
{
AddJsonResponse(VulsUri, ReadFixture("vu-294418-vuls.json"), vulsEtag);
}
else
{
_handler.AddResponse(VulsUri, () =>
{
var response = new HttpResponseMessage(vulsStatus)
{
Content = new StringContent("vuls error", Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(vulsEtag);
return response;
});
}
if (vendorStatusesStatus == HttpStatusCode.OK)
{
AddJsonResponse(VendorStatusesUri, ReadFixture("vendor-statuses-294418.json"), vendorStatusesEtag);
}
else
{
_handler.AddResponse(VendorStatusesUri, () =>
{
var response = new HttpResponseMessage(vendorStatusesStatus)
{
Content = new StringContent("vendor statuses error", Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(vendorStatusesEtag);
return response;
});
}
}
private void SeedDetailNotModifiedResponses(string detailEtag, string vendorsEtag, string vulsEtag, string vendorStatusesEtag)
{
AddNotModifiedResponse(NoteDetailUri, detailEtag);
AddNotModifiedResponse(VendorsUri, vendorsEtag);
AddNotModifiedResponse(VulsUri, vulsEtag);
AddNotModifiedResponse(VendorStatusesUri, vendorStatusesEtag);
}
private void AddJsonResponse(Uri uri, string json, string etag)
{
_handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private void AddNotModifiedResponse(Uri uri, string etag)
{
_handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var candidate = Path.Combine(baseDirectory, "Source", "CertCc", "Fixtures", filename);
if (File.Exists(candidate))
{
return File.ReadAllText(candidate);
}
var fallback = Path.Combine(baseDirectory, "Fixtures", filename);
return File.ReadAllText(fallback);
}
}
private void SeedSummaryResponses(string summaryEtag = "\"summary-oct\"", string yearlyEtag = "\"summary-year\"")
{
AddJsonResponse(MonthlySummaryUri, ReadFixture("summary-2025-10.json"), summaryEtag);
AddJsonResponse(YearlySummaryUri, ReadFixture("summary-2025.json"), yearlyEtag);
}
private void SeedSummaryNotModifiedResponses(string summaryEtag, string yearlyEtag)
{
AddNotModifiedResponse(MonthlySummaryUri, summaryEtag);
AddNotModifiedResponse(YearlySummaryUri, yearlyEtag);
}
private void SeedDetailResponses(
string detailEtag = "\"note-etag\"",
string vendorsEtag = "\"vendors-etag\"",
string vulsEtag = "\"vuls-etag\"",
string vendorStatusesEtag = "\"vendor-statuses-etag\"",
HttpStatusCode vendorsStatus = HttpStatusCode.OK,
HttpStatusCode vulsStatus = HttpStatusCode.OK,
HttpStatusCode vendorStatusesStatus = HttpStatusCode.OK)
{
AddJsonResponse(NoteDetailUri, ReadFixture("vu-294418.json"), detailEtag);
if (vendorsStatus == HttpStatusCode.OK)
{
AddJsonResponse(VendorsUri, ReadFixture("vu-294418-vendors.json"), vendorsEtag);
}
else
{
_handler.AddResponse(VendorsUri, () =>
{
var response = new HttpResponseMessage(vendorsStatus)
{
Content = new StringContent("vendors error", Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(vendorsEtag);
return response;
});
}
if (vulsStatus == HttpStatusCode.OK)
{
AddJsonResponse(VulsUri, ReadFixture("vu-294418-vuls.json"), vulsEtag);
}
else
{
_handler.AddResponse(VulsUri, () =>
{
var response = new HttpResponseMessage(vulsStatus)
{
Content = new StringContent("vuls error", Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(vulsEtag);
return response;
});
}
if (vendorStatusesStatus == HttpStatusCode.OK)
{
AddJsonResponse(VendorStatusesUri, ReadFixture("vendor-statuses-294418.json"), vendorStatusesEtag);
}
else
{
_handler.AddResponse(VendorStatusesUri, () =>
{
var response = new HttpResponseMessage(vendorStatusesStatus)
{
Content = new StringContent("vendor statuses error", Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(vendorStatusesEtag);
return response;
});
}
}
private void SeedDetailNotModifiedResponses(string detailEtag, string vendorsEtag, string vulsEtag, string vendorStatusesEtag)
{
AddNotModifiedResponse(NoteDetailUri, detailEtag);
AddNotModifiedResponse(VendorsUri, vendorsEtag);
AddNotModifiedResponse(VulsUri, vulsEtag);
AddNotModifiedResponse(VendorStatusesUri, vendorStatusesEtag);
}
private void AddJsonResponse(Uri uri, string json, string etag)
{
_handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private void AddNotModifiedResponse(Uri uri, string etag)
{
_handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var candidate = Path.Combine(baseDirectory, "Source", "CertCc", "Fixtures", filename);
if (File.Exists(candidate))
{
return File.ReadAllText(candidate);
}
var fallback = Path.Combine(baseDirectory, "Fixtures", filename);
return File.ReadAllText(fallback);
}
}

View File

@@ -1,6 +1,6 @@
using System;
using System.Globalization;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.CertCc.Internal;
using StellaOps.Concelier.Storage;
@@ -90,7 +90,7 @@ public sealed class CertCcMapperTests
SourceName: "cert-cc",
Format: "certcc.vince.note.v1",
SchemaVersion: "certcc.vince.note.v1",
Payload: new BsonDocument(),
Payload: new DocumentObject(),
CreatedAt: PublishedAt,
ValidatedAt: PublishedAt.AddMinutes(1));

View File

@@ -1,58 +1,58 @@
using System.Text;
using System.Text.Json;
using StellaOps.Concelier.Connector.CertCc.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
public sealed class CertCcSummaryParserTests
{
[Fact]
public void ParseNotes_ReturnsTokens_FromStringArray()
{
var payload = Encoding.UTF8.GetBytes("{\"notes\":[\"VU#123456\",\"VU#654321\"]}");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Equal(new[] { "VU#123456", "VU#654321" }, notes);
}
[Fact]
public void ParseNotes_DeduplicatesTokens_IgnoringCaseAndWhitespace()
{
var payload = Encoding.UTF8.GetBytes("{\"notes\":[\"VU#123456\",\"vu#123456\",\" 123456 \"]}");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Single(notes);
Assert.Equal("VU#123456", notes[0], ignoreCase: true);
}
[Fact]
public void ParseNotes_ReadsTokens_FromObjectEntries()
{
var payload = Encoding.UTF8.GetBytes("{\"notes\":[{\"id\":\"VU#294418\"},{\"idnumber\":\"257161\"}]}");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Equal(new[] { "VU#294418", "257161" }, notes);
}
[Fact]
public void ParseNotes_SupportsArrayRoot()
{
var payload = Encoding.UTF8.GetBytes("[\"VU#360686\",\"VU#760160\"]");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Equal(new[] { "VU#360686", "VU#760160" }, notes);
}
[Fact]
public void ParseNotes_InvalidStructure_Throws()
{
var payload = Encoding.UTF8.GetBytes("\"invalid\"");
Assert.Throws<JsonException>(() => CertCcSummaryParser.ParseNotes(payload));
}
}
using System.Text;
using System.Text.Json;
using StellaOps.Concelier.Connector.CertCc.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
public sealed class CertCcSummaryParserTests
{
[Fact]
public void ParseNotes_ReturnsTokens_FromStringArray()
{
var payload = Encoding.UTF8.GetBytes("{\"notes\":[\"VU#123456\",\"VU#654321\"]}");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Equal(new[] { "VU#123456", "VU#654321" }, notes);
}
[Fact]
public void ParseNotes_DeduplicatesTokens_IgnoringCaseAndWhitespace()
{
var payload = Encoding.UTF8.GetBytes("{\"notes\":[\"VU#123456\",\"vu#123456\",\" 123456 \"]}");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Single(notes);
Assert.Equal("VU#123456", notes[0], ignoreCase: true);
}
[Fact]
public void ParseNotes_ReadsTokens_FromObjectEntries()
{
var payload = Encoding.UTF8.GetBytes("{\"notes\":[{\"id\":\"VU#294418\"},{\"idnumber\":\"257161\"}]}");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Equal(new[] { "VU#294418", "257161" }, notes);
}
[Fact]
public void ParseNotes_SupportsArrayRoot()
{
var payload = Encoding.UTF8.GetBytes("[\"VU#360686\",\"VU#760160\"]");
var notes = CertCcSummaryParser.ParseNotes(payload);
Assert.Equal(new[] { "VU#360686", "VU#760160" }, notes);
}
[Fact]
public void ParseNotes_InvalidStructure_Throws()
{
var payload = Encoding.UTF8.GetBytes("\"invalid\"");
Assert.Throws<JsonException>(() => CertCcSummaryParser.ParseNotes(payload));
}
}

View File

@@ -1,95 +1,95 @@
using System;
using System.Linq;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.CertCc.Internal;
using StellaOps.Concelier.Connector.Common.Cursors;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
public sealed class CertCcSummaryPlannerTests
{
[Fact]
public void CreatePlan_UsesInitialBackfillWindow()
{
var options = Options.Create(new CertCcOptions
{
SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(3),
InitialBackfill = TimeSpan.FromDays(120),
MinimumWindowSize = TimeSpan.FromDays(1),
},
});
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-10T12:00:00Z"));
var planner = new CertCcSummaryPlanner(options, timeProvider);
var plan = planner.CreatePlan(state: null);
Assert.Equal(DateTimeOffset.Parse("2025-06-12T12:00:00Z"), plan.Window.Start);
Assert.Equal(DateTimeOffset.Parse("2025-07-12T12:00:00Z"), plan.Window.End);
Assert.Equal(3, plan.Requests.Count);
var monthly = plan.Requests.Where(r => r.Scope == CertCcSummaryScope.Monthly).ToArray();
Assert.Collection(monthly,
request =>
{
Assert.Equal(2025, request.Year);
Assert.Equal(6, request.Month);
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/06/summary/", request.Uri.AbsoluteUri);
},
request =>
{
Assert.Equal(2025, request.Year);
Assert.Equal(7, request.Month);
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/07/summary/", request.Uri.AbsoluteUri);
});
var yearly = plan.Requests.Where(r => r.Scope == CertCcSummaryScope.Yearly).ToArray();
Assert.Single(yearly);
Assert.Equal(2025, yearly[0].Year);
Assert.Null(yearly[0].Month);
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/summary/", yearly[0].Uri.AbsoluteUri);
Assert.Equal(plan.Window.End, plan.NextState.LastWindowEnd);
}
[Fact]
public void CreatePlan_AdvancesWindowRespectingOverlap()
{
var options = Options.Create(new CertCcOptions
{
SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(10),
InitialBackfill = TimeSpan.FromDays(90),
MinimumWindowSize = TimeSpan.FromDays(1),
},
});
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-12-01T00:00:00Z"));
var planner = new CertCcSummaryPlanner(options, timeProvider);
var first = planner.CreatePlan(null);
var second = planner.CreatePlan(first.NextState);
Assert.True(second.Window.Start < second.Window.End);
Assert.Equal(first.Window.End - options.Value.SummaryWindow.Overlap, second.Window.Start);
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
}
using System;
using System.Linq;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.CertCc.Configuration;
using StellaOps.Concelier.Connector.CertCc.Internal;
using StellaOps.Concelier.Connector.Common.Cursors;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
public sealed class CertCcSummaryPlannerTests
{
[Fact]
public void CreatePlan_UsesInitialBackfillWindow()
{
var options = Options.Create(new CertCcOptions
{
SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(3),
InitialBackfill = TimeSpan.FromDays(120),
MinimumWindowSize = TimeSpan.FromDays(1),
},
});
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-10-10T12:00:00Z"));
var planner = new CertCcSummaryPlanner(options, timeProvider);
var plan = planner.CreatePlan(state: null);
Assert.Equal(DateTimeOffset.Parse("2025-06-12T12:00:00Z"), plan.Window.Start);
Assert.Equal(DateTimeOffset.Parse("2025-07-12T12:00:00Z"), plan.Window.End);
Assert.Equal(3, plan.Requests.Count);
var monthly = plan.Requests.Where(r => r.Scope == CertCcSummaryScope.Monthly).ToArray();
Assert.Collection(monthly,
request =>
{
Assert.Equal(2025, request.Year);
Assert.Equal(6, request.Month);
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/06/summary/", request.Uri.AbsoluteUri);
},
request =>
{
Assert.Equal(2025, request.Year);
Assert.Equal(7, request.Month);
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/07/summary/", request.Uri.AbsoluteUri);
});
var yearly = plan.Requests.Where(r => r.Scope == CertCcSummaryScope.Yearly).ToArray();
Assert.Single(yearly);
Assert.Equal(2025, yearly[0].Year);
Assert.Null(yearly[0].Month);
Assert.Equal("https://www.kb.cert.org/vuls/api/2025/summary/", yearly[0].Uri.AbsoluteUri);
Assert.Equal(plan.Window.End, plan.NextState.LastWindowEnd);
}
[Fact]
public void CreatePlan_AdvancesWindowRespectingOverlap()
{
var options = Options.Create(new CertCcOptions
{
SummaryWindow = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromDays(30),
Overlap = TimeSpan.FromDays(10),
InitialBackfill = TimeSpan.FromDays(90),
MinimumWindowSize = TimeSpan.FromDays(1),
},
});
var timeProvider = new TestTimeProvider(DateTimeOffset.Parse("2025-12-01T00:00:00Z"));
var planner = new CertCcSummaryPlanner(options, timeProvider);
var first = planner.CreatePlan(null);
var second = planner.CreatePlan(first.NextState);
Assert.True(second.Window.Start < second.Window.End);
Assert.Equal(first.Window.End - options.Value.SummaryWindow.Overlap, second.Window.Start);
}
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
}

View File

@@ -1,31 +1,31 @@
using System.Linq;
using StellaOps.Concelier.Connector.CertCc.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
public sealed class CertCcVendorStatementParserTests
{
[Fact]
public void Parse_ReturnsPatchesForTabDelimitedList()
{
const string statement =
"V3912/V3910/V2962/V1000B\t4.4.3.6/4.4.5.1\n" +
"V2927/V2865/V2866\t4.5.1\n" +
"V2765/V2766/V2763/V2135\t4.5.1";
var patches = CertCcVendorStatementParser.Parse(statement);
Assert.Equal(11, patches.Count);
Assert.Contains(patches, patch => patch.Product == "V3912" && patch.Version == "4.4.3.6");
Assert.Contains(patches, patch => patch.Product == "V2962" && patch.Version == "4.4.5.1");
Assert.Equal(7, patches.Count(patch => patch.Version == "4.5.1"));
}
[Fact]
public void Parse_ReturnsEmptyWhenStatementMissing()
{
var patches = CertCcVendorStatementParser.Parse(null);
Assert.Empty(patches);
}
}
using System.Linq;
using StellaOps.Concelier.Connector.CertCc.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.CertCc.Tests.Internal;
public sealed class CertCcVendorStatementParserTests
{
[Fact]
public void Parse_ReturnsPatchesForTabDelimitedList()
{
const string statement =
"V3912/V3910/V2962/V1000B\t4.4.3.6/4.4.5.1\n" +
"V2927/V2865/V2866\t4.5.1\n" +
"V2765/V2766/V2763/V2135\t4.5.1";
var patches = CertCcVendorStatementParser.Parse(statement);
Assert.Equal(11, patches.Count);
Assert.Contains(patches, patch => patch.Product == "V3912" && patch.Version == "4.4.3.6");
Assert.Contains(patches, patch => patch.Product == "V2962" && patch.Version == "4.4.5.1");
Assert.Equal(7, patches.Count(patch => patch.Version == "4.5.1"));
}
[Fact]
public void Parse_ReturnsEmptyWhenStatementMissing()
{
var patches = CertCcVendorStatementParser.Parse(null);
Assert.Empty(patches);
}
}

View File

@@ -1,14 +1,14 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.CertFr;
using StellaOps.Concelier.Connector.CertFr.Configuration;
using StellaOps.Concelier.Connector.Common;
@@ -17,14 +17,14 @@ using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Connector.CertFr.Tests;
namespace StellaOps.Concelier.Connector.CertFr.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class CertFrConnectorTests
{
private static readonly Uri FeedUri = new("https://www.cert.ssi.gouv.fr/feed/alertes/");
private static readonly Uri FirstDetailUri = new("https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/");
private static readonly Uri FeedUri = new("https://www.cert.ssi.gouv.fr/feed/alertes/");
private static readonly Uri FirstDetailUri = new("https://www.cert.ssi.gouv.fr/alerte/AV-2024.001/");
private static readonly Uri SecondDetailUri = new("https://www.cert.ssi.gouv.fr/alerte/AV-2024.002/");
private readonly ConcelierPostgresFixture _fixture;
@@ -33,7 +33,7 @@ public sealed class CertFrConnectorTests
{
_fixture = fixture;
}
[Fact]
public async Task FetchParseMap_ProducesDeterministicSnapshot()
{
@@ -50,25 +50,25 @@ public sealed class CertFrConnectorTests
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var snapshot = SnapshotSerializer.ToSnapshot(advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray());
var expected = ReadFixture("certfr-advisories.snapshot.json");
var normalizedSnapshot = Normalize(snapshot);
var normalizedExpected = Normalize(expected);
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Source", "CertFr", "Fixtures", "certfr-advisories.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(normalizedExpected, normalizedSnapshot);
var snapshot = SnapshotSerializer.ToSnapshot(advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray());
var expected = ReadFixture("certfr-advisories.snapshot.json");
var normalizedSnapshot = Normalize(snapshot);
var normalizedExpected = Normalize(expected);
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Source", "CertFr", "Fixtures", "certfr-advisories.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(normalizedExpected, normalizedSnapshot);
var documentStore = harness.ServiceProvider.GetRequiredService<IDocumentStore>();
var firstDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, FirstDetailUri.ToString(), CancellationToken.None);
Assert.NotNull(firstDocument);
Assert.Equal(DocumentStatuses.Mapped, firstDocument!.Status);
var secondDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, SecondDetailUri.ToString(), CancellationToken.None);
Assert.NotNull(secondDocument);
Assert.Equal(DocumentStatuses.Mapped, secondDocument!.Status);
@@ -76,10 +76,10 @@ public sealed class CertFrConnectorTests
var stateRepository = harness.ServiceProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertFrConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsBsonArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMaps) && pendingMaps.AsBsonArray.Count == 0);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsDocumentArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMaps) && pendingMaps.AsDocumentArray.Count == 0);
}
[Fact]
public async Task FetchFailure_RecordsBackoffAndReason()
{
@@ -101,7 +101,7 @@ public sealed class CertFrConnectorTests
Assert.NotNull(state.BackoffUntil);
Assert.True(state.BackoffUntil > harness.TimeProvider.GetUtcNow());
}
[Fact]
public async Task Fetch_NotModifiedResponsesMaintainDocumentState()
{
@@ -118,7 +118,7 @@ public sealed class CertFrConnectorTests
var firstDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, FirstDetailUri.ToString(), CancellationToken.None);
Assert.NotNull(firstDocument);
Assert.Equal(DocumentStatuses.Mapped, firstDocument!.Status);
var secondDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, SecondDetailUri.ToString(), CancellationToken.None);
Assert.NotNull(secondDocument);
Assert.Equal(DocumentStatuses.Mapped, secondDocument!.Status);
@@ -131,7 +131,7 @@ public sealed class CertFrConnectorTests
firstDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, FirstDetailUri.ToString(), CancellationToken.None);
Assert.NotNull(firstDocument);
Assert.Equal(DocumentStatuses.Mapped, firstDocument!.Status);
secondDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, SecondDetailUri.ToString(), CancellationToken.None);
Assert.NotNull(secondDocument);
Assert.Equal(DocumentStatuses.Mapped, secondDocument!.Status);
@@ -139,10 +139,10 @@ public sealed class CertFrConnectorTests
var stateRepository = harness.ServiceProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertFrConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsBsonArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMaps) && pendingMaps.AsBsonArray.Count == 0);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsDocumentArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMaps) && pendingMaps.AsDocumentArray.Count == 0);
}
[Fact]
public async Task Fetch_DuplicateContentSkipsRequeue()
{
@@ -159,7 +159,7 @@ public sealed class CertFrConnectorTests
var firstDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, FirstDetailUri.ToString(), CancellationToken.None);
Assert.NotNull(firstDocument);
Assert.Equal(DocumentStatuses.Mapped, firstDocument!.Status);
var secondDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, SecondDetailUri.ToString(), CancellationToken.None);
Assert.NotNull(secondDocument);
Assert.Equal(DocumentStatuses.Mapped, secondDocument!.Status);
@@ -174,7 +174,7 @@ public sealed class CertFrConnectorTests
firstDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, FirstDetailUri.ToString(), CancellationToken.None);
Assert.NotNull(firstDocument);
Assert.Equal(DocumentStatuses.Mapped, firstDocument!.Status);
secondDocument = await documentStore.FindBySourceAndUriAsync(CertFrConnectorPlugin.SourceName, SecondDetailUri.ToString(), CancellationToken.None);
Assert.NotNull(secondDocument);
Assert.Equal(DocumentStatuses.Mapped, secondDocument!.Status);
@@ -182,10 +182,10 @@ public sealed class CertFrConnectorTests
var stateRepository = harness.ServiceProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertFrConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsBsonArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMaps) && pendingMaps.AsBsonArray.Count == 0);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsDocumentArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMaps) && pendingMaps.AsDocumentArray.Count == 0);
}
private async Task<ConnectorTestHarness> BuildHarnessAsync()
{
var initialTime = new DateTimeOffset(2024, 10, 3, 0, 0, 0, TimeSpan.Zero);
@@ -251,21 +251,21 @@ public sealed class CertFrConnectorTests
return response;
});
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "CertFr", "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var fallback = Path.Combine(baseDirectory, "CertFr", "Fixtures", filename);
return File.ReadAllText(fallback);
}
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "CertFr", "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var fallback = Path.Combine(baseDirectory, "CertFr", "Fixtures", filename);
return File.ReadAllText(fallback);
}
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
}

View File

@@ -1,24 +1,24 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.CertIn;
using StellaOps.Concelier.Connector.CertIn.Configuration;
using StellaOps.Concelier.Connector.CertIn.Internal;
using StellaOps.Concelier.Connector.Common;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.CertIn;
using StellaOps.Concelier.Connector.CertIn.Configuration;
using StellaOps.Concelier.Connector.CertIn.Internal;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
@@ -28,254 +28,254 @@ using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Testing;
namespace StellaOps.Concelier.Connector.CertIn.Tests;
namespace StellaOps.Concelier.Connector.CertIn.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class CertInConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private ServiceProvider? _serviceProvider;
public CertInConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 4, 20, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_GeneratesExpectedSnapshot()
{
var options = new CertInOptions
{
AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(60),
WindowOverlap = TimeSpan.FromDays(7),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
_handler.AddTextResponse(options.AlertsEndpoint, ReadFixture("alerts-page1.json"), "application/json");
var detailUri = new Uri("https://cert-in.example/advisory/CIAD-2024-0005");
_handler.AddTextResponse(detailUri, ReadFixture("detail-CIAD-2024-0005.html"), "text/html");
var connector = new CertInConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
Assert.Single(advisories);
var canonical = SnapshotSerializer.ToSnapshot(advisories.Single());
var expected = ReadFixture("expected-advisory.json");
var normalizedExpected = NormalizeLineEndings(expected);
var normalizedActual = NormalizeLineEndings(canonical);
if (!string.Equals(normalizedExpected, normalizedActual, StringComparison.Ordinal))
{
var actualPath = ResolveFixturePath("expected-advisory.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, canonical);
}
Assert.Equal(normalizedExpected, normalizedActual);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pending));
Assert.Empty(pending.AsBsonArray);
}
[Fact]
public async Task FetchFailure_RecordsBackoffAndReason()
{
var options = new CertInOptions
{
AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(60),
WindowOverlap = TimeSpan.FromDays(7),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
_handler.Clear();
_handler.AddResponse(options.AlertsEndpoint, () => new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
});
var provider = _serviceProvider!;
var connector = new CertInConnectorPlugin().Create(provider);
await Assert.ThrowsAsync<HttpRequestException>(() => connector.FetchAsync(provider, CancellationToken.None));
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.Equal(1, state!.FailCount);
Assert.NotNull(state.LastFailureReason);
Assert.Contains("500", state.LastFailureReason, StringComparison.Ordinal);
Assert.True(state.BackoffUntil.HasValue);
Assert.True(state.BackoffUntil!.Value > _timeProvider.GetUtcNow());
}
[Fact]
public async Task Fetch_NotModifiedMaintainsDocumentState()
{
var options = new CertInOptions
{
AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(30),
WindowOverlap = TimeSpan.FromDays(7),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
var listingPayload = ReadFixture("alerts-page1.json");
var detailUri = new Uri("https://cert-in.example/advisory/CIAD-2024-0005");
var detailHtml = ReadFixture("detail-CIAD-2024-0005.html");
var etag = new EntityTagHeaderValue("\"certin-2024-0005\"");
var lastModified = new DateTimeOffset(2024, 4, 15, 10, 0, 0, TimeSpan.Zero);
_handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
_handler.AddResponse(detailUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(detailHtml, Encoding.UTF8, "text/html"),
};
response.Headers.ETag = etag;
response.Content.Headers.LastModified = lastModified;
return response;
});
var connector = new CertInConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
Assert.Equal(etag.Tag, document.Etag);
_handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
_handler.AddResponse(detailUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified)
{
Content = new StringContent(string.Empty)
};
response.Headers.ETag = etag;
return response;
});
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
Assert.Equal(0, pendingDocs.AsBsonArray.Count);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings));
Assert.Equal(0, pendingMappings.AsBsonArray.Count);
}
[Fact]
public async Task Fetch_DuplicateContentSkipsRequeue()
{
var options = new CertInOptions
{
AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(30),
WindowOverlap = TimeSpan.FromDays(7),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
var listingPayload = ReadFixture("alerts-page1.json");
var detailUri = new Uri("https://cert-in.example/advisory/CIAD-2024-0005");
var detailHtml = ReadFixture("detail-CIAD-2024-0005.html");
_handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
_handler.AddTextResponse(detailUri, detailHtml, "text/html");
var connector = new CertInConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
_handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
_handler.AddTextResponse(detailUri, detailHtml, "text/html");
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
Assert.Equal(0, pendingDocs.AsBsonArray.Count);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings));
Assert.Equal(0, pendingMappings.AsBsonArray.Count);
}
private async Task EnsureServiceProviderAsync(CertInOptions template)
{
if (_serviceProvider is not null)
{
await ResetDatabaseAsync();
return;
}
public sealed class CertInConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private ServiceProvider? _serviceProvider;
public CertInConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 4, 20, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_GeneratesExpectedSnapshot()
{
var options = new CertInOptions
{
AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(60),
WindowOverlap = TimeSpan.FromDays(7),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
_handler.AddTextResponse(options.AlertsEndpoint, ReadFixture("alerts-page1.json"), "application/json");
var detailUri = new Uri("https://cert-in.example/advisory/CIAD-2024-0005");
_handler.AddTextResponse(detailUri, ReadFixture("detail-CIAD-2024-0005.html"), "text/html");
var connector = new CertInConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
Assert.Single(advisories);
var canonical = SnapshotSerializer.ToSnapshot(advisories.Single());
var expected = ReadFixture("expected-advisory.json");
var normalizedExpected = NormalizeLineEndings(expected);
var normalizedActual = NormalizeLineEndings(canonical);
if (!string.Equals(normalizedExpected, normalizedActual, StringComparison.Ordinal))
{
var actualPath = ResolveFixturePath("expected-advisory.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, canonical);
}
Assert.Equal(normalizedExpected, normalizedActual);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pending));
Assert.Empty(pending.AsDocumentArray);
}
[Fact]
public async Task FetchFailure_RecordsBackoffAndReason()
{
var options = new CertInOptions
{
AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(60),
WindowOverlap = TimeSpan.FromDays(7),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
_handler.Clear();
_handler.AddResponse(options.AlertsEndpoint, () => new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
});
var provider = _serviceProvider!;
var connector = new CertInConnectorPlugin().Create(provider);
await Assert.ThrowsAsync<HttpRequestException>(() => connector.FetchAsync(provider, CancellationToken.None));
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.Equal(1, state!.FailCount);
Assert.NotNull(state.LastFailureReason);
Assert.Contains("500", state.LastFailureReason, StringComparison.Ordinal);
Assert.True(state.BackoffUntil.HasValue);
Assert.True(state.BackoffUntil!.Value > _timeProvider.GetUtcNow());
}
[Fact]
public async Task Fetch_NotModifiedMaintainsDocumentState()
{
var options = new CertInOptions
{
AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(30),
WindowOverlap = TimeSpan.FromDays(7),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
var listingPayload = ReadFixture("alerts-page1.json");
var detailUri = new Uri("https://cert-in.example/advisory/CIAD-2024-0005");
var detailHtml = ReadFixture("detail-CIAD-2024-0005.html");
var etag = new EntityTagHeaderValue("\"certin-2024-0005\"");
var lastModified = new DateTimeOffset(2024, 4, 15, 10, 0, 0, TimeSpan.Zero);
_handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
_handler.AddResponse(detailUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(detailHtml, Encoding.UTF8, "text/html"),
};
response.Headers.ETag = etag;
response.Content.Headers.LastModified = lastModified;
return response;
});
var connector = new CertInConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
Assert.Equal(etag.Tag, document.Etag);
_handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
_handler.AddResponse(detailUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified)
{
Content = new StringContent(string.Empty)
};
response.Headers.ETag = etag;
return response;
});
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
Assert.Equal(0, pendingDocs.AsDocumentArray.Count);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings));
Assert.Equal(0, pendingMappings.AsDocumentArray.Count);
}
[Fact]
public async Task Fetch_DuplicateContentSkipsRequeue()
{
var options = new CertInOptions
{
AlertsEndpoint = new Uri("https://cert-in.example/api/alerts", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(30),
WindowOverlap = TimeSpan.FromDays(7),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
var listingPayload = ReadFixture("alerts-page1.json");
var detailUri = new Uri("https://cert-in.example/advisory/CIAD-2024-0005");
var detailHtml = ReadFixture("detail-CIAD-2024-0005.html");
_handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
_handler.AddTextResponse(detailUri, detailHtml, "text/html");
var connector = new CertInConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
_handler.AddTextResponse(options.AlertsEndpoint, listingPayload, "application/json");
_handler.AddTextResponse(detailUri, detailHtml, "text/html");
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
document = await documentStore.FindBySourceAndUriAsync(CertInConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertInConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
Assert.Equal(0, pendingDocs.AsDocumentArray.Count);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings));
Assert.Equal(0, pendingMappings.AsDocumentArray.Count);
}
private async Task EnsureServiceProviderAsync(CertInOptions template)
{
if (_serviceProvider is not null)
{
await ResetDatabaseAsync();
return;
}
await _fixture.TruncateAllTablesAsync();
@@ -294,19 +294,19 @@ public sealed class CertInConnectorTests : IAsyncLifetime
services.AddSourceCommon();
services.AddCertInConnector(opts =>
{
opts.AlertsEndpoint = template.AlertsEndpoint;
opts.WindowSize = template.WindowSize;
opts.WindowOverlap = template.WindowOverlap;
opts.MaxPagesPerFetch = template.MaxPagesPerFetch;
opts.RequestDelay = template.RequestDelay;
});
services.Configure<HttpClientFactoryOptions>(CertInOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
opts.AlertsEndpoint = template.AlertsEndpoint;
opts.WindowSize = template.WindowSize;
opts.WindowOverlap = template.WindowOverlap;
opts.MaxPagesPerFetch = template.MaxPagesPerFetch;
opts.RequestDelay = template.RequestDelay;
});
services.Configure<HttpClientFactoryOptions>(CertInOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
_serviceProvider = services.BuildServiceProvider();
@@ -314,36 +314,36 @@ public sealed class CertInConnectorTests : IAsyncLifetime
private Task ResetDatabaseAsync()
=> _fixture.TruncateAllTablesAsync();
private static string ReadFixture(string filename)
=> File.ReadAllText(ResolveFixturePath(filename));
private static string ResolveFixturePath(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "CertIn", "Fixtures", filename);
if (File.Exists(primary) || filename.EndsWith(".actual.json", StringComparison.OrdinalIgnoreCase))
{
return primary;
}
return Path.Combine(baseDirectory, "CertIn", "Fixtures", filename);
}
private static string NormalizeLineEndings(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
_serviceProvider?.Dispose();
}
}
}
private static string ReadFixture(string filename)
=> File.ReadAllText(ResolveFixturePath(filename));
private static string ResolveFixturePath(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "CertIn", "Fixtures", filename);
if (File.Exists(primary) || filename.EndsWith(".actual.json", StringComparison.OrdinalIgnoreCase))
{
return primary;
}
return Path.Combine(baseDirectory, "CertIn", "Fixtures", filename);
}
private static string NormalizeLineEndings(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
_serviceProvider?.Dispose();
}
}
}

View File

@@ -1,37 +1,37 @@
using System.Net;
using System.Net.Http;
using StellaOps.Concelier.Connector.Common.Testing;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class CannedHttpMessageHandlerTests
{
[Fact]
public async Task SendAsync_RecordsRequestsAndSupportsFallback()
{
var handler = new CannedHttpMessageHandler();
var requestUri = new Uri("https://example.test/api/resource");
handler.AddResponse(HttpMethod.Get, requestUri, () => new HttpResponseMessage(HttpStatusCode.OK));
handler.SetFallback(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = handler.CreateClient();
var firstResponse = await client.GetAsync(requestUri);
var secondResponse = await client.GetAsync(new Uri("https://example.test/other"));
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, secondResponse.StatusCode);
Assert.Equal(2, handler.Requests.Count);
handler.AssertNoPendingResponses();
}
[Fact]
public async Task AddException_ThrowsDuringSend()
{
var handler = new CannedHttpMessageHandler();
var requestUri = new Uri("https://example.test/api/error");
handler.AddException(HttpMethod.Get, requestUri, new InvalidOperationException("boom"));
using var client = handler.CreateClient();
await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetAsync(requestUri));
}
}
using System.Net;
using System.Net.Http;
using StellaOps.Concelier.Connector.Common.Testing;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class CannedHttpMessageHandlerTests
{
[Fact]
public async Task SendAsync_RecordsRequestsAndSupportsFallback()
{
var handler = new CannedHttpMessageHandler();
var requestUri = new Uri("https://example.test/api/resource");
handler.AddResponse(HttpMethod.Get, requestUri, () => new HttpResponseMessage(HttpStatusCode.OK));
handler.SetFallback(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
using var client = handler.CreateClient();
var firstResponse = await client.GetAsync(requestUri);
var secondResponse = await client.GetAsync(new Uri("https://example.test/other"));
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, secondResponse.StatusCode);
Assert.Equal(2, handler.Requests.Count);
handler.AssertNoPendingResponses();
}
[Fact]
public async Task AddException_ThrowsDuringSend()
{
var handler = new CannedHttpMessageHandler();
var requestUri = new Uri("https://example.test/api/error");
handler.AddException(HttpMethod.Get, requestUri, new InvalidOperationException("boom"));
using var client = handler.CreateClient();
await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetAsync(requestUri));
}
}

View File

@@ -1,24 +1,24 @@
using StellaOps.Concelier.Connector.Common.Html;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class HtmlContentSanitizerTests
{
[Fact]
public void Sanitize_RemovesScriptAndDangerousAttributes()
{
var sanitizer = new HtmlContentSanitizer();
var input = "<div onclick=\"alert(1)\"><script>alert('bad')</script><a href='/foo' target='_blank'>link</a></div>";
var sanitized = sanitizer.Sanitize(input, new Uri("https://example.test/base/"));
Assert.DoesNotContain("script", sanitized, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("onclick", sanitized, StringComparison.OrdinalIgnoreCase);
Assert.Contains("https://example.test/foo", sanitized, StringComparison.Ordinal);
Assert.Contains("rel=\"noopener nofollow noreferrer\"", sanitized, StringComparison.Ordinal);
}
[Fact]
using StellaOps.Concelier.Connector.Common.Html;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class HtmlContentSanitizerTests
{
[Fact]
public void Sanitize_RemovesScriptAndDangerousAttributes()
{
var sanitizer = new HtmlContentSanitizer();
var input = "<div onclick=\"alert(1)\"><script>alert('bad')</script><a href='/foo' target='_blank'>link</a></div>";
var sanitized = sanitizer.Sanitize(input, new Uri("https://example.test/base/"));
Assert.DoesNotContain("script", sanitized, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("onclick", sanitized, StringComparison.OrdinalIgnoreCase);
Assert.Contains("https://example.test/foo", sanitized, StringComparison.Ordinal);
Assert.Contains("rel=\"noopener nofollow noreferrer\"", sanitized, StringComparison.Ordinal);
}
[Fact]
public void Sanitize_PreservesBasicFormatting()
{
var sanitizer = new HtmlContentSanitizer();

View File

@@ -1,41 +1,41 @@
using NuGet.Versioning;
using StellaOps.Concelier.Connector.Common.Packages;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class PackageCoordinateHelperTests
{
[Fact]
public void TryParsePackageUrl_ReturnsCanonicalForm()
{
var success = PackageCoordinateHelper.TryParsePackageUrl("pkg:npm/@scope/example@1.0.0?env=prod", out var coordinates);
Assert.True(success);
Assert.NotNull(coordinates);
Assert.Equal("pkg:npm/@scope/example@1.0.0?env=prod", coordinates!.Canonical);
Assert.Equal("npm", coordinates.Type);
Assert.Equal("example", coordinates.Name);
Assert.Equal("1.0.0", coordinates.Version);
Assert.Equal("prod", coordinates.Qualifiers["env"]);
}
[Fact]
public void TryParseSemVer_NormalizesVersion()
{
var success = PackageCoordinateHelper.TryParseSemVer("1.2.3+build", out var version, out var normalized);
Assert.True(success);
Assert.Equal(SemanticVersion.Parse("1.2.3"), version);
Assert.Equal("1.2.3", normalized);
}
[Fact]
public void TryParseSemVerRange_SupportsCaret()
{
var success = PackageCoordinateHelper.TryParseSemVerRange("^1.2.3", out var range);
Assert.True(success);
Assert.NotNull(range);
Assert.True(range!.Satisfies(NuGetVersion.Parse("1.3.0")));
}
}
using NuGet.Versioning;
using StellaOps.Concelier.Connector.Common.Packages;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class PackageCoordinateHelperTests
{
[Fact]
public void TryParsePackageUrl_ReturnsCanonicalForm()
{
var success = PackageCoordinateHelper.TryParsePackageUrl("pkg:npm/@scope/example@1.0.0?env=prod", out var coordinates);
Assert.True(success);
Assert.NotNull(coordinates);
Assert.Equal("pkg:npm/@scope/example@1.0.0?env=prod", coordinates!.Canonical);
Assert.Equal("npm", coordinates.Type);
Assert.Equal("example", coordinates.Name);
Assert.Equal("1.0.0", coordinates.Version);
Assert.Equal("prod", coordinates.Qualifiers["env"]);
}
[Fact]
public void TryParseSemVer_NormalizesVersion()
{
var success = PackageCoordinateHelper.TryParseSemVer("1.2.3+build", out var version, out var normalized);
Assert.True(success);
Assert.Equal(SemanticVersion.Parse("1.2.3"), version);
Assert.Equal("1.2.3", normalized);
}
[Fact]
public void TryParseSemVerRange_SupportsCaret()
{
var success = PackageCoordinateHelper.TryParseSemVerRange("^1.2.3", out var range);
Assert.True(success);
Assert.NotNull(range);
Assert.True(range!.Satisfies(NuGetVersion.Parse("1.3.0")));
}
}

View File

@@ -1,21 +1,21 @@
using StellaOps.Concelier.Connector.Common.Pdf;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class PdfTextExtractorTests
{
private const string SamplePdfBase64 = "JVBERi0xLjEKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA0NCA+PgpzdHJlYW0KQlQKL0YxIDI0IFRmCjcyIDcyMCBUZAooSGVsbG8gV29ybGQpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKNSAwIG9iago8PCAvVHlwZSAvRm9udCAvU3VidHlwZSAvVHlwZTEgL0Jhc2VGb250IC9IZWx2ZXRpY2EgPj4KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMTAgMDAwMDAgbiAKMDAwMDAwMDU2IDAwMDAwIG4gCjAwMDAwMDAxMTMgMDAwMDAgbiAKMDAwMDAwMDIxMCAwMDAwMCBuIAowMDAwMDAwMzExIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1Jvb3QgMSAwIFIgL1NpemUgNiA+PgpzdGFydHhyZWYKMzc3CiUlRU9G";
[Fact]
public async Task ExtractTextAsync_ReturnsPageText()
{
var bytes = Convert.FromBase64String(SamplePdfBase64);
using var stream = new MemoryStream(bytes);
var extractor = new PdfTextExtractor();
var result = await extractor.ExtractTextAsync(stream, cancellationToken: CancellationToken.None);
Assert.Contains("Hello World", result.Text);
Assert.Equal(1, result.PagesProcessed);
}
}
using StellaOps.Concelier.Connector.Common.Pdf;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class PdfTextExtractorTests
{
private const string SamplePdfBase64 = "JVBERi0xLjEKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA0NCA+PgpzdHJlYW0KQlQKL0YxIDI0IFRmCjcyIDcyMCBUZAooSGVsbG8gV29ybGQpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKNSAwIG9iago8PCAvVHlwZSAvRm9udCAvU3VidHlwZSAvVHlwZTEgL0Jhc2VGb250IC9IZWx2ZXRpY2EgPj4KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMTAgMDAwMDAgbiAKMDAwMDAwMDU2IDAwMDAwIG4gCjAwMDAwMDAxMTMgMDAwMDAgbiAKMDAwMDAwMDIxMCAwMDAwMCBuIAowMDAwMDAwMzExIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1Jvb3QgMSAwIFIgL1NpemUgNiA+PgpzdGFydHhyZWYKMzc3CiUlRU9G";
[Fact]
public async Task ExtractTextAsync_ReturnsPageText()
{
var bytes = Convert.FromBase64String(SamplePdfBase64);
using var stream = new MemoryStream(bytes);
var extractor = new PdfTextExtractor();
var result = await extractor.ExtractTextAsync(stream, cancellationToken: CancellationToken.None);
Assert.Contains("Hello World", result.Text);
Assert.Equal(1, result.PagesProcessed);
}
}

View File

@@ -1,258 +1,258 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Concelier.Bson;
using MongoDB.Driver;
using StellaOps.Aoc;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.RawModels;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Cryptography;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class SourceFetchServiceGuardTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
private readonly IMongoDatabase _database;
private readonly RawDocumentStorage _rawStorage;
private readonly ICryptoHash _hash;
public SourceFetchServiceGuardTests()
{
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(_runner.ConnectionString);
_database = client.GetDatabase($"source-fetch-guard-{Guid.NewGuid():N}");
_rawStorage = new RawDocumentStorage();
_hash = CryptoHashFactory.CreateDefault();
}
[Fact]
public async Task FetchAsync_ValidatesWithGuardBeforePersisting()
{
var responsePayload = "{\"id\":\"CVE-2025-1111\"}";
var handler = new StaticHttpMessageHandler(() => CreateSuccessResponse(responsePayload));
var client = new HttpClient(handler, disposeHandler: false);
var httpClientFactory = new StaticHttpClientFactory(client);
var documentStore = new RecordingDocumentStore();
var guard = new RecordingAdvisoryRawWriteGuard();
var jitter = new NoJitterSource();
var httpOptions = new TestOptionsMonitor<StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions>(new StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions());
var storageOptions = Options.Create(new MongoStorageOptions
{
ConnectionString = _runner.ConnectionString,
DatabaseName = _database.DatabaseNamespace.DatabaseName,
});
var linksetMapper = new NoopAdvisoryLinksetMapper();
var service = new SourceFetchService(
httpClientFactory,
_rawStorage,
documentStore,
NullLogger<SourceFetchService>.Instance,
jitter,
guard,
linksetMapper,
_hash,
TimeProvider.System,
httpOptions,
storageOptions);
var request = new SourceFetchRequest("client", "vndr.msrc", new Uri("https://example.test/advisories/ADV-1234"))
{
Metadata = new Dictionary<string, string>
{
["upstream.id"] = "ADV-1234",
["content.format"] = "csaf",
["msrc.lastModified"] = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"),
}
};
var result = await service.FetchAsync(request, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.NotNull(guard.LastDocument);
Assert.Equal("tenant-default", guard.LastDocument!.Tenant);
Assert.Equal("msrc", guard.LastDocument.Source.Vendor);
Assert.Equal("ADV-1234", guard.LastDocument.Upstream.UpstreamId);
var expectedHash = _hash.ComputeHashHex(Encoding.UTF8.GetBytes(responsePayload), HashAlgorithms.Sha256);
Assert.Equal(expectedHash, guard.LastDocument.Upstream.ContentHash);
Assert.NotNull(documentStore.LastRecord);
Assert.True(documentStore.UpsertCount > 0);
Assert.Equal("msrc", documentStore.LastRecord!.Metadata!["source.vendor"]);
Assert.Equal("tenant-default", documentStore.LastRecord.Metadata!["tenant"]);
// verify raw payload stored
var filesCollection = _database.GetCollection<BsonDocument>("documents.files");
var count = await filesCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
Assert.Equal(1, count);
}
[Fact]
public async Task FetchAsync_WhenGuardThrows_DoesNotPersist()
{
var handler = new StaticHttpMessageHandler(() => CreateSuccessResponse("{\"id\":\"CVE-2025-2222\"}"));
var client = new HttpClient(handler, disposeHandler: false);
var httpClientFactory = new StaticHttpClientFactory(client);
var documentStore = new RecordingDocumentStore();
var guard = new RecordingAdvisoryRawWriteGuard { ShouldThrow = true };
var jitter = new NoJitterSource();
var httpOptions = new TestOptionsMonitor<StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions>(new StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions());
var storageOptions = Options.Create(new MongoStorageOptions
{
ConnectionString = _runner.ConnectionString,
DatabaseName = _database.DatabaseNamespace.DatabaseName,
});
var linksetMapper = new NoopAdvisoryLinksetMapper();
var service = new SourceFetchService(
httpClientFactory,
_rawStorage,
documentStore,
NullLogger<SourceFetchService>.Instance,
jitter,
guard,
linksetMapper,
_hash,
TimeProvider.System,
httpOptions,
storageOptions);
var request = new SourceFetchRequest("client", "nvd", new Uri("https://example.test/data/XYZ"))
{
Metadata = new Dictionary<string, string>
{
["vulnerability.id"] = "CVE-2025-2222",
}
};
await Assert.ThrowsAsync<ConcelierAocGuardException>(() => service.FetchAsync(request, CancellationToken.None));
Assert.Equal(0, documentStore.UpsertCount);
var filesCollection = _database.GetCollection<BsonDocument>("documents.files");
var count = await filesCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
Assert.Equal(0, count);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
private static HttpResponseMessage CreateSuccessResponse(string payload)
{
var message = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
message.Headers.ETag = new EntityTagHeaderValue("\"etag\"");
message.Content.Headers.LastModified = DateTimeOffset.UtcNow.AddHours(-1);
return message;
}
private sealed class StaticHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public StaticHttpClientFactory(HttpClient client) => _client = client;
public HttpClient CreateClient(string name) => _client;
}
private sealed class StaticHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpResponseMessage> _responseFactory;
public StaticHttpMessageHandler(Func<HttpResponseMessage> responseFactory) => _responseFactory = responseFactory;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_responseFactory());
}
private sealed class RecordingDocumentStore : IDocumentStore
{
public DocumentRecord? LastRecord { get; private set; }
public int UpsertCount { get; private set; }
public Task<DocumentRecord> UpsertAsync(DocumentRecord record, CancellationToken cancellationToken)
{
UpsertCount++;
LastRecord = record;
return Task.FromResult(record);
}
public Task<DocumentRecord?> FindBySourceAndUriAsync(string sourceName, string uri, CancellationToken cancellationToken)
=> Task.FromResult<DocumentRecord?>(null);
public Task<DocumentRecord?> FindAsync(Guid id, CancellationToken cancellationToken)
=> Task.FromResult<DocumentRecord?>(null);
public Task UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
private sealed class RecordingAdvisoryRawWriteGuard : IAdvisoryRawWriteGuard
{
public AdvisoryRawDocument? LastDocument { get; private set; }
public bool ShouldThrow { get; set; }
public void EnsureValid(AdvisoryRawDocument document)
{
LastDocument = document;
if (ShouldThrow)
{
var violation = AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "test");
throw new ConcelierAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
}
}
}
private sealed class NoJitterSource : IJitterSource
{
public TimeSpan Next(TimeSpan minInclusive, TimeSpan maxInclusive) => minInclusive;
}
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
where T : class, new()
{
private readonly T _options;
public TestOptionsMonitor(T options) => _options = options;
public T CurrentValue => _options;
public T Get(string? name) => _options;
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose() { }
}
}
private sealed class NoopAdvisoryLinksetMapper : IAdvisoryLinksetMapper
{
public RawLinkset Map(AdvisoryRawDocument document) => new();
}
}
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.InMemoryRunner;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.InMemoryDriver;
using StellaOps.Aoc;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.RawModels;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Cryptography;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class SourceFetchServiceGuardTests : IAsyncLifetime
{
private readonly InMemoryDbRunner _runner;
private readonly IMongoDatabase _database;
private readonly RawDocumentStorage _rawStorage;
private readonly ICryptoHash _hash;
public SourceFetchServiceGuardTests()
{
_runner = InMemoryDbRunner.Start(singleNodeReplSet: true);
var client = new InMemoryClient(_runner.ConnectionString);
_database = client.GetDatabase($"source-fetch-guard-{Guid.NewGuid():N}");
_rawStorage = new RawDocumentStorage();
_hash = CryptoHashFactory.CreateDefault();
}
[Fact]
public async Task FetchAsync_ValidatesWithGuardBeforePersisting()
{
var responsePayload = "{\"id\":\"CVE-2025-1111\"}";
var handler = new StaticHttpMessageHandler(() => CreateSuccessResponse(responsePayload));
var client = new HttpClient(handler, disposeHandler: false);
var httpClientFactory = new StaticHttpClientFactory(client);
var documentStore = new RecordingDocumentStore();
var guard = new RecordingAdvisoryRawWriteGuard();
var jitter = new NoJitterSource();
var httpOptions = new TestOptionsMonitor<StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions>(new StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions());
var storageOptions = Options.Create(new StorageOptions
{
ConnectionString = _runner.ConnectionString,
DatabaseName = _database.DatabaseNamespace.DatabaseName,
});
var linksetMapper = new NoopAdvisoryLinksetMapper();
var service = new SourceFetchService(
httpClientFactory,
_rawStorage,
documentStore,
NullLogger<SourceFetchService>.Instance,
jitter,
guard,
linksetMapper,
_hash,
TimeProvider.System,
httpOptions,
storageOptions);
var request = new SourceFetchRequest("client", "vndr.msrc", new Uri("https://example.test/advisories/ADV-1234"))
{
Metadata = new Dictionary<string, string>
{
["upstream.id"] = "ADV-1234",
["content.format"] = "csaf",
["msrc.lastModified"] = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"),
}
};
var result = await service.FetchAsync(request, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.NotNull(guard.LastDocument);
Assert.Equal("tenant-default", guard.LastDocument!.Tenant);
Assert.Equal("msrc", guard.LastDocument.Source.Vendor);
Assert.Equal("ADV-1234", guard.LastDocument.Upstream.UpstreamId);
var expectedHash = _hash.ComputeHashHex(Encoding.UTF8.GetBytes(responsePayload), HashAlgorithms.Sha256);
Assert.Equal(expectedHash, guard.LastDocument.Upstream.ContentHash);
Assert.NotNull(documentStore.LastRecord);
Assert.True(documentStore.UpsertCount > 0);
Assert.Equal("msrc", documentStore.LastRecord!.Metadata!["source.vendor"]);
Assert.Equal("tenant-default", documentStore.LastRecord.Metadata!["tenant"]);
// verify raw payload stored
var filesCollection = _database.GetCollection<DocumentObject>("documents.files");
var count = await filesCollection.CountDocumentsAsync(FilterDefinition<DocumentObject>.Empty);
Assert.Equal(1, count);
}
[Fact]
public async Task FetchAsync_WhenGuardThrows_DoesNotPersist()
{
var handler = new StaticHttpMessageHandler(() => CreateSuccessResponse("{\"id\":\"CVE-2025-2222\"}"));
var client = new HttpClient(handler, disposeHandler: false);
var httpClientFactory = new StaticHttpClientFactory(client);
var documentStore = new RecordingDocumentStore();
var guard = new RecordingAdvisoryRawWriteGuard { ShouldThrow = true };
var jitter = new NoJitterSource();
var httpOptions = new TestOptionsMonitor<StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions>(new StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions());
var storageOptions = Options.Create(new StorageOptions
{
ConnectionString = _runner.ConnectionString,
DatabaseName = _database.DatabaseNamespace.DatabaseName,
});
var linksetMapper = new NoopAdvisoryLinksetMapper();
var service = new SourceFetchService(
httpClientFactory,
_rawStorage,
documentStore,
NullLogger<SourceFetchService>.Instance,
jitter,
guard,
linksetMapper,
_hash,
TimeProvider.System,
httpOptions,
storageOptions);
var request = new SourceFetchRequest("client", "nvd", new Uri("https://example.test/data/XYZ"))
{
Metadata = new Dictionary<string, string>
{
["vulnerability.id"] = "CVE-2025-2222",
}
};
await Assert.ThrowsAsync<ConcelierAocGuardException>(() => service.FetchAsync(request, CancellationToken.None));
Assert.Equal(0, documentStore.UpsertCount);
var filesCollection = _database.GetCollection<DocumentObject>("documents.files");
var count = await filesCollection.CountDocumentsAsync(FilterDefinition<DocumentObject>.Empty);
Assert.Equal(0, count);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
private static HttpResponseMessage CreateSuccessResponse(string payload)
{
var message = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
message.Headers.ETag = new EntityTagHeaderValue("\"etag\"");
message.Content.Headers.LastModified = DateTimeOffset.UtcNow.AddHours(-1);
return message;
}
private sealed class StaticHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public StaticHttpClientFactory(HttpClient client) => _client = client;
public HttpClient CreateClient(string name) => _client;
}
private sealed class StaticHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpResponseMessage> _responseFactory;
public StaticHttpMessageHandler(Func<HttpResponseMessage> responseFactory) => _responseFactory = responseFactory;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_responseFactory());
}
private sealed class RecordingDocumentStore : IDocumentStore
{
public DocumentRecord? LastRecord { get; private set; }
public int UpsertCount { get; private set; }
public Task<DocumentRecord> UpsertAsync(DocumentRecord record, CancellationToken cancellationToken)
{
UpsertCount++;
LastRecord = record;
return Task.FromResult(record);
}
public Task<DocumentRecord?> FindBySourceAndUriAsync(string sourceName, string uri, CancellationToken cancellationToken)
=> Task.FromResult<DocumentRecord?>(null);
public Task<DocumentRecord?> FindAsync(Guid id, CancellationToken cancellationToken)
=> Task.FromResult<DocumentRecord?>(null);
public Task UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
private sealed class RecordingAdvisoryRawWriteGuard : IAdvisoryRawWriteGuard
{
public AdvisoryRawDocument? LastDocument { get; private set; }
public bool ShouldThrow { get; set; }
public void EnsureValid(AdvisoryRawDocument document)
{
LastDocument = document;
if (ShouldThrow)
{
var violation = AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "test");
throw new ConcelierAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
}
}
}
private sealed class NoJitterSource : IJitterSource
{
public TimeSpan Next(TimeSpan minInclusive, TimeSpan maxInclusive) => minInclusive;
}
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
where T : class, new()
{
private readonly T _options;
public TestOptionsMonitor(T options) => _options = options;
public T CurrentValue => _options;
public T Get(string? name) => _options;
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose() { }
}
}
private sealed class NoopAdvisoryLinksetMapper : IAdvisoryLinksetMapper
{
public RawLinkset Map(AdvisoryRawDocument document) => new();
}
}

View File

@@ -1,36 +1,36 @@
using StellaOps.Concelier.Connector.Common.Fetch;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class SourceFetchServiceTests
{
[Fact]
public void CreateHttpRequestMessage_DefaultsToJsonAccept()
{
var request = new SourceFetchRequest("client", "source", new Uri("https://example.test/data"));
using var message = SourceFetchService.CreateHttpRequestMessage(request);
Assert.Single(message.Headers.Accept);
Assert.Equal("application/json", message.Headers.Accept.First().MediaType);
}
[Fact]
public void CreateHttpRequestMessage_UsesAcceptOverrides()
{
var request = new SourceFetchRequest("client", "source", new Uri("https://example.test/data"))
{
AcceptHeaders = new[]
{
"text/html",
"application/xhtml+xml;q=0.9",
}
};
using var message = SourceFetchService.CreateHttpRequestMessage(request);
Assert.Equal(2, message.Headers.Accept.Count);
Assert.Contains(message.Headers.Accept, h => h.MediaType == "text/html");
Assert.Contains(message.Headers.Accept, h => h.MediaType == "application/xhtml+xml");
}
}
using StellaOps.Concelier.Connector.Common.Fetch;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class SourceFetchServiceTests
{
[Fact]
public void CreateHttpRequestMessage_DefaultsToJsonAccept()
{
var request = new SourceFetchRequest("client", "source", new Uri("https://example.test/data"));
using var message = SourceFetchService.CreateHttpRequestMessage(request);
Assert.Single(message.Headers.Accept);
Assert.Equal("application/json", message.Headers.Accept.First().MediaType);
}
[Fact]
public void CreateHttpRequestMessage_UsesAcceptOverrides()
{
var request = new SourceFetchRequest("client", "source", new Uri("https://example.test/data"))
{
AcceptHeaders = new[]
{
"text/html",
"application/xhtml+xml;q=0.9",
}
};
using var message = SourceFetchService.CreateHttpRequestMessage(request);
Assert.Equal(2, message.Headers.Accept.Count);
Assert.Contains(message.Headers.Accept, h => h.MediaType == "text/html");
Assert.Contains(message.Headers.Accept, h => h.MediaType == "application/xhtml+xml");
}
}

View File

@@ -1,327 +1,327 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.Common.Http;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class SourceHttpClientBuilderTests
{
[Fact]
public void AddSourceHttpClient_ConfiguresVersionAndHandler()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
bool configureInvoked = false;
bool? observedEnableMultiple = null;
SocketsHttpHandler? capturedHandler = null;
services.AddSourceHttpClient("source.test", (_, options) =>
{
options.AllowedHosts.Add("example.test");
options.RequestVersion = HttpVersion.Version20;
options.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
options.EnableMultipleHttp2Connections = false;
options.ConfigureHandler = handler =>
{
capturedHandler = handler;
observedEnableMultiple = handler.EnableMultipleHttp2Connections;
configureInvoked = true;
};
});
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient("source.test");
Assert.Equal(HttpVersion.Version20, client.DefaultRequestVersion);
Assert.Equal(HttpVersionPolicy.RequestVersionOrLower, client.DefaultVersionPolicy);
Assert.True(configureInvoked);
Assert.False(observedEnableMultiple);
Assert.NotNull(capturedHandler);
}
[Fact]
public void AddSourceHttpClient_LoadsProxyConfiguration()
{
var services = new ServiceCollection();
services.AddLogging();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyAddressKey}"] = "http://proxy.local:8080",
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyBypassOnLocalKey}"] = "false",
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyBypassListKey}:0"] = "localhost",
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyBypassListKey}:1"] = "127.0.0.1",
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyUseDefaultCredentialsKey}"] = "false",
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyUsernameKey}"] = "svc-concelier",
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyPasswordKey}"] = "s3cr3t!",
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddSourceHttpClient("source.icscisa", (_, options) =>
{
options.AllowedHosts.Add("content.govdelivery.com");
options.ProxyAddress = new Uri("http://configure.local:9000");
});
using var provider = services.BuildServiceProvider();
_ = provider.GetRequiredService<IHttpClientFactory>().CreateClient("source.icscisa");
var resolvedConfiguration = provider.GetRequiredService<IConfiguration>();
var proxySection = resolvedConfiguration
.GetSection("concelier")
.GetSection("httpClients")
.GetSection("source.icscisa")
.GetSection("proxy");
Assert.True(proxySection.Exists());
Assert.Equal("http://proxy.local:8080", proxySection[ProxyAddressKey]);
var configuredOptions = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>().Get("source.icscisa");
Assert.NotNull(configuredOptions.ProxyAddress);
Assert.Equal(new Uri("http://proxy.local:8080"), configuredOptions.ProxyAddress);
Assert.False(configuredOptions.ProxyBypassOnLocal);
Assert.Contains("localhost", configuredOptions.ProxyBypassList, StringComparer.OrdinalIgnoreCase);
Assert.Contains("127.0.0.1", configuredOptions.ProxyBypassList);
Assert.False(configuredOptions.ProxyUseDefaultCredentials);
Assert.Equal("svc-concelier", configuredOptions.ProxyUsername);
Assert.Equal("s3cr3t!", configuredOptions.ProxyPassword);
}
[Fact]
public void AddSourceHttpClient_UsesConfigurationToBypassValidation()
{
var services = new ServiceCollection();
services.AddLogging();
using var trustedRoot = CreateSelfSignedCertificate();
var pemPath = Path.Combine(Path.GetTempPath(), $"stellaops-trust-{Guid.NewGuid():N}.pem");
WriteCertificatePem(trustedRoot, pemPath);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"concelier:httpClients:source.acsc:{AllowInvalidKey}"] = "true",
[$"concelier:httpClients:source.acsc:{TrustedRootPathsKey}:0"] = pemPath,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
bool configureInvoked = false;
SocketsHttpHandler? capturedHandler = null;
services.AddSourceHttpClient("source.acsc", (_, options) =>
{
options.AllowedHosts.Add("example.test");
options.ConfigureHandler = handler =>
{
capturedHandler = handler;
configureInvoked = true;
};
});
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient("source.acsc");
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>();
var configuredOptions = optionsMonitor.Get("source.acsc");
Assert.True(configureInvoked);
Assert.NotNull(capturedHandler);
Assert.True(configuredOptions.AllowInvalidServerCertificates);
Assert.NotNull(capturedHandler!.SslOptions.RemoteCertificateValidationCallback);
var callback = capturedHandler.SslOptions.RemoteCertificateValidationCallback!;
#pragma warning disable SYSLIB0057
using var serverCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
var result = callback(new object(), serverCertificate, null, SslPolicyErrors.RemoteCertificateChainErrors);
Assert.True(result);
File.Delete(pemPath);
}
[Fact]
public void AddSourceHttpClient_LoadsTrustedRootsFromOfflineRoot()
{
var services = new ServiceCollection();
services.AddLogging();
using var trustedRoot = CreateSelfSignedCertificate();
var offlineRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"stellaops-offline-{Guid.NewGuid():N}"));
var relativePath = Path.Combine("trust", "root.pem");
var certificatePath = Path.Combine(offlineRoot.FullName, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(certificatePath)!);
WriteCertificatePem(trustedRoot, certificatePath);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"concelier:{OfflineRootKey}"] = offlineRoot.FullName,
[$"concelier:httpClients:source.nkcki:{TrustedRootPathsKey}:0"] = relativePath,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
SocketsHttpHandler? capturedHandler = null;
services.AddSourceHttpClient("source.nkcki", (_, options) =>
{
options.AllowedHosts.Add("example.test");
options.ConfigureHandler = handler => capturedHandler = handler;
});
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
_ = factory.CreateClient("source.nkcki");
var monitor = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>();
var configuredOptions = monitor.Get("source.nkcki");
Assert.False(configuredOptions.AllowInvalidServerCertificates);
Assert.NotEmpty(configuredOptions.TrustedRootCertificates);
using (var manualChain = new X509Chain())
{
manualChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
manualChain.ChainPolicy.CustomTrustStore.AddRange(configuredOptions.TrustedRootCertificates.ToArray());
manualChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
manualChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
#pragma warning disable SYSLIB0057
using var manualServerCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
Assert.True(manualChain.Build(manualServerCertificate));
}
Assert.All(configuredOptions.TrustedRootCertificates, certificate => Assert.NotEqual(IntPtr.Zero, certificate.Handle));
Assert.NotNull(capturedHandler);
var callback = capturedHandler!.SslOptions.RemoteCertificateValidationCallback;
Assert.NotNull(callback);
#pragma warning disable SYSLIB0057
using var serverCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
using var chain = new X509Chain();
chain.ChainPolicy.CustomTrustStore.Add(serverCertificate);
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
_ = chain.Build(serverCertificate);
var validationResult = callback!(new object(), serverCertificate, chain, SslPolicyErrors.RemoteCertificateChainErrors);
Assert.True(validationResult);
Directory.Delete(offlineRoot.FullName, recursive: true);
}
[Fact]
public void AddSourceHttpClient_LoadsConfigurationFromSourceHttpSection()
{
var services = new ServiceCollection();
services.AddLogging();
using var trustedRoot = CreateSelfSignedCertificate();
var offlineRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"stellaops-offline-{Guid.NewGuid():N}"));
var relativePath = Path.Combine("certs", "root.pem");
var certificatePath = Path.Combine(offlineRoot.FullName, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(certificatePath)!);
WriteCertificatePem(trustedRoot, certificatePath);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"concelier:{OfflineRootKey}"] = offlineRoot.FullName,
[$"concelier:sources:nkcki:http:{TrustedRootPathsKey}:0"] = relativePath,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
SocketsHttpHandler? capturedHandler = null;
services.AddSourceHttpClient("source.nkcki", (_, options) =>
{
options.AllowedHosts.Add("example.test");
options.ConfigureHandler = handler => capturedHandler = handler;
});
using var provider = services.BuildServiceProvider();
_ = provider.GetRequiredService<IHttpClientFactory>().CreateClient("source.nkcki");
var configuredOptions = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>().Get("source.nkcki");
Assert.False(configuredOptions.AllowInvalidServerCertificates);
Assert.NotEmpty(configuredOptions.TrustedRootCertificates);
using (var manualChain = new X509Chain())
{
manualChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
manualChain.ChainPolicy.CustomTrustStore.AddRange(configuredOptions.TrustedRootCertificates.ToArray());
manualChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
manualChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
#pragma warning disable SYSLIB0057
using var manualServerCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
Assert.True(manualChain.Build(manualServerCertificate));
}
Assert.All(configuredOptions.TrustedRootCertificates, certificate => Assert.NotEqual(IntPtr.Zero, certificate.Handle));
Assert.NotNull(capturedHandler);
var callback = capturedHandler!.SslOptions.RemoteCertificateValidationCallback;
Assert.NotNull(callback);
#pragma warning disable SYSLIB0057
using var serverCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
using var chain = new X509Chain();
chain.ChainPolicy.CustomTrustStore.Add(serverCertificate);
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
_ = chain.Build(serverCertificate);
var validationResult = callback!(new object(), serverCertificate, chain, SslPolicyErrors.RemoteCertificateChainErrors);
Assert.True(validationResult);
Directory.Delete(offlineRoot.FullName, recursive: true);
}
private static X509Certificate2 CreateSelfSignedCertificate()
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest("CN=StellaOps Test Root", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5));
}
private static void WriteCertificatePem(X509Certificate2 certificate, string path)
{
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN CERTIFICATE-----");
builder.AppendLine(Convert.ToBase64String(certificate.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END CERTIFICATE-----");
File.WriteAllText(path, builder.ToString(), Encoding.ASCII);
}
private const string AllowInvalidKey = "allowInvalidCertificates";
private const string TrustedRootPathsKey = "trustedRootPaths";
private const string OfflineRootKey = "offlineRoot";
private const string ProxySection = "proxy";
private const string ProxyAddressKey = "address";
private const string ProxyBypassOnLocalKey = "bypassOnLocal";
private const string ProxyBypassListKey = "bypassList";
private const string ProxyUseDefaultCredentialsKey = "useDefaultCredentials";
private const string ProxyUsernameKey = "username";
private const string ProxyPasswordKey = "password";
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.Common.Http;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class SourceHttpClientBuilderTests
{
[Fact]
public void AddSourceHttpClient_ConfiguresVersionAndHandler()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
bool configureInvoked = false;
bool? observedEnableMultiple = null;
SocketsHttpHandler? capturedHandler = null;
services.AddSourceHttpClient("source.test", (_, options) =>
{
options.AllowedHosts.Add("example.test");
options.RequestVersion = HttpVersion.Version20;
options.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
options.EnableMultipleHttp2Connections = false;
options.ConfigureHandler = handler =>
{
capturedHandler = handler;
observedEnableMultiple = handler.EnableMultipleHttp2Connections;
configureInvoked = true;
};
});
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient("source.test");
Assert.Equal(HttpVersion.Version20, client.DefaultRequestVersion);
Assert.Equal(HttpVersionPolicy.RequestVersionOrLower, client.DefaultVersionPolicy);
Assert.True(configureInvoked);
Assert.False(observedEnableMultiple);
Assert.NotNull(capturedHandler);
}
[Fact]
public void AddSourceHttpClient_LoadsProxyConfiguration()
{
var services = new ServiceCollection();
services.AddLogging();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyAddressKey}"] = "http://proxy.local:8080",
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyBypassOnLocalKey}"] = "false",
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyBypassListKey}:0"] = "localhost",
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyBypassListKey}:1"] = "127.0.0.1",
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyUseDefaultCredentialsKey}"] = "false",
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyUsernameKey}"] = "svc-concelier",
[$"concelier:httpClients:source.icscisa:{ProxySection}:{ProxyPasswordKey}"] = "s3cr3t!",
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddSourceHttpClient("source.icscisa", (_, options) =>
{
options.AllowedHosts.Add("content.govdelivery.com");
options.ProxyAddress = new Uri("http://configure.local:9000");
});
using var provider = services.BuildServiceProvider();
_ = provider.GetRequiredService<IHttpClientFactory>().CreateClient("source.icscisa");
var resolvedConfiguration = provider.GetRequiredService<IConfiguration>();
var proxySection = resolvedConfiguration
.GetSection("concelier")
.GetSection("httpClients")
.GetSection("source.icscisa")
.GetSection("proxy");
Assert.True(proxySection.Exists());
Assert.Equal("http://proxy.local:8080", proxySection[ProxyAddressKey]);
var configuredOptions = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>().Get("source.icscisa");
Assert.NotNull(configuredOptions.ProxyAddress);
Assert.Equal(new Uri("http://proxy.local:8080"), configuredOptions.ProxyAddress);
Assert.False(configuredOptions.ProxyBypassOnLocal);
Assert.Contains("localhost", configuredOptions.ProxyBypassList, StringComparer.OrdinalIgnoreCase);
Assert.Contains("127.0.0.1", configuredOptions.ProxyBypassList);
Assert.False(configuredOptions.ProxyUseDefaultCredentials);
Assert.Equal("svc-concelier", configuredOptions.ProxyUsername);
Assert.Equal("s3cr3t!", configuredOptions.ProxyPassword);
}
[Fact]
public void AddSourceHttpClient_UsesConfigurationToBypassValidation()
{
var services = new ServiceCollection();
services.AddLogging();
using var trustedRoot = CreateSelfSignedCertificate();
var pemPath = Path.Combine(Path.GetTempPath(), $"stellaops-trust-{Guid.NewGuid():N}.pem");
WriteCertificatePem(trustedRoot, pemPath);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"concelier:httpClients:source.acsc:{AllowInvalidKey}"] = "true",
[$"concelier:httpClients:source.acsc:{TrustedRootPathsKey}:0"] = pemPath,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
bool configureInvoked = false;
SocketsHttpHandler? capturedHandler = null;
services.AddSourceHttpClient("source.acsc", (_, options) =>
{
options.AllowedHosts.Add("example.test");
options.ConfigureHandler = handler =>
{
capturedHandler = handler;
configureInvoked = true;
};
});
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient("source.acsc");
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>();
var configuredOptions = optionsMonitor.Get("source.acsc");
Assert.True(configureInvoked);
Assert.NotNull(capturedHandler);
Assert.True(configuredOptions.AllowInvalidServerCertificates);
Assert.NotNull(capturedHandler!.SslOptions.RemoteCertificateValidationCallback);
var callback = capturedHandler.SslOptions.RemoteCertificateValidationCallback!;
#pragma warning disable SYSLIB0057
using var serverCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
var result = callback(new object(), serverCertificate, null, SslPolicyErrors.RemoteCertificateChainErrors);
Assert.True(result);
File.Delete(pemPath);
}
[Fact]
public void AddSourceHttpClient_LoadsTrustedRootsFromOfflineRoot()
{
var services = new ServiceCollection();
services.AddLogging();
using var trustedRoot = CreateSelfSignedCertificate();
var offlineRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"stellaops-offline-{Guid.NewGuid():N}"));
var relativePath = Path.Combine("trust", "root.pem");
var certificatePath = Path.Combine(offlineRoot.FullName, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(certificatePath)!);
WriteCertificatePem(trustedRoot, certificatePath);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"concelier:{OfflineRootKey}"] = offlineRoot.FullName,
[$"concelier:httpClients:source.nkcki:{TrustedRootPathsKey}:0"] = relativePath,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
SocketsHttpHandler? capturedHandler = null;
services.AddSourceHttpClient("source.nkcki", (_, options) =>
{
options.AllowedHosts.Add("example.test");
options.ConfigureHandler = handler => capturedHandler = handler;
});
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
_ = factory.CreateClient("source.nkcki");
var monitor = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>();
var configuredOptions = monitor.Get("source.nkcki");
Assert.False(configuredOptions.AllowInvalidServerCertificates);
Assert.NotEmpty(configuredOptions.TrustedRootCertificates);
using (var manualChain = new X509Chain())
{
manualChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
manualChain.ChainPolicy.CustomTrustStore.AddRange(configuredOptions.TrustedRootCertificates.ToArray());
manualChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
manualChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
#pragma warning disable SYSLIB0057
using var manualServerCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
Assert.True(manualChain.Build(manualServerCertificate));
}
Assert.All(configuredOptions.TrustedRootCertificates, certificate => Assert.NotEqual(IntPtr.Zero, certificate.Handle));
Assert.NotNull(capturedHandler);
var callback = capturedHandler!.SslOptions.RemoteCertificateValidationCallback;
Assert.NotNull(callback);
#pragma warning disable SYSLIB0057
using var serverCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
using var chain = new X509Chain();
chain.ChainPolicy.CustomTrustStore.Add(serverCertificate);
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
_ = chain.Build(serverCertificate);
var validationResult = callback!(new object(), serverCertificate, chain, SslPolicyErrors.RemoteCertificateChainErrors);
Assert.True(validationResult);
Directory.Delete(offlineRoot.FullName, recursive: true);
}
[Fact]
public void AddSourceHttpClient_LoadsConfigurationFromSourceHttpSection()
{
var services = new ServiceCollection();
services.AddLogging();
using var trustedRoot = CreateSelfSignedCertificate();
var offlineRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"stellaops-offline-{Guid.NewGuid():N}"));
var relativePath = Path.Combine("certs", "root.pem");
var certificatePath = Path.Combine(offlineRoot.FullName, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(certificatePath)!);
WriteCertificatePem(trustedRoot, certificatePath);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"concelier:{OfflineRootKey}"] = offlineRoot.FullName,
[$"concelier:sources:nkcki:http:{TrustedRootPathsKey}:0"] = relativePath,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
SocketsHttpHandler? capturedHandler = null;
services.AddSourceHttpClient("source.nkcki", (_, options) =>
{
options.AllowedHosts.Add("example.test");
options.ConfigureHandler = handler => capturedHandler = handler;
});
using var provider = services.BuildServiceProvider();
_ = provider.GetRequiredService<IHttpClientFactory>().CreateClient("source.nkcki");
var configuredOptions = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>().Get("source.nkcki");
Assert.False(configuredOptions.AllowInvalidServerCertificates);
Assert.NotEmpty(configuredOptions.TrustedRootCertificates);
using (var manualChain = new X509Chain())
{
manualChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
manualChain.ChainPolicy.CustomTrustStore.AddRange(configuredOptions.TrustedRootCertificates.ToArray());
manualChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
manualChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
#pragma warning disable SYSLIB0057
using var manualServerCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
Assert.True(manualChain.Build(manualServerCertificate));
}
Assert.All(configuredOptions.TrustedRootCertificates, certificate => Assert.NotEqual(IntPtr.Zero, certificate.Handle));
Assert.NotNull(capturedHandler);
var callback = capturedHandler!.SslOptions.RemoteCertificateValidationCallback;
Assert.NotNull(callback);
#pragma warning disable SYSLIB0057
using var serverCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
using var chain = new X509Chain();
chain.ChainPolicy.CustomTrustStore.Add(serverCertificate);
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
_ = chain.Build(serverCertificate);
var validationResult = callback!(new object(), serverCertificate, chain, SslPolicyErrors.RemoteCertificateChainErrors);
Assert.True(validationResult);
Directory.Delete(offlineRoot.FullName, recursive: true);
}
private static X509Certificate2 CreateSelfSignedCertificate()
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest("CN=StellaOps Test Root", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5));
}
private static void WriteCertificatePem(X509Certificate2 certificate, string path)
{
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN CERTIFICATE-----");
builder.AppendLine(Convert.ToBase64String(certificate.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END CERTIFICATE-----");
File.WriteAllText(path, builder.ToString(), Encoding.ASCII);
}
private const string AllowInvalidKey = "allowInvalidCertificates";
private const string TrustedRootPathsKey = "trustedRootPaths";
private const string OfflineRootKey = "offlineRoot";
private const string ProxySection = "proxy";
private const string ProxyAddressKey = "address";
private const string ProxyBypassOnLocalKey = "bypassOnLocal";
private const string ProxyBypassListKey = "bypassList";
private const string ProxyUseDefaultCredentialsKey = "useDefaultCredentials";
private const string ProxyUsernameKey = "username";
private const string ProxyPasswordKey = "password";
}

View File

@@ -1,9 +1,9 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Mongo2Go;
using StellaOps.Concelier.Bson;
using MongoDB.Driver;
using StellaOps.Concelier.InMemoryRunner;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.InMemoryDriver;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Connector.Common;
@@ -17,23 +17,23 @@ namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class SourceStateSeedProcessorTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
private readonly MongoClient _client;
private readonly InMemoryDbRunner _runner;
private readonly InMemoryClient _client;
private readonly IMongoDatabase _database;
private readonly DocumentStore _documentStore;
private readonly RawDocumentStorage _rawStorage;
private readonly MongoSourceStateRepository _stateRepository;
private readonly InMemorySourceStateRepository _stateRepository;
private readonly FakeTimeProvider _timeProvider;
private readonly ICryptoHash _hash;
public SourceStateSeedProcessorTests()
{
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
_client = new MongoClient(_runner.ConnectionString);
_runner = InMemoryDbRunner.Start(singleNodeReplSet: true);
_client = new InMemoryClient(_runner.ConnectionString);
_database = _client.GetDatabase($"source-state-seed-{Guid.NewGuid():N}");
_documentStore = new DocumentStore(_database, NullLogger<DocumentStore>.Instance);
_rawStorage = new RawDocumentStorage();
_stateRepository = new MongoSourceStateRepository(_database, NullLogger<MongoSourceStateRepository>.Instance);
_stateRepository = new InMemorySourceStateRepository(_database, NullLogger<InMemorySourceStateRepository>.Instance);
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 28, 12, 0, 0, TimeSpan.Zero));
_hash = CryptoHashFactory.CreateDefault();
}
@@ -99,8 +99,8 @@ public sealed class SourceStateSeedProcessorTests : IAsyncLifetime
Assert.NotNull(storedDocument.Metadata);
Assert.Equal("value", storedDocument.Metadata!["test.meta"]);
var filesCollection = _database.GetCollection<BsonDocument>("documents.files");
var fileCount = await filesCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
var filesCollection = _database.GetCollection<DocumentObject>("documents.files");
var fileCount = await filesCollection.CountDocumentsAsync(FilterDefinition<DocumentObject>.Empty);
Assert.Equal(1, fileCount);
var state = await _stateRepository.TryGetAsync("vndr.test", CancellationToken.None);
@@ -108,13 +108,13 @@ public sealed class SourceStateSeedProcessorTests : IAsyncLifetime
Assert.Equal(_timeProvider.GetUtcNow().UtcDateTime, state!.LastSuccess);
var cursor = state.Cursor;
var pendingDocs = cursor["pendingDocuments"].AsBsonArray.Select(v => Guid.Parse(v.AsString)).ToList();
var pendingDocs = cursor["pendingDocuments"].AsDocumentArray.Select(v => Guid.Parse(v.AsString)).ToList();
Assert.Contains(documentId, pendingDocs);
var pendingMappings = cursor["pendingMappings"].AsBsonArray.Select(v => Guid.Parse(v.AsString)).ToList();
var pendingMappings = cursor["pendingMappings"].AsDocumentArray.Select(v => Guid.Parse(v.AsString)).ToList();
Assert.Contains(documentId, pendingMappings);
var knownAdvisories = cursor["knownAdvisories"].AsBsonArray.Select(v => v.AsString).ToList();
var knownAdvisories = cursor["knownAdvisories"].AsDocumentArray.Select(v => v.AsString).ToList();
Assert.Contains("ADV-0", knownAdvisories);
Assert.Contains("ADV-1", knownAdvisories);
@@ -156,8 +156,8 @@ public sealed class SourceStateSeedProcessorTests : IAsyncLifetime
var previousGridId = existingRecord!.PayloadId;
Assert.NotNull(previousGridId);
var filesCollection = _database.GetCollection<BsonDocument>("documents.files");
var initialFiles = await filesCollection.Find(FilterDefinition<BsonDocument>.Empty).ToListAsync();
var filesCollection = _database.GetCollection<DocumentObject>("documents.files");
var initialFiles = await filesCollection.Find(FilterDefinition<DocumentObject>.Empty).ToListAsync();
Assert.Single(initialFiles);
var updatedSpecification = new SourceStateSeedSpecification
@@ -192,7 +192,7 @@ public sealed class SourceStateSeedProcessorTests : IAsyncLifetime
Assert.NotNull(refreshedRecord.PayloadId);
Assert.NotEqual(previousGridId?.ToString(), refreshedRecord.PayloadId?.ToString());
var files = await filesCollection.Find(FilterDefinition<BsonDocument>.Empty).ToListAsync();
var files = await filesCollection.Find(FilterDefinition<DocumentObject>.Empty).ToListAsync();
Assert.Single(files);
Assert.NotEqual(previousGridId?.ToString(), files[0]["_id"].AsObjectId.ToString());
}

View File

@@ -1,87 +1,87 @@
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Connector.Common.Cursors;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class TimeWindowCursorPlannerTests
{
[Fact]
public void GetNextWindow_UsesInitialBackfillWhenStateEmpty()
{
var now = new DateTimeOffset(2024, 10, 1, 12, 0, 0, TimeSpan.Zero);
var options = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromHours(4),
Overlap = TimeSpan.FromMinutes(15),
InitialBackfill = TimeSpan.FromDays(2),
MinimumWindowSize = TimeSpan.FromMinutes(1),
};
var window = TimeWindowCursorPlanner.GetNextWindow(now, null, options);
Assert.Equal(now - options.InitialBackfill, window.Start);
Assert.Equal(window.Start + options.WindowSize, window.End);
}
[Fact]
public void GetNextWindow_ClampsEndToNowWhenWindowExtendPastPresent()
{
var now = new DateTimeOffset(2024, 10, 10, 0, 0, 0, TimeSpan.Zero);
var options = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromHours(6),
Overlap = TimeSpan.FromMinutes(30),
InitialBackfill = TimeSpan.FromDays(3),
MinimumWindowSize = TimeSpan.FromMinutes(1),
};
var previousEnd = now - TimeSpan.FromMinutes(10);
var state = new TimeWindowCursorState(previousEnd - options.WindowSize, previousEnd);
var window = TimeWindowCursorPlanner.GetNextWindow(now, state, options);
var expectedStart = previousEnd - options.Overlap;
var earliest = now - options.InitialBackfill;
if (expectedStart < earliest)
{
expectedStart = earliest;
}
Assert.Equal(expectedStart, window.Start);
Assert.Equal(now, window.End);
}
[Fact]
public void TimeWindowCursorState_RoundTripThroughBson()
{
var state = new TimeWindowCursorState(
new DateTimeOffset(2024, 9, 1, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2024, 9, 1, 6, 0, 0, TimeSpan.Zero));
var document = new BsonDocument
{
["preserve"] = "value",
};
state.WriteTo(document);
var roundTripped = TimeWindowCursorState.FromBsonDocument(document);
Assert.Equal(state.LastWindowStart, roundTripped.LastWindowStart);
Assert.Equal(state.LastWindowEnd, roundTripped.LastWindowEnd);
Assert.Equal("value", document["preserve"].AsString);
}
[Fact]
public void PaginationPlanner_EnumeratesAdditionalPages()
{
var indices = PaginationPlanner.EnumerateAdditionalPages(4500, 2000).ToArray();
Assert.Equal(new[] { 2000, 4000 }, indices);
}
[Fact]
public void PaginationPlanner_ReturnsEmptyWhenSinglePage()
{
var indices = PaginationPlanner.EnumerateAdditionalPages(1000, 2000).ToArray();
Assert.Empty(indices);
}
}
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Common.Cursors;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class TimeWindowCursorPlannerTests
{
[Fact]
public void GetNextWindow_UsesInitialBackfillWhenStateEmpty()
{
var now = new DateTimeOffset(2024, 10, 1, 12, 0, 0, TimeSpan.Zero);
var options = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromHours(4),
Overlap = TimeSpan.FromMinutes(15),
InitialBackfill = TimeSpan.FromDays(2),
MinimumWindowSize = TimeSpan.FromMinutes(1),
};
var window = TimeWindowCursorPlanner.GetNextWindow(now, null, options);
Assert.Equal(now - options.InitialBackfill, window.Start);
Assert.Equal(window.Start + options.WindowSize, window.End);
}
[Fact]
public void GetNextWindow_ClampsEndToNowWhenWindowExtendPastPresent()
{
var now = new DateTimeOffset(2024, 10, 10, 0, 0, 0, TimeSpan.Zero);
var options = new TimeWindowCursorOptions
{
WindowSize = TimeSpan.FromHours(6),
Overlap = TimeSpan.FromMinutes(30),
InitialBackfill = TimeSpan.FromDays(3),
MinimumWindowSize = TimeSpan.FromMinutes(1),
};
var previousEnd = now - TimeSpan.FromMinutes(10);
var state = new TimeWindowCursorState(previousEnd - options.WindowSize, previousEnd);
var window = TimeWindowCursorPlanner.GetNextWindow(now, state, options);
var expectedStart = previousEnd - options.Overlap;
var earliest = now - options.InitialBackfill;
if (expectedStart < earliest)
{
expectedStart = earliest;
}
Assert.Equal(expectedStart, window.Start);
Assert.Equal(now, window.End);
}
[Fact]
public void TimeWindowCursorState_RoundTripThroughBson()
{
var state = new TimeWindowCursorState(
new DateTimeOffset(2024, 9, 1, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2024, 9, 1, 6, 0, 0, TimeSpan.Zero));
var document = new DocumentObject
{
["preserve"] = "value",
};
state.WriteTo(document);
var roundTripped = TimeWindowCursorState.FromDocumentObject(document);
Assert.Equal(state.LastWindowStart, roundTripped.LastWindowStart);
Assert.Equal(state.LastWindowEnd, roundTripped.LastWindowEnd);
Assert.Equal("value", document["preserve"].AsString);
}
[Fact]
public void PaginationPlanner_EnumeratesAdditionalPages()
{
var indices = PaginationPlanner.EnumerateAdditionalPages(4500, 2000).ToArray();
Assert.Equal(new[] { 2000, 4000 }, indices);
}
[Fact]
public void PaginationPlanner_ReturnsEmptyWhenSinglePage()
{
var indices = PaginationPlanner.EnumerateAdditionalPages(1000, 2000).ToArray();
Assert.Empty(indices);
}
}

View File

@@ -1,24 +1,24 @@
using StellaOps.Concelier.Connector.Common.Url;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class UrlNormalizerTests
{
[Fact]
public void TryNormalize_ResolvesRelative()
{
var success = UrlNormalizer.TryNormalize("/foo/bar", new Uri("https://example.test/base/"), out var normalized);
Assert.True(success);
Assert.Equal("https://example.test/foo/bar", normalized!.ToString());
}
[Fact]
public void TryNormalize_StripsFragment()
{
var success = UrlNormalizer.TryNormalize("https://example.test/path#section", null, out var normalized);
Assert.True(success);
Assert.Equal("https://example.test/path", normalized!.ToString());
}
}
using StellaOps.Concelier.Connector.Common.Url;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class UrlNormalizerTests
{
[Fact]
public void TryNormalize_ResolvesRelative()
{
var success = UrlNormalizer.TryNormalize("/foo/bar", new Uri("https://example.test/base/"), out var normalized);
Assert.True(success);
Assert.Equal("https://example.test/foo/bar", normalized!.ToString());
}
[Fact]
public void TryNormalize_StripsFragment()
{
var success = UrlNormalizer.TryNormalize("https://example.test/path#section", null, out var normalized);
Assert.True(success);
Assert.Equal("https://example.test/path", normalized!.ToString());
}
}

View File

@@ -1,51 +1,51 @@
using System;
using System.Text.Json;
using Json.Schema;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Concelier.Connector.Common.Json;
namespace StellaOps.Concelier.Connector.Common.Tests.Json;
public sealed class JsonSchemaValidatorTests
{
private static JsonSchema CreateSchema()
=> JsonSchema.FromText("""
{
"type": "object",
"properties": {
"id": { "type": "string" },
"count": { "type": "integer", "minimum": 1 }
},
"required": ["id", "count"],
"additionalProperties": false
}
""");
[Fact]
public void Validate_AllowsDocumentsMatchingSchema()
{
var schema = CreateSchema();
using var document = JsonDocument.Parse("""{"id":"abc","count":2}""");
var validator = new JsonSchemaValidator(NullLogger<JsonSchemaValidator>.Instance);
var exception = Record.Exception(() => validator.Validate(document, schema, "valid-doc"));
Assert.Null(exception);
}
[Fact]
public void Validate_ThrowsWithDetailedViolations()
{
var schema = CreateSchema();
using var document = JsonDocument.Parse("""{"count":0,"extra":"nope"}""");
var validator = new JsonSchemaValidator(NullLogger<JsonSchemaValidator>.Instance);
var ex = Assert.Throws<JsonSchemaValidationException>(() => validator.Validate(document, schema, "invalid-doc"));
Assert.Equal("invalid-doc", ex.DocumentName);
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors, error => error.Keyword == "required");
Assert.Contains(ex.Errors, error => error.SchemaLocation.Contains("#/additionalProperties", StringComparison.Ordinal));
Assert.Contains(ex.Errors, error => error.Keyword == "minimum");
}
}
using System;
using System.Text.Json;
using Json.Schema;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Concelier.Connector.Common.Json;
namespace StellaOps.Concelier.Connector.Common.Tests.Json;
public sealed class JsonSchemaValidatorTests
{
private static JsonSchema CreateSchema()
=> JsonSchema.FromText("""
{
"type": "object",
"properties": {
"id": { "type": "string" },
"count": { "type": "integer", "minimum": 1 }
},
"required": ["id", "count"],
"additionalProperties": false
}
""");
[Fact]
public void Validate_AllowsDocumentsMatchingSchema()
{
var schema = CreateSchema();
using var document = JsonDocument.Parse("""{"id":"abc","count":2}""");
var validator = new JsonSchemaValidator(NullLogger<JsonSchemaValidator>.Instance);
var exception = Record.Exception(() => validator.Validate(document, schema, "valid-doc"));
Assert.Null(exception);
}
[Fact]
public void Validate_ThrowsWithDetailedViolations()
{
var schema = CreateSchema();
using var document = JsonDocument.Parse("""{"count":0,"extra":"nope"}""");
var validator = new JsonSchemaValidator(NullLogger<JsonSchemaValidator>.Instance);
var ex = Assert.Throws<JsonSchemaValidationException>(() => validator.Validate(document, schema, "invalid-doc"));
Assert.Equal("invalid-doc", ex.DocumentName);
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors, error => error.Keyword == "required");
Assert.Contains(ex.Errors, error => error.SchemaLocation.Contains("#/additionalProperties", StringComparison.Ordinal));
Assert.Contains(ex.Errors, error => error.Keyword == "minimum");
}
}

View File

@@ -1,58 +1,58 @@
using System.IO;
using System.Xml;
using System.Xml.Linq;
using System.Xml.Schema;
using Microsoft.Extensions.Logging.Abstractions;
using ConcelierXmlSchemaValidator = StellaOps.Concelier.Connector.Common.Xml.XmlSchemaValidator;
using ConcelierXmlSchemaValidationException = StellaOps.Concelier.Connector.Common.Xml.XmlSchemaValidationException;
namespace StellaOps.Concelier.Connector.Common.Tests.Xml;
public sealed class XmlSchemaValidatorTests
{
private static XmlSchemaSet CreateSchema()
{
var set = new XmlSchemaSet();
set.Add(string.Empty, XmlReader.Create(new StringReader("""
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="root">
<xs:complexType>
<xs:sequence>
<xs:element name="id" type="xs:string" />
<xs:element name="count" type="xs:int" />
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
""")));
set.CompilationSettings = new XmlSchemaCompilationSettings { EnableUpaCheck = true };
set.Compile();
return set;
}
[Fact]
public void Validate_AllowsCompliantDocument()
{
var schemaSet = CreateSchema();
var document = XDocument.Parse("<root><id>abc</id><count>3</count></root>");
var validator = new ConcelierXmlSchemaValidator(NullLogger<ConcelierXmlSchemaValidator>.Instance);
var exception = Record.Exception(() => validator.Validate(document, schemaSet, "valid.xml"));
Assert.Null(exception);
}
[Fact]
public void Validate_ThrowsWithDetailedErrors()
{
var schemaSet = CreateSchema();
var document = XDocument.Parse("<root><id>missing-count</id></root>");
var validator = new ConcelierXmlSchemaValidator(NullLogger<ConcelierXmlSchemaValidator>.Instance);
var ex = Assert.Throws<ConcelierXmlSchemaValidationException>(() => validator.Validate(document, schemaSet, "invalid.xml"));
Assert.Equal("invalid.xml", ex.DocumentName);
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors, error => error.Message.Contains("count", StringComparison.OrdinalIgnoreCase));
}
}
using System.IO;
using System.Xml;
using System.Xml.Linq;
using System.Xml.Schema;
using Microsoft.Extensions.Logging.Abstractions;
using ConcelierXmlSchemaValidator = StellaOps.Concelier.Connector.Common.Xml.XmlSchemaValidator;
using ConcelierXmlSchemaValidationException = StellaOps.Concelier.Connector.Common.Xml.XmlSchemaValidationException;
namespace StellaOps.Concelier.Connector.Common.Tests.Xml;
public sealed class XmlSchemaValidatorTests
{
private static XmlSchemaSet CreateSchema()
{
var set = new XmlSchemaSet();
set.Add(string.Empty, XmlReader.Create(new StringReader("""
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="root">
<xs:complexType>
<xs:sequence>
<xs:element name="id" type="xs:string" />
<xs:element name="count" type="xs:int" />
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
""")));
set.CompilationSettings = new XmlSchemaCompilationSettings { EnableUpaCheck = true };
set.Compile();
return set;
}
[Fact]
public void Validate_AllowsCompliantDocument()
{
var schemaSet = CreateSchema();
var document = XDocument.Parse("<root><id>abc</id><count>3</count></root>");
var validator = new ConcelierXmlSchemaValidator(NullLogger<ConcelierXmlSchemaValidator>.Instance);
var exception = Record.Exception(() => validator.Validate(document, schemaSet, "valid.xml"));
Assert.Null(exception);
}
[Fact]
public void Validate_ThrowsWithDetailedErrors()
{
var schemaSet = CreateSchema();
var document = XDocument.Parse("<root><id>missing-count</id></root>");
var validator = new ConcelierXmlSchemaValidator(NullLogger<ConcelierXmlSchemaValidator>.Instance);
var ex = Assert.Throws<ConcelierXmlSchemaValidationException>(() => validator.Validate(document, schemaSet, "invalid.xml"));
Assert.Equal("invalid.xml", ex.DocumentName);
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors, error => error.Message.Contains("count", StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -1,257 +1,257 @@
using System.Diagnostics.Metrics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Bson;
using MongoDB.Driver;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Cve.Configuration;
using StellaOps.Concelier.Connector.Cve.Internal;
using StellaOps.Concelier.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Connector.Cve.Tests;
using System.Diagnostics.Metrics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.InMemoryDriver;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Cve.Configuration;
using StellaOps.Concelier.Connector.Cve.Internal;
using StellaOps.Concelier.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Connector.Cve.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class CveConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ITestOutputHelper _output;
private ConnectorTestHarness? _harness;
public CveConnectorTests(ConcelierPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
}
[Fact]
public async Task FetchParseMap_EmitsCanonicalAdvisory()
{
var initialTime = new DateTimeOffset(2024, 10, 1, 0, 0, 0, TimeSpan.Zero);
await EnsureHarnessAsync(initialTime);
var harness = _harness!;
var since = initialTime - TimeSpan.FromDays(30);
var listUri = new Uri($"https://cve.test/api/cve?time_modified.gte={Uri.EscapeDataString(since.ToString("O"))}&time_modified.lte={Uri.EscapeDataString(initialTime.ToString("O"))}&page=1&size=5");
harness.Handler.AddJsonResponse(listUri, ReadFixture("Fixtures/cve-list.json"));
harness.Handler.SetFallback(request =>
{
if (request.RequestUri is null)
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
if (request.RequestUri.AbsoluteUri.Equals("https://cve.test/api/cve/CVE-2024-0001", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture("Fixtures/cve-CVE-2024-0001.json"), Encoding.UTF8, "application/json")
};
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var metrics = new Dictionary<string, long>(StringComparer.Ordinal);
using var listener = new MeterListener
{
InstrumentPublished = (instrument, meterListener) =>
{
if (instrument.Meter.Name == CveDiagnostics.MeterName)
{
meterListener.EnableMeasurementEvents(instrument);
}
}
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
{
if (metrics.TryGetValue(instrument.Name, out var existing))
{
metrics[instrument.Name] = existing + value;
}
else
{
metrics[instrument.Name] = value;
}
});
listener.Start();
var connector = new CveConnectorPlugin().Create(harness.ServiceProvider);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
listener.Dispose();
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisory = await advisoryStore.FindAsync("CVE-2024-0001", CancellationToken.None);
Assert.NotNull(advisory);
var snapshot = SnapshotSerializer.ToSnapshot(advisory!).Replace("\r\n", "\n").TrimEnd();
var expected = ReadFixture("Fixtures/expected-CVE-2024-0001.json").Replace("\r\n", "\n").TrimEnd();
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "expected-CVE-2024-0001.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(expected, snapshot);
harness.Handler.AssertNoPendingResponses();
_output.WriteLine("CVE connector smoke metrics:");
foreach (var entry in metrics.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
_output.WriteLine($" {entry.Key} = {entry.Value}");
}
}
[Fact]
public async Task FetchWithoutCredentials_SeedsFromDirectory()
{
var initialTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var projectRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
var repositoryRoot = Path.GetFullPath(Path.Combine(projectRoot, "..", ".."));
var seedDirectory = Path.Combine(repositoryRoot, "seed-data", "cve", "2025-10-15");
Assert.True(Directory.Exists(seedDirectory), $"Seed directory '{seedDirectory}' was not found.");
await using var harness = new ConnectorTestHarness(_fixture, initialTime, CveOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddProvider(new TestOutputLoggerProvider(_output, LogLevel.Information));
builder.SetMinimumLevel(LogLevel.Information);
});
services.AddCveConnector(options =>
{
options.BaseEndpoint = new Uri("https://cve.test/api/", UriKind.Absolute);
options.SeedDirectory = seedDirectory;
options.PageSize = 5;
options.MaxPagesPerFetch = 1;
options.InitialBackfill = TimeSpan.FromDays(30);
options.RequestDelay = TimeSpan.Zero;
});
});
var connector = new CveConnectorPlugin().Create(harness.ServiceProvider);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
Assert.Empty(harness.Handler.Requests);
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
var keys = advisories.Select(advisory => advisory.AdvisoryKey).ToArray();
Assert.Contains("CVE-2024-0001", keys);
Assert.Contains("CVE-2024-4567", keys);
}
private async Task EnsureHarnessAsync(DateTimeOffset initialTime)
{
if (_harness is not null)
{
return;
}
var harness = new ConnectorTestHarness(_fixture, initialTime, CveOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddProvider(new TestOutputLoggerProvider(_output, LogLevel.Information));
builder.SetMinimumLevel(LogLevel.Information);
});
services.AddCveConnector(options =>
{
options.BaseEndpoint = new Uri("https://cve.test/api/", UriKind.Absolute);
options.ApiOrg = "test-org";
options.ApiUser = "test-user";
options.ApiKey = "test-key";
options.InitialBackfill = TimeSpan.FromDays(30);
options.PageSize = 5;
options.MaxPagesPerFetch = 2;
options.RequestDelay = TimeSpan.Zero;
});
});
_harness = harness;
}
private static string ReadFixture(string relativePath)
{
var path = Path.Combine(AppContext.BaseDirectory, relativePath);
return File.ReadAllText(path);
}
public async Task InitializeAsync()
{
await Task.CompletedTask;
}
public async Task DisposeAsync()
{
if (_harness is not null)
{
await _harness.DisposeAsync();
}
}
private sealed class TestOutputLoggerProvider : ILoggerProvider
{
private readonly ITestOutputHelper _output;
private readonly LogLevel _minLevel;
public TestOutputLoggerProvider(ITestOutputHelper output, LogLevel minLevel)
{
_output = output;
_minLevel = minLevel;
}
public ILogger CreateLogger(string categoryName) => new TestOutputLogger(_output, _minLevel);
public void Dispose()
{
}
private sealed class TestOutputLogger : ILogger
{
private readonly ITestOutputHelper _output;
private readonly LogLevel _minLevel;
public TestOutputLogger(ITestOutputHelper output, LogLevel minLevel)
{
_output = output;
_minLevel = minLevel;
}
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullLogger.Instance.BeginScope(state);
public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLevel;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (IsEnabled(logLevel))
{
_output.WriteLine(formatter(state, exception));
}
}
}
}
}
public sealed class CveConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ITestOutputHelper _output;
private ConnectorTestHarness? _harness;
public CveConnectorTests(ConcelierPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
}
[Fact]
public async Task FetchParseMap_EmitsCanonicalAdvisory()
{
var initialTime = new DateTimeOffset(2024, 10, 1, 0, 0, 0, TimeSpan.Zero);
await EnsureHarnessAsync(initialTime);
var harness = _harness!;
var since = initialTime - TimeSpan.FromDays(30);
var listUri = new Uri($"https://cve.test/api/cve?time_modified.gte={Uri.EscapeDataString(since.ToString("O"))}&time_modified.lte={Uri.EscapeDataString(initialTime.ToString("O"))}&page=1&size=5");
harness.Handler.AddJsonResponse(listUri, ReadFixture("Fixtures/cve-list.json"));
harness.Handler.SetFallback(request =>
{
if (request.RequestUri is null)
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
if (request.RequestUri.AbsoluteUri.Equals("https://cve.test/api/cve/CVE-2024-0001", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture("Fixtures/cve-CVE-2024-0001.json"), Encoding.UTF8, "application/json")
};
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var metrics = new Dictionary<string, long>(StringComparer.Ordinal);
using var listener = new MeterListener
{
InstrumentPublished = (instrument, meterListener) =>
{
if (instrument.Meter.Name == CveDiagnostics.MeterName)
{
meterListener.EnableMeasurementEvents(instrument);
}
}
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
{
if (metrics.TryGetValue(instrument.Name, out var existing))
{
metrics[instrument.Name] = existing + value;
}
else
{
metrics[instrument.Name] = value;
}
});
listener.Start();
var connector = new CveConnectorPlugin().Create(harness.ServiceProvider);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
listener.Dispose();
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisory = await advisoryStore.FindAsync("CVE-2024-0001", CancellationToken.None);
Assert.NotNull(advisory);
var snapshot = SnapshotSerializer.ToSnapshot(advisory!).Replace("\r\n", "\n").TrimEnd();
var expected = ReadFixture("Fixtures/expected-CVE-2024-0001.json").Replace("\r\n", "\n").TrimEnd();
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "expected-CVE-2024-0001.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(expected, snapshot);
harness.Handler.AssertNoPendingResponses();
_output.WriteLine("CVE connector smoke metrics:");
foreach (var entry in metrics.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
_output.WriteLine($" {entry.Key} = {entry.Value}");
}
}
[Fact]
public async Task FetchWithoutCredentials_SeedsFromDirectory()
{
var initialTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var projectRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
var repositoryRoot = Path.GetFullPath(Path.Combine(projectRoot, "..", ".."));
var seedDirectory = Path.Combine(repositoryRoot, "seed-data", "cve", "2025-10-15");
Assert.True(Directory.Exists(seedDirectory), $"Seed directory '{seedDirectory}' was not found.");
await using var harness = new ConnectorTestHarness(_fixture, initialTime, CveOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddProvider(new TestOutputLoggerProvider(_output, LogLevel.Information));
builder.SetMinimumLevel(LogLevel.Information);
});
services.AddCveConnector(options =>
{
options.BaseEndpoint = new Uri("https://cve.test/api/", UriKind.Absolute);
options.SeedDirectory = seedDirectory;
options.PageSize = 5;
options.MaxPagesPerFetch = 1;
options.InitialBackfill = TimeSpan.FromDays(30);
options.RequestDelay = TimeSpan.Zero;
});
});
var connector = new CveConnectorPlugin().Create(harness.ServiceProvider);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
Assert.Empty(harness.Handler.Requests);
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
var keys = advisories.Select(advisory => advisory.AdvisoryKey).ToArray();
Assert.Contains("CVE-2024-0001", keys);
Assert.Contains("CVE-2024-4567", keys);
}
private async Task EnsureHarnessAsync(DateTimeOffset initialTime)
{
if (_harness is not null)
{
return;
}
var harness = new ConnectorTestHarness(_fixture, initialTime, CveOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddProvider(new TestOutputLoggerProvider(_output, LogLevel.Information));
builder.SetMinimumLevel(LogLevel.Information);
});
services.AddCveConnector(options =>
{
options.BaseEndpoint = new Uri("https://cve.test/api/", UriKind.Absolute);
options.ApiOrg = "test-org";
options.ApiUser = "test-user";
options.ApiKey = "test-key";
options.InitialBackfill = TimeSpan.FromDays(30);
options.PageSize = 5;
options.MaxPagesPerFetch = 2;
options.RequestDelay = TimeSpan.Zero;
});
});
_harness = harness;
}
private static string ReadFixture(string relativePath)
{
var path = Path.Combine(AppContext.BaseDirectory, relativePath);
return File.ReadAllText(path);
}
public async Task InitializeAsync()
{
await Task.CompletedTask;
}
public async Task DisposeAsync()
{
if (_harness is not null)
{
await _harness.DisposeAsync();
}
}
private sealed class TestOutputLoggerProvider : ILoggerProvider
{
private readonly ITestOutputHelper _output;
private readonly LogLevel _minLevel;
public TestOutputLoggerProvider(ITestOutputHelper output, LogLevel minLevel)
{
_output = output;
_minLevel = minLevel;
}
public ILogger CreateLogger(string categoryName) => new TestOutputLogger(_output, _minLevel);
public void Dispose()
{
}
private sealed class TestOutputLogger : ILogger
{
private readonly ITestOutputHelper _output;
private readonly LogLevel _minLevel;
public TestOutputLogger(ITestOutputHelper output, LogLevel minLevel)
{
_output = output;
_minLevel = minLevel;
}
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullLogger.Instance.BeginScope(state);
public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLevel;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (IsEnabled(logLevel))
{
_output.WriteLine(formatter(state, exception));
}
}
}
}
}

View File

@@ -1,15 +1,15 @@
using System.Collections.Generic;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using System.Collections.Generic;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -19,260 +19,260 @@ using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Distro.Debian.Configuration;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Testing;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Connector.Distro.Debian.Tests;
using StellaOps.Concelier.Connector.Distro.Debian.Configuration;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Testing;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Connector.Distro.Debian.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class DebianConnectorTests : IAsyncLifetime
{
private static readonly Uri ListUri = new("https://salsa.debian.org/security-tracker-team/security-tracker/-/raw/master/data/DSA/list");
private static readonly Uri DetailResolved = new("https://security-tracker.debian.org/tracker/DSA-2024-123");
private static readonly Uri DetailOpen = new("https://security-tracker.debian.org/tracker/DSA-2024-124");
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private readonly Dictionary<Uri, Func<HttpRequestMessage, HttpResponseMessage>> _fallbackFactories = new();
private readonly ITestOutputHelper _output;
public DebianConnectorTests(ConcelierPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_handler = new CannedHttpMessageHandler();
_handler.SetFallback(request =>
{
if (request.RequestUri is null)
{
throw new InvalidOperationException("Request URI required for fallback response.");
}
if (_fallbackFactories.TryGetValue(request.RequestUri, out var factory))
{
return factory(request);
}
throw new InvalidOperationException($"No canned or fallback response registered for {request.Method} {request.RequestUri}.");
});
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 9, 12, 0, 0, 0, TimeSpan.Zero));
_output = output;
}
[Fact]
public async Task FetchParseMap_PopulatesRangePrimitivesAndResumesWithNotModified()
{
await using var provider = await BuildServiceProviderAsync();
SeedInitialResponses();
var connector = provider.GetRequiredService<DebianConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var resolved = advisories.Single(a => a.AdvisoryKey == "DSA-2024-123");
_output.WriteLine("Resolved aliases: " + string.Join(",", resolved.Aliases));
var resolvedBookworm = Assert.Single(resolved.AffectedPackages, p => p.Platform == "bookworm");
var resolvedRange = Assert.Single(resolvedBookworm.VersionRanges);
Assert.Equal("evr", resolvedRange.RangeKind);
Assert.Equal("1:1.1.1n-0+deb11u2", resolvedRange.IntroducedVersion);
Assert.Equal("1:1.1.1n-0+deb11u5", resolvedRange.FixedVersion);
Assert.NotNull(resolvedRange.Primitives);
Assert.NotNull(resolvedRange.Primitives!.Evr);
Assert.Equal(1, resolvedRange.Primitives.Evr!.Introduced!.Epoch);
Assert.Equal("1.1.1n", resolvedRange.Primitives.Evr.Introduced.UpstreamVersion);
var open = advisories.Single(a => a.AdvisoryKey == "DSA-2024-124");
var openBookworm = Assert.Single(open.AffectedPackages, p => p.Platform == "bookworm");
var openRange = Assert.Single(openBookworm.VersionRanges);
Assert.Equal("evr", openRange.RangeKind);
Assert.Equal("1:1.3.1-1", openRange.IntroducedVersion);
Assert.Null(openRange.FixedVersion);
Assert.NotNull(openRange.Primitives);
Assert.NotNull(openRange.Primitives!.Evr);
public sealed class DebianConnectorTests : IAsyncLifetime
{
private static readonly Uri ListUri = new("https://salsa.debian.org/security-tracker-team/security-tracker/-/raw/master/data/DSA/list");
private static readonly Uri DetailResolved = new("https://security-tracker.debian.org/tracker/DSA-2024-123");
private static readonly Uri DetailOpen = new("https://security-tracker.debian.org/tracker/DSA-2024-124");
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private readonly Dictionary<Uri, Func<HttpRequestMessage, HttpResponseMessage>> _fallbackFactories = new();
private readonly ITestOutputHelper _output;
public DebianConnectorTests(ConcelierPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_handler = new CannedHttpMessageHandler();
_handler.SetFallback(request =>
{
if (request.RequestUri is null)
{
throw new InvalidOperationException("Request URI required for fallback response.");
}
if (_fallbackFactories.TryGetValue(request.RequestUri, out var factory))
{
return factory(request);
}
throw new InvalidOperationException($"No canned or fallback response registered for {request.Method} {request.RequestUri}.");
});
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 9, 12, 0, 0, 0, TimeSpan.Zero));
_output = output;
}
[Fact]
public async Task FetchParseMap_PopulatesRangePrimitivesAndResumesWithNotModified()
{
await using var provider = await BuildServiceProviderAsync();
SeedInitialResponses();
var connector = provider.GetRequiredService<DebianConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var resolved = advisories.Single(a => a.AdvisoryKey == "DSA-2024-123");
_output.WriteLine("Resolved aliases: " + string.Join(",", resolved.Aliases));
var resolvedBookworm = Assert.Single(resolved.AffectedPackages, p => p.Platform == "bookworm");
var resolvedRange = Assert.Single(resolvedBookworm.VersionRanges);
Assert.Equal("evr", resolvedRange.RangeKind);
Assert.Equal("1:1.1.1n-0+deb11u2", resolvedRange.IntroducedVersion);
Assert.Equal("1:1.1.1n-0+deb11u5", resolvedRange.FixedVersion);
Assert.NotNull(resolvedRange.Primitives);
Assert.NotNull(resolvedRange.Primitives!.Evr);
Assert.Equal(1, resolvedRange.Primitives.Evr!.Introduced!.Epoch);
Assert.Equal("1.1.1n", resolvedRange.Primitives.Evr.Introduced.UpstreamVersion);
var open = advisories.Single(a => a.AdvisoryKey == "DSA-2024-124");
var openBookworm = Assert.Single(open.AffectedPackages, p => p.Platform == "bookworm");
var openRange = Assert.Single(openBookworm.VersionRanges);
Assert.Equal("evr", openRange.RangeKind);
Assert.Equal("1:1.3.1-1", openRange.IntroducedVersion);
Assert.Null(openRange.FixedVersion);
Assert.NotNull(openRange.Primitives);
Assert.NotNull(openRange.Primitives!.Evr);
// Ensure data persisted through storage round-trip.
var found = await advisoryStore.FindAsync("DSA-2024-123", CancellationToken.None);
Assert.NotNull(found);
var persistedRange = Assert.Single(found!.AffectedPackages, pkg => pkg.Platform == "bookworm").VersionRanges.Single();
Assert.NotNull(persistedRange.Primitives);
Assert.NotNull(persistedRange.Primitives!.Evr);
// Second run should issue conditional requests and no additional parsing/mapping.
SeedNotModifiedResponses();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documents = provider.GetRequiredService<IDocumentStore>();
var listDoc = await documents.FindBySourceAndUriAsync(DebianConnectorPlugin.SourceName, DetailResolved.ToString(), CancellationToken.None);
Assert.NotNull(listDoc);
var refreshed = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, refreshed.Count);
}
var found = await advisoryStore.FindAsync("DSA-2024-123", CancellationToken.None);
Assert.NotNull(found);
var persistedRange = Assert.Single(found!.AffectedPackages, pkg => pkg.Platform == "bookworm").VersionRanges.Single();
Assert.NotNull(persistedRange.Primitives);
Assert.NotNull(persistedRange.Primitives!.Evr);
// Second run should issue conditional requests and no additional parsing/mapping.
SeedNotModifiedResponses();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documents = provider.GetRequiredService<IDocumentStore>();
var listDoc = await documents.FindBySourceAndUriAsync(DebianConnectorPlugin.SourceName, DetailResolved.ToString(), CancellationToken.None);
Assert.NotNull(listDoc);
var refreshed = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, refreshed.Count);
}
private async Task<ServiceProvider> BuildServiceProviderAsync()
{
await _fixture.TruncateAllTablesAsync(CancellationToken.None);
_handler.Clear();
_fallbackFactories.Clear();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(new TestOutputLoggerProvider(_output)));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(new TestOutputLoggerProvider(_output)));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddDebianConnector(options =>
{
options.ListEndpoint = ListUri;
options.DetailBaseUri = new Uri("https://security-tracker.debian.org/tracker/");
options.MaxAdvisoriesPerFetch = 10;
options.RequestDelay = TimeSpan.Zero;
});
services.Configure<HttpClientFactoryOptions>(DebianOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
services.AddSourceCommon();
services.AddDebianConnector(options =>
{
options.ListEndpoint = ListUri;
options.DetailBaseUri = new Uri("https://security-tracker.debian.org/tracker/");
options.MaxAdvisoriesPerFetch = 10;
options.RequestDelay = TimeSpan.Zero;
});
services.Configure<HttpClientFactoryOptions>(DebianOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
return services.BuildServiceProvider();
}
private void SeedInitialResponses()
{
AddListResponse("debian-list.txt", "\"list-v1\"");
AddDetailResponse(DetailResolved, "debian-detail-dsa-2024-123.html", "\"detail-123\"");
AddDetailResponse(DetailOpen, "debian-detail-dsa-2024-124.html", "\"detail-124\"");
}
private void SeedNotModifiedResponses()
{
AddNotModifiedResponse(ListUri, "\"list-v1\"");
AddNotModifiedResponse(DetailResolved, "\"detail-123\"");
AddNotModifiedResponse(DetailOpen, "\"detail-124\"");
}
private void AddListResponse(string fixture, string etag)
{
RegisterResponseFactory(ListUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private void AddDetailResponse(Uri uri, string fixture, string etag)
{
RegisterResponseFactory(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/html"),
};
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private void AddNotModifiedResponse(Uri uri, string etag)
{
RegisterResponseFactory(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private void RegisterResponseFactory(Uri uri, Func<HttpResponseMessage> factory)
{
_handler.AddResponse(uri, () => factory());
_fallbackFactories[uri] = _ => factory();
}
private static string ReadFixture(string filename)
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "Debian", "Fixtures", filename),
Path.Combine(AppContext.BaseDirectory, "Distro", "Debian", "Fixtures", filename),
Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Source", "Distro", "Debian", "Fixtures", filename),
};
foreach (var candidate in candidates)
{
var fullPath = Path.GetFullPath(candidate);
if (File.Exists(fullPath))
{
return File.ReadAllText(fullPath);
}
}
throw new FileNotFoundException($"Fixture '{filename}' not found", filename);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
private sealed class TestOutputLoggerProvider : ILoggerProvider
{
private readonly ITestOutputHelper _output;
public TestOutputLoggerProvider(ITestOutputHelper output) => _output = output;
public ILogger CreateLogger(string categoryName) => new TestOutputLogger(_output);
public void Dispose()
{
}
private sealed class TestOutputLogger : ILogger
{
private readonly ITestOutputHelper _output;
public TestOutputLogger(ITestOutputHelper output) => _output = output;
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullLogger.Instance.BeginScope(state);
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (IsEnabled(logLevel))
{
_output.WriteLine(formatter(state, exception));
}
}
}
}
}
private void SeedInitialResponses()
{
AddListResponse("debian-list.txt", "\"list-v1\"");
AddDetailResponse(DetailResolved, "debian-detail-dsa-2024-123.html", "\"detail-123\"");
AddDetailResponse(DetailOpen, "debian-detail-dsa-2024-124.html", "\"detail-124\"");
}
private void SeedNotModifiedResponses()
{
AddNotModifiedResponse(ListUri, "\"list-v1\"");
AddNotModifiedResponse(DetailResolved, "\"detail-123\"");
AddNotModifiedResponse(DetailOpen, "\"detail-124\"");
}
private void AddListResponse(string fixture, string etag)
{
RegisterResponseFactory(ListUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/plain"),
};
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private void AddDetailResponse(Uri uri, string fixture, string etag)
{
RegisterResponseFactory(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/html"),
};
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private void AddNotModifiedResponse(Uri uri, string etag)
{
RegisterResponseFactory(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
});
}
private void RegisterResponseFactory(Uri uri, Func<HttpResponseMessage> factory)
{
_handler.AddResponse(uri, () => factory());
_fallbackFactories[uri] = _ => factory();
}
private static string ReadFixture(string filename)
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "Debian", "Fixtures", filename),
Path.Combine(AppContext.BaseDirectory, "Distro", "Debian", "Fixtures", filename),
Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Source", "Distro", "Debian", "Fixtures", filename),
};
foreach (var candidate in candidates)
{
var fullPath = Path.GetFullPath(candidate);
if (File.Exists(fullPath))
{
return File.ReadAllText(fullPath);
}
}
throw new FileNotFoundException($"Fixture '{filename}' not found", filename);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
private sealed class TestOutputLoggerProvider : ILoggerProvider
{
private readonly ITestOutputHelper _output;
public TestOutputLoggerProvider(ITestOutputHelper output) => _output = output;
public ILogger CreateLogger(string categoryName) => new TestOutputLogger(_output);
public void Dispose()
{
}
private sealed class TestOutputLogger : ILogger
{
private readonly ITestOutputHelper _output;
public TestOutputLogger(ITestOutputHelper output) => _output = output;
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullLogger.Instance.BeginScope(state);
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (IsEnabled(logLevel))
{
_output.WriteLine(formatter(state, exception));
}
}
}
}
}

View File

@@ -1,33 +1,33 @@
using System;
using System.IO;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Distro.RedHat;
using StellaOps.Concelier.Connector.Distro.RedHat.Configuration;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using StellaOps.Concelier.Testing;
namespace StellaOps.Concelier.Connector.Distro.RedHat.Tests;
using System;
using System.IO;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Distro.RedHat;
using StellaOps.Concelier.Connector.Distro.RedHat.Configuration;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using StellaOps.Concelier.Testing;
namespace StellaOps.Concelier.Connector.Distro.RedHat.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class RedHatConnectorHarnessTests : IAsyncLifetime
{
private readonly ConnectorTestHarness _harness;
public RedHatConnectorHarnessTests(ConcelierPostgresFixture fixture)
{
_harness = new ConnectorTestHarness(fixture, new DateTimeOffset(2025, 10, 5, 0, 0, 0, TimeSpan.Zero), RedHatOptions.HttpClientName);
}
[Fact]
public async Task FetchParseMap_WithHarness_ProducesCanonicalAdvisory()
{
await _harness.ResetAsync();
public sealed class RedHatConnectorHarnessTests : IAsyncLifetime
{
private readonly ConnectorTestHarness _harness;
public RedHatConnectorHarnessTests(ConcelierPostgresFixture fixture)
{
_harness = new ConnectorTestHarness(fixture, new DateTimeOffset(2025, 10, 5, 0, 0, 0, TimeSpan.Zero), RedHatOptions.HttpClientName);
}
[Fact]
public async Task FetchParseMap_WithHarness_ProducesCanonicalAdvisory()
{
await _harness.ResetAsync();
var options = new RedHatOptions
{
BaseEndpoint = new Uri("https://access.redhat.com/hydra/rest/securitydata"),
@@ -39,10 +39,10 @@ public sealed class RedHatConnectorHarnessTests : IAsyncLifetime
FetchTimeout = TimeSpan.FromSeconds(30),
UserAgent = "StellaOps.Tests.RedHatHarness/1.0",
};
var handler = _harness.Handler;
var timeProvider = _harness.TimeProvider;
var handler = _harness.Handler;
var timeProvider = _harness.TimeProvider;
var summaryUri = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-04&per_page=10&page=1");
var summaryUriPost = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-05&per_page=10&page=1");
var summaryUriPostPage2 = new Uri("https://access.redhat.com/hydra/rest/securitydata/csaf.json?after=2025-10-05&per_page=10&page=2");
@@ -54,46 +54,46 @@ public sealed class RedHatConnectorHarnessTests : IAsyncLifetime
handler.AddJsonResponse(summaryUriPostPage2, "[]");
handler.AddJsonResponse(detailUri, ReadFixture("csaf-rhsa-2025-0001.json"));
handler.AddJsonResponse(detailUri2, ReadFixture("csaf-rhsa-2025-0002.json"));
await _harness.EnsureServiceProviderAsync(services =>
{
services.AddRedHatConnector(opts =>
{
opts.BaseEndpoint = options.BaseEndpoint;
opts.PageSize = options.PageSize;
opts.MaxPagesPerFetch = options.MaxPagesPerFetch;
opts.MaxAdvisoriesPerFetch = options.MaxAdvisoriesPerFetch;
opts.InitialBackfill = options.InitialBackfill;
opts.Overlap = options.Overlap;
opts.FetchTimeout = options.FetchTimeout;
opts.UserAgent = options.UserAgent;
});
});
var provider = _harness.ServiceProvider;
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
await stateRepository.UpsertAsync(
new SourceStateRecord(
RedHatConnectorPlugin.SourceName,
Enabled: true,
Paused: false,
Cursor: new BsonDocument(),
LastSuccess: null,
LastFailure: null,
FailCount: 0,
BackoffUntil: null,
UpdatedAt: timeProvider.GetUtcNow(),
LastFailureReason: null),
CancellationToken.None);
var connector = new RedHatConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
await _harness.EnsureServiceProviderAsync(services =>
{
services.AddRedHatConnector(opts =>
{
opts.BaseEndpoint = options.BaseEndpoint;
opts.PageSize = options.PageSize;
opts.MaxPagesPerFetch = options.MaxPagesPerFetch;
opts.MaxAdvisoriesPerFetch = options.MaxAdvisoriesPerFetch;
opts.InitialBackfill = options.InitialBackfill;
opts.Overlap = options.Overlap;
opts.FetchTimeout = options.FetchTimeout;
opts.UserAgent = options.UserAgent;
});
});
var provider = _harness.ServiceProvider;
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
await stateRepository.UpsertAsync(
new SourceStateRecord(
RedHatConnectorPlugin.SourceName,
Enabled: true,
Paused: false,
Cursor: new DocumentObject(),
LastSuccess: null,
LastFailure: null,
FailCount: 0,
BackoffUntil: null,
UpdatedAt: timeProvider.GetUtcNow(),
LastFailureReason: null),
CancellationToken.None);
var connector = new RedHatConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var advisory = advisories.Single(a => string.Equals(a.AdvisoryKey, "RHSA-2025:0001", StringComparison.Ordinal));
@@ -104,20 +104,20 @@ public sealed class RedHatConnectorHarnessTests : IAsyncLifetime
var secondAdvisory = advisories.Single(a => string.Equals(a.AdvisoryKey, "RHSA-2025:0002", StringComparison.Ordinal));
Assert.Equal("medium", secondAdvisory.Severity, ignoreCase: true);
Assert.Contains(secondAdvisory.Aliases, alias => alias == "CVE-2025-0002");
var state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsBsonArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings) && pendingMappings.AsBsonArray.Count == 0);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => _harness.ResetAsync();
private static string ReadFixture(string filename)
{
var path = Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "RedHat", "Fixtures", filename);
return File.ReadAllText(path);
}
}
var state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsDocumentArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings) && pendingMappings.AsDocumentArray.Count == 0);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => _harness.ResetAsync();
private static string ReadFixture(string filename)
{
var path = Path.Combine(AppContext.BaseDirectory, "Source", "Distro", "RedHat", "Fixtures", filename);
return File.ReadAllText(path);
}
}

View File

@@ -13,7 +13,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Connector.Common.Fetch;
@@ -104,7 +104,7 @@ public sealed class RedHatConnectorTests : IAsyncLifetime
RedHatConnectorPlugin.SourceName,
Enabled: true,
Paused: false,
Cursor: new BsonDocument(),
Cursor: new DocumentObject(),
LastSuccess: null,
LastFailure: null,
FailCount: 0,
@@ -171,8 +171,8 @@ public sealed class RedHatConnectorTests : IAsyncLifetime
var state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs2) && pendingDocs2.AsBsonArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings2) && pendingMappings2.AsBsonArray.Count == 0);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs2) && pendingDocs2.AsDocumentArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings2) && pendingMappings2.AsDocumentArray.Count == 0);
const string fetchKind = "source:redhat:fetch";
const string parseKind = "source:redhat:parse";
@@ -242,8 +242,8 @@ public sealed class RedHatConnectorTests : IAsyncLifetime
state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs3) && pendingDocs3.AsBsonArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings3) && pendingMappings3.AsBsonArray.Count == 0);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs3) && pendingDocs3.AsDocumentArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings3) && pendingMappings3.AsDocumentArray.Count == 0);
}
[Fact]
@@ -325,7 +325,7 @@ public sealed class RedHatConnectorTests : IAsyncLifetime
RedHatConnectorPlugin.SourceName,
Enabled: true,
Paused: false,
Cursor: new BsonDocument(),
Cursor: new DocumentObject(),
LastSuccess: null,
LastFailure: null,
FailCount: 0,
@@ -340,8 +340,8 @@ public sealed class RedHatConnectorTests : IAsyncLifetime
var state = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocs = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue.AsBsonArray
: new BsonArray();
? pendingDocsValue.AsDocumentArray
: new DocumentArray();
Assert.NotEmpty(pendingDocs);
pendingDocumentIds = pendingDocs.Select(value => Guid.Parse(value.AsString)).ToArray();
}
@@ -369,9 +369,9 @@ public sealed class RedHatConnectorTests : IAsyncLifetime
var stateRepository = resumeProvider.GetRequiredService<ISourceStateRepository>();
var finalState = await stateRepository.TryGetAsync(RedHatConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(finalState);
var finalPendingDocs = finalState!.Cursor.TryGetValue("pendingDocuments", out var docsValue) ? docsValue.AsBsonArray : new BsonArray();
var finalPendingDocs = finalState!.Cursor.TryGetValue("pendingDocuments", out var docsValue) ? docsValue.AsDocumentArray : new DocumentArray();
Assert.Empty(finalPendingDocs);
var finalPendingMappings = finalState.Cursor.TryGetValue("pendingMappings", out var mappingsValue) ? mappingsValue.AsBsonArray : new BsonArray();
var finalPendingMappings = finalState.Cursor.TryGetValue("pendingMappings", out var mappingsValue) ? mappingsValue.AsDocumentArray : new DocumentArray();
Assert.Empty(finalPendingMappings);
}
}
@@ -410,7 +410,7 @@ public sealed class RedHatConnectorTests : IAsyncLifetime
RedHatConnectorPlugin.SourceName,
Enabled: true,
Paused: false,
Cursor: new BsonDocument(),
Cursor: new DocumentObject(),
LastSuccess: null,
LastFailure: null,
FailCount: 0,
@@ -480,7 +480,7 @@ public sealed class RedHatConnectorTests : IAsyncLifetime
var json = File.ReadAllText(jsonPath);
using var jsonDocument = JsonDocument.Parse(json);
var bson = BsonDocument.Parse(json);
var bson = DocumentObject.Parse(json);
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{

View File

@@ -1,9 +1,9 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -18,14 +18,14 @@ using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Connector.Distro.Suse.Tests;
namespace StellaOps.Concelier.Connector.Distro.Suse.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class SuseConnectorTests
{
private static readonly Uri ChangesUri = new("https://ftp.suse.com/pub/projects/security/csaf/changes.csv");
private static readonly Uri AdvisoryResolvedUri = new("https://ftp.suse.com/pub/projects/security/csaf/suse-su-2025_0001-1.json");
private static readonly Uri ChangesUri = new("https://ftp.suse.com/pub/projects/security/csaf/changes.csv");
private static readonly Uri AdvisoryResolvedUri = new("https://ftp.suse.com/pub/projects/security/csaf/suse-su-2025_0001-1.json");
private static readonly Uri AdvisoryOpenUri = new("https://ftp.suse.com/pub/projects/security/csaf/suse-su-2025_0002-1.json");
private readonly ConcelierPostgresFixture _fixture;
@@ -34,7 +34,7 @@ public sealed class SuseConnectorTests
{
_fixture = fixture;
}
[Fact]
public async Task FetchParseMap_ProcessesResolvedAndOpenNotices()
{
@@ -51,15 +51,15 @@ public sealed class SuseConnectorTests
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var resolved = advisories.Single(a => a.AdvisoryKey == "SUSE-SU-2025:0001-1");
var resolvedPackage = Assert.Single(resolved.AffectedPackages);
var resolvedRange = Assert.Single(resolvedPackage.VersionRanges);
Assert.Equal("nevra", resolvedRange.RangeKind);
Assert.NotNull(resolvedRange.Primitives);
Assert.NotNull(resolvedRange.Primitives!.Nevra?.Fixed);
var open = advisories.Single(a => a.AdvisoryKey == "SUSE-SU-2025:0002-1");
var resolved = advisories.Single(a => a.AdvisoryKey == "SUSE-SU-2025:0001-1");
var resolvedPackage = Assert.Single(resolved.AffectedPackages);
var resolvedRange = Assert.Single(resolvedPackage.VersionRanges);
Assert.Equal("nevra", resolvedRange.RangeKind);
Assert.NotNull(resolvedRange.Primitives);
Assert.NotNull(resolvedRange.Primitives!.Nevra?.Fixed);
var open = advisories.Single(a => a.AdvisoryKey == "SUSE-SU-2025:0002-1");
var openPackage = Assert.Single(open.AffectedPackages);
Assert.Equal(AffectedPackageStatusCatalog.UnderInvestigation, openPackage.Statuses.Single().Status);
@@ -108,22 +108,22 @@ public sealed class SuseConnectorTests
{
var response = new HttpResponseMessage(statusCode);
if (statusCode == HttpStatusCode.OK)
{
var contentType = fixture.EndsWith(".csv", StringComparison.OrdinalIgnoreCase) ? "text/csv" : "application/json";
response.Content = new StringContent(ReadFixture(Path.Combine("Source", "Distro", "Suse", "Fixtures", fixture)), Encoding.UTF8, contentType);
}
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
}
private static string ReadFixture(string relativePath)
{
var path = Path.Combine(AppContext.BaseDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(path))
{
throw new FileNotFoundException($"Fixture '{relativePath}' not found.", path);
}
{
var contentType = fixture.EndsWith(".csv", StringComparison.OrdinalIgnoreCase) ? "text/csv" : "application/json";
response.Content = new StringContent(ReadFixture(Path.Combine("Source", "Distro", "Suse", "Fixtures", fixture)), Encoding.UTF8, contentType);
}
response.Headers.ETag = new EntityTagHeaderValue(etag);
return response;
}
private static string ReadFixture(string relativePath)
{
var path = Path.Combine(AppContext.BaseDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(path))
{
throw new FileNotFoundException($"Fixture '{relativePath}' not found.", path);
}
return File.ReadAllText(path);
}

View File

@@ -1,52 +1,52 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using StellaOps.Concelier.Connector.Distro.Suse.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Distro.Suse.Tests;
public sealed class SuseCsafParserTests
{
[Fact]
public void Parse_ProducesRecommendedAndAffectedPackages()
{
var json = ReadFixture("Source/Distro/Suse/Fixtures/suse-su-2025_0001-1.json");
var dto = SuseCsafParser.Parse(json);
Assert.Equal("SUSE-SU-2025:0001-1", dto.AdvisoryId);
Assert.Contains("CVE-2025-0001", dto.CveIds);
var package = Assert.Single(dto.Packages);
Assert.Equal("openssl", package.Package);
Assert.Equal("resolved", package.Status);
Assert.NotNull(package.FixedVersion);
Assert.Equal("SUSE Linux Enterprise Server 15 SP5", package.Platform);
Assert.Equal("openssl-1.1.1w-150500.17.25.1.x86_64", package.CanonicalNevra);
}
[Fact]
public void Parse_HandlesOpenInvestigation()
{
var json = ReadFixture("Source/Distro/Suse/Fixtures/suse-su-2025_0002-1.json");
var dto = SuseCsafParser.Parse(json);
Assert.Equal("SUSE-SU-2025:0002-1", dto.AdvisoryId);
Assert.Contains("CVE-2025-0002", dto.CveIds);
var package = Assert.Single(dto.Packages);
Assert.Equal("open", package.Status);
Assert.Equal("postgresql16", package.Package);
Assert.NotNull(package.LastAffectedVersion);
}
private static string ReadFixture(string relativePath)
{
var path = Path.Combine(AppContext.BaseDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(path))
{
throw new FileNotFoundException($"Fixture '{relativePath}' not found.", path);
}
return File.ReadAllText(path);
}
}
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using StellaOps.Concelier.Connector.Distro.Suse.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Distro.Suse.Tests;
public sealed class SuseCsafParserTests
{
[Fact]
public void Parse_ProducesRecommendedAndAffectedPackages()
{
var json = ReadFixture("Source/Distro/Suse/Fixtures/suse-su-2025_0001-1.json");
var dto = SuseCsafParser.Parse(json);
Assert.Equal("SUSE-SU-2025:0001-1", dto.AdvisoryId);
Assert.Contains("CVE-2025-0001", dto.CveIds);
var package = Assert.Single(dto.Packages);
Assert.Equal("openssl", package.Package);
Assert.Equal("resolved", package.Status);
Assert.NotNull(package.FixedVersion);
Assert.Equal("SUSE Linux Enterprise Server 15 SP5", package.Platform);
Assert.Equal("openssl-1.1.1w-150500.17.25.1.x86_64", package.CanonicalNevra);
}
[Fact]
public void Parse_HandlesOpenInvestigation()
{
var json = ReadFixture("Source/Distro/Suse/Fixtures/suse-su-2025_0002-1.json");
var dto = SuseCsafParser.Parse(json);
Assert.Equal("SUSE-SU-2025:0002-1", dto.AdvisoryId);
Assert.Contains("CVE-2025-0002", dto.CveIds);
var package = Assert.Single(dto.Packages);
Assert.Equal("open", package.Status);
Assert.Equal("postgresql16", package.Package);
Assert.NotNull(package.LastAffectedVersion);
}
private static string ReadFixture(string relativePath)
{
var path = Path.Combine(AppContext.BaseDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(path))
{
throw new FileNotFoundException($"Fixture '{relativePath}' not found.", path);
}
return File.ReadAllText(path);
}
}

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Distro.Suse;

View File

@@ -1,9 +1,9 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -18,13 +18,13 @@ using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using StellaOps.Cryptography.DependencyInjection;
using Xunit;
namespace StellaOps.Concelier.Connector.Distro.Ubuntu.Tests;
namespace StellaOps.Concelier.Connector.Distro.Ubuntu.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class UbuntuConnectorTests
{
private static readonly Uri IndexPage0Uri = new("https://ubuntu.com/security/notices.json?offset=0&limit=1");
private static readonly Uri IndexPage0Uri = new("https://ubuntu.com/security/notices.json?offset=0&limit=1");
private static readonly Uri IndexPage1Uri = new("https://ubuntu.com/security/notices.json?offset=1&limit=1");
private readonly ConcelierPostgresFixture _fixture;
@@ -50,9 +50,9 @@ public sealed class UbuntuConnectorTests
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var kernelNotice = advisories.Single(a => a.AdvisoryKey == "USN-9001-1");
var noblePackage = Assert.Single(kernelNotice.AffectedPackages, pkg => pkg.Platform == "noble");
var kernelNotice = advisories.Single(a => a.AdvisoryKey == "USN-9001-1");
var noblePackage = Assert.Single(kernelNotice.AffectedPackages, pkg => pkg.Platform == "noble");
var range = Assert.Single(noblePackage.VersionRanges);
Assert.Equal("evr", range.RangeKind);
Assert.NotNull(range.Primitives);
@@ -101,8 +101,8 @@ public sealed class UbuntuConnectorTests
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture("Fixtures/ubuntu-notices-page0.json"), Encoding.UTF8, "application/json")
};
response.Headers.ETag = new EntityTagHeaderValue("\"index-page0-v1\"");
};
response.Headers.ETag = new EntityTagHeaderValue("\"index-page0-v1\"");
return response;
});
@@ -111,8 +111,8 @@ public sealed class UbuntuConnectorTests
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture("Fixtures/ubuntu-notices-page1.json"), Encoding.UTF8, "application/json")
};
response.Headers.ETag = new EntityTagHeaderValue("\"index-page1-v1\"");
};
response.Headers.ETag = new EntityTagHeaderValue("\"index-page1-v1\"");
return response;
});
}
@@ -125,18 +125,18 @@ public sealed class UbuntuConnectorTests
response.Headers.ETag = new EntityTagHeaderValue("\"index-page0-v1\"");
return response;
});
// Page 1 remains cached; the connector should skip fetching it when page 0 is unchanged.
}
// Page 1 remains cached; the connector should skip fetching it when page 0 is unchanged.
}
private static string ReadFixture(string relativePath)
{
var path = Path.Combine(AppContext.BaseDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(path))
{
throw new FileNotFoundException($"Fixture '{relativePath}' not found.", path);
}
{
var path = Path.Combine(AppContext.BaseDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(path))
{
throw new FileNotFoundException($"Fixture '{relativePath}' not found.", path);
}
return File.ReadAllText(path);
}
}

View File

@@ -1,204 +1,204 @@
using System.Net;
using System.Net.Http;
using System.Text;
using StellaOps.Concelier.Bson;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Ghsa.Configuration;
using StellaOps.Concelier.Connector.Ghsa.Internal;
using StellaOps.Concelier.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
namespace StellaOps.Concelier.Connector.Ghsa.Tests;
using System.Net;
using System.Net.Http;
using System.Text;
using StellaOps.Concelier.Documents;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Ghsa.Configuration;
using StellaOps.Concelier.Connector.Ghsa.Internal;
using StellaOps.Concelier.Testing;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
namespace StellaOps.Concelier.Connector.Ghsa.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class GhsaConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private ConnectorTestHarness? _harness;
public GhsaConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task FetchParseMap_EmitsCanonicalAdvisory()
{
var initialTime = new DateTimeOffset(2024, 10, 2, 0, 0, 0, TimeSpan.Zero);
await EnsureHarnessAsync(initialTime);
var harness = _harness!;
var since = initialTime - TimeSpan.FromDays(30);
var listUri = new Uri($"https://ghsa.test/security/advisories?updated_since={Uri.EscapeDataString(since.ToString("O"))}&updated_until={Uri.EscapeDataString(initialTime.ToString("O"))}&page=1&per_page=5");
harness.Handler.AddJsonResponse(listUri, ReadFixture("Fixtures/ghsa-list.json"));
harness.Handler.SetFallback(request =>
{
if (request.RequestUri is null)
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
if (request.RequestUri.AbsoluteUri.Equals("https://ghsa.test/security/advisories/GHSA-xxxx-yyyy-zzzz", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture("Fixtures/ghsa-GHSA-xxxx-yyyy-zzzz.json"), Encoding.UTF8, "application/json")
};
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var connector = new GhsaConnectorPlugin().Create(harness.ServiceProvider);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisory = await advisoryStore.FindAsync("GHSA-xxxx-yyyy-zzzz", CancellationToken.None);
Assert.NotNull(advisory);
Assert.Collection(advisory!.Credits,
credit =>
{
Assert.Equal("remediation_developer", credit.Role);
Assert.Equal("maintainer-team", credit.DisplayName);
Assert.Contains("https://github.com/maintainer-team", credit.Contacts);
},
credit =>
{
Assert.Equal("reporter", credit.Role);
Assert.Equal("security-reporter", credit.DisplayName);
Assert.Contains("https://github.com/security-reporter", credit.Contacts);
});
var weakness = Assert.Single(advisory.Cwes);
Assert.Equal("CWE-79", weakness.Identifier);
Assert.Equal("https://cwe.mitre.org/data/definitions/79.html", weakness.Uri);
var metric = Assert.Single(advisory.CvssMetrics);
Assert.Equal("3.1", metric.Version);
Assert.Equal("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", metric.Vector);
Assert.Equal("critical", metric.BaseSeverity);
Assert.Equal("3.1|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", advisory.CanonicalMetricId);
var snapshot = SnapshotSerializer.ToSnapshot(advisory).Replace("\r\n", "\n").TrimEnd();
var expected = ReadFixture("Fixtures/expected-GHSA-xxxx-yyyy-zzzz.json").Replace("\r\n", "\n").TrimEnd();
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "expected-GHSA-xxxx-yyyy-zzzz.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(expected, snapshot);
harness.Handler.AssertNoPendingResponses();
}
[Fact]
public async Task FetchAsync_RateLimitDefersWindowAndRecordsSnapshot()
{
var initialTime = new DateTimeOffset(2024, 10, 5, 0, 0, 0, TimeSpan.Zero);
await EnsureHarnessAsync(initialTime);
var harness = _harness!;
var since = initialTime - TimeSpan.FromDays(30);
var until = initialTime;
var listUri = new Uri($"https://ghsa.test/security/advisories?updated_since={Uri.EscapeDataString(since.ToString("O"))}&updated_until={Uri.EscapeDataString(until.ToString("O"))}&page=1&per_page=5");
harness.Handler.AddResponse(HttpMethod.Get, listUri, _ =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture("Fixtures/ghsa-list.json"), Encoding.UTF8, "application/json")
};
response.Headers.TryAddWithoutValidation("X-RateLimit-Resource", "core");
response.Headers.TryAddWithoutValidation("X-RateLimit-Limit", "5000");
response.Headers.TryAddWithoutValidation("X-RateLimit-Remaining", "0");
return response;
});
harness.Handler.SetFallback(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
var connector = new GhsaConnectorPlugin().Create(harness.ServiceProvider);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
Assert.Single(harness.Handler.Requests);
var diagnostics = harness.ServiceProvider.GetRequiredService<GhsaDiagnostics>();
var snapshot = diagnostics.GetLastRateLimitSnapshot();
Assert.True(snapshot.HasValue);
Assert.Equal("list", snapshot!.Value.Phase);
Assert.Equal("core", snapshot.Value.Resource);
Assert.Equal(0, snapshot.Value.Remaining);
var stateRepository = harness.ServiceProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(GhsaConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("currentWindowStart", out var startValue));
Assert.True(state.Cursor.TryGetValue("currentWindowEnd", out var endValue));
Assert.True(state.Cursor.TryGetValue("nextPage", out var nextPageValue));
Assert.Equal(since.UtcDateTime, startValue.ToUniversalTime());
Assert.Equal(until.UtcDateTime, endValue.ToUniversalTime());
Assert.Equal(1, nextPageValue.AsInt32);
Assert.True(state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
Assert.Empty(pendingDocs.AsBsonArray);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings));
Assert.Empty(pendingMappings.AsBsonArray);
}
private async Task EnsureHarnessAsync(DateTimeOffset initialTime)
{
if (_harness is not null)
{
return;
}
var harness = new ConnectorTestHarness(_fixture, initialTime, GhsaOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddGhsaConnector(options =>
{
options.BaseEndpoint = new Uri("https://ghsa.test/", UriKind.Absolute);
options.ApiToken = "test-token";
options.PageSize = 5;
options.MaxPagesPerFetch = 2;
options.RequestDelay = TimeSpan.Zero;
options.InitialBackfill = TimeSpan.FromDays(30);
options.SecondaryRateLimitBackoff = TimeSpan.FromMilliseconds(10);
});
});
_harness = harness;
}
private static string ReadFixture(string relativePath)
{
var path = Path.Combine(AppContext.BaseDirectory, relativePath);
return File.ReadAllText(path);
}
public async Task InitializeAsync()
{
await Task.CompletedTask;
}
public async Task DisposeAsync()
{
if (_harness is not null)
{
await _harness.DisposeAsync();
}
}
}
public sealed class GhsaConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private ConnectorTestHarness? _harness;
public GhsaConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task FetchParseMap_EmitsCanonicalAdvisory()
{
var initialTime = new DateTimeOffset(2024, 10, 2, 0, 0, 0, TimeSpan.Zero);
await EnsureHarnessAsync(initialTime);
var harness = _harness!;
var since = initialTime - TimeSpan.FromDays(30);
var listUri = new Uri($"https://ghsa.test/security/advisories?updated_since={Uri.EscapeDataString(since.ToString("O"))}&updated_until={Uri.EscapeDataString(initialTime.ToString("O"))}&page=1&per_page=5");
harness.Handler.AddJsonResponse(listUri, ReadFixture("Fixtures/ghsa-list.json"));
harness.Handler.SetFallback(request =>
{
if (request.RequestUri is null)
{
return new HttpResponseMessage(HttpStatusCode.NotFound);
}
if (request.RequestUri.AbsoluteUri.Equals("https://ghsa.test/security/advisories/GHSA-xxxx-yyyy-zzzz", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture("Fixtures/ghsa-GHSA-xxxx-yyyy-zzzz.json"), Encoding.UTF8, "application/json")
};
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var connector = new GhsaConnectorPlugin().Create(harness.ServiceProvider);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisory = await advisoryStore.FindAsync("GHSA-xxxx-yyyy-zzzz", CancellationToken.None);
Assert.NotNull(advisory);
Assert.Collection(advisory!.Credits,
credit =>
{
Assert.Equal("remediation_developer", credit.Role);
Assert.Equal("maintainer-team", credit.DisplayName);
Assert.Contains("https://github.com/maintainer-team", credit.Contacts);
},
credit =>
{
Assert.Equal("reporter", credit.Role);
Assert.Equal("security-reporter", credit.DisplayName);
Assert.Contains("https://github.com/security-reporter", credit.Contacts);
});
var weakness = Assert.Single(advisory.Cwes);
Assert.Equal("CWE-79", weakness.Identifier);
Assert.Equal("https://cwe.mitre.org/data/definitions/79.html", weakness.Uri);
var metric = Assert.Single(advisory.CvssMetrics);
Assert.Equal("3.1", metric.Version);
Assert.Equal("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", metric.Vector);
Assert.Equal("critical", metric.BaseSeverity);
Assert.Equal("3.1|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", advisory.CanonicalMetricId);
var snapshot = SnapshotSerializer.ToSnapshot(advisory).Replace("\r\n", "\n").TrimEnd();
var expected = ReadFixture("Fixtures/expected-GHSA-xxxx-yyyy-zzzz.json").Replace("\r\n", "\n").TrimEnd();
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "expected-GHSA-xxxx-yyyy-zzzz.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(expected, snapshot);
harness.Handler.AssertNoPendingResponses();
}
[Fact]
public async Task FetchAsync_RateLimitDefersWindowAndRecordsSnapshot()
{
var initialTime = new DateTimeOffset(2024, 10, 5, 0, 0, 0, TimeSpan.Zero);
await EnsureHarnessAsync(initialTime);
var harness = _harness!;
var since = initialTime - TimeSpan.FromDays(30);
var until = initialTime;
var listUri = new Uri($"https://ghsa.test/security/advisories?updated_since={Uri.EscapeDataString(since.ToString("O"))}&updated_until={Uri.EscapeDataString(until.ToString("O"))}&page=1&per_page=5");
harness.Handler.AddResponse(HttpMethod.Get, listUri, _ =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture("Fixtures/ghsa-list.json"), Encoding.UTF8, "application/json")
};
response.Headers.TryAddWithoutValidation("X-RateLimit-Resource", "core");
response.Headers.TryAddWithoutValidation("X-RateLimit-Limit", "5000");
response.Headers.TryAddWithoutValidation("X-RateLimit-Remaining", "0");
return response;
});
harness.Handler.SetFallback(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
var connector = new GhsaConnectorPlugin().Create(harness.ServiceProvider);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
Assert.Single(harness.Handler.Requests);
var diagnostics = harness.ServiceProvider.GetRequiredService<GhsaDiagnostics>();
var snapshot = diagnostics.GetLastRateLimitSnapshot();
Assert.True(snapshot.HasValue);
Assert.Equal("list", snapshot!.Value.Phase);
Assert.Equal("core", snapshot.Value.Resource);
Assert.Equal(0, snapshot.Value.Remaining);
var stateRepository = harness.ServiceProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(GhsaConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("currentWindowStart", out var startValue));
Assert.True(state.Cursor.TryGetValue("currentWindowEnd", out var endValue));
Assert.True(state.Cursor.TryGetValue("nextPage", out var nextPageValue));
Assert.Equal(since.UtcDateTime, startValue.ToUniversalTime());
Assert.Equal(until.UtcDateTime, endValue.ToUniversalTime());
Assert.Equal(1, nextPageValue.AsInt32);
Assert.True(state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
Assert.Empty(pendingDocs.AsDocumentArray);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings));
Assert.Empty(pendingMappings.AsDocumentArray);
}
private async Task EnsureHarnessAsync(DateTimeOffset initialTime)
{
if (_harness is not null)
{
return;
}
var harness = new ConnectorTestHarness(_fixture, initialTime, GhsaOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddGhsaConnector(options =>
{
options.BaseEndpoint = new Uri("https://ghsa.test/", UriKind.Absolute);
options.ApiToken = "test-token";
options.PageSize = 5;
options.MaxPagesPerFetch = 2;
options.RequestDelay = TimeSpan.Zero;
options.InitialBackfill = TimeSpan.FromDays(30);
options.SecondaryRateLimitBackoff = TimeSpan.FromMilliseconds(10);
});
});
_harness = harness;
}
private static string ReadFixture(string relativePath)
{
var path = Path.Combine(AppContext.BaseDirectory, relativePath);
return File.ReadAllText(path);
}
public async Task InitializeAsync()
{
await Task.CompletedTask;
}
public async Task DisposeAsync()
{
if (_harness is not null)
{
await _harness.DisposeAsync();
}
}
}

View File

@@ -1,51 +1,51 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Connector.Ghsa.Tests.Ghsa;
public sealed class GhsaCreditParityRegressionTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public void CreditParity_FixturesRemainInSyncAcrossSources()
{
var ghsa = LoadFixture("credit-parity.ghsa.json");
var osv = LoadFixture("credit-parity.osv.json");
var nvd = LoadFixture("credit-parity.nvd.json");
var ghsaCredits = NormalizeCredits(ghsa);
var osvCredits = NormalizeCredits(osv);
var nvdCredits = NormalizeCredits(nvd);
Assert.NotEmpty(ghsaCredits);
Assert.Equal(ghsaCredits, osvCredits);
Assert.Equal(ghsaCredits, nvdCredits);
}
private static Advisory LoadFixture(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName);
return JsonSerializer.Deserialize<Advisory>(File.ReadAllText(path), SerializerOptions)
?? throw new InvalidOperationException($"Failed to deserialize fixture '{fileName}'.");
}
private static HashSet<string> NormalizeCredits(Advisory advisory)
{
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var credit in advisory.Credits)
{
var contactList = credit.Contacts.IsDefaultOrEmpty
? Array.Empty<string>()
: credit.Contacts.ToArray();
var contacts = string.Join("|", contactList.OrderBy(static contact => contact, StringComparer.Ordinal));
var key = string.Join("||", credit.Role ?? string.Empty, credit.DisplayName ?? string.Empty, contacts);
set.Add(key);
}
return set;
}
}
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Connector.Ghsa.Tests.Ghsa;
public sealed class GhsaCreditParityRegressionTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public void CreditParity_FixturesRemainInSyncAcrossSources()
{
var ghsa = LoadFixture("credit-parity.ghsa.json");
var osv = LoadFixture("credit-parity.osv.json");
var nvd = LoadFixture("credit-parity.nvd.json");
var ghsaCredits = NormalizeCredits(ghsa);
var osvCredits = NormalizeCredits(osv);
var nvdCredits = NormalizeCredits(nvd);
Assert.NotEmpty(ghsaCredits);
Assert.Equal(ghsaCredits, osvCredits);
Assert.Equal(ghsaCredits, nvdCredits);
}
private static Advisory LoadFixture(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName);
return JsonSerializer.Deserialize<Advisory>(File.ReadAllText(path), SerializerOptions)
?? throw new InvalidOperationException($"Failed to deserialize fixture '{fileName}'.");
}
private static HashSet<string> NormalizeCredits(Advisory advisory)
{
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var credit in advisory.Credits)
{
var contactList = credit.Contacts.IsDefaultOrEmpty
? Array.Empty<string>()
: credit.Contacts.ToArray();
var contacts = string.Join("|", contactList.OrderBy(static contact => contact, StringComparer.Ordinal));
var key = string.Join("||", credit.Role ?? string.Empty, credit.DisplayName ?? string.Empty, contacts);
set.Add(key);
}
return set;
}
}

View File

@@ -1,71 +1,71 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Ghsa;
using StellaOps.Concelier.Connector.Ghsa.Configuration;
using Xunit;
namespace StellaOps.Concelier.Connector.Ghsa.Tests.Ghsa;
public sealed class GhsaDependencyInjectionRoutineTests
{
[Fact]
public void Register_ConfiguresConnectorAndScheduler()
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddOptions();
services.AddSourceCommon();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["concelier:sources:ghsa:apiToken"] = "test-token",
["concelier:sources:ghsa:pageSize"] = "25",
["concelier:sources:ghsa:maxPagesPerFetch"] = "3",
["concelier:sources:ghsa:initialBackfill"] = "1.00:00:00",
})
.Build();
var routine = new GhsaDependencyInjectionRoutine();
routine.Register(services, configuration);
services.Configure<JobSchedulerOptions>(_ => { });
var provider = services.BuildServiceProvider(validateScopes: true);
var ghsaOptions = provider.GetRequiredService<IOptions<GhsaOptions>>().Value;
Assert.Equal("test-token", ghsaOptions.ApiToken);
Assert.Equal(25, ghsaOptions.PageSize);
Assert.Equal(TimeSpan.FromDays(1), ghsaOptions.InitialBackfill);
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.True(schedulerOptions.Definitions.TryGetValue(GhsaJobKinds.Fetch, out var fetchDefinition));
Assert.True(schedulerOptions.Definitions.TryGetValue(GhsaJobKinds.Parse, out var parseDefinition));
Assert.True(schedulerOptions.Definitions.TryGetValue(GhsaJobKinds.Map, out var mapDefinition));
Assert.Equal(typeof(GhsaFetchJob), fetchDefinition.JobType);
Assert.Equal(TimeSpan.FromMinutes(6), fetchDefinition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(4), fetchDefinition.LeaseDuration);
Assert.Equal("1,11,21,31,41,51 * * * *", fetchDefinition.CronExpression);
Assert.True(fetchDefinition.Enabled);
Assert.Equal(typeof(GhsaParseJob), parseDefinition.JobType);
Assert.Equal(TimeSpan.FromMinutes(5), parseDefinition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(4), parseDefinition.LeaseDuration);
Assert.Equal("3,13,23,33,43,53 * * * *", parseDefinition.CronExpression);
Assert.True(parseDefinition.Enabled);
Assert.Equal(typeof(GhsaMapJob), mapDefinition.JobType);
Assert.Equal(TimeSpan.FromMinutes(5), mapDefinition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(4), mapDefinition.LeaseDuration);
Assert.Equal("5,15,25,35,45,55 * * * *", mapDefinition.CronExpression);
Assert.True(mapDefinition.Enabled);
}
}
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Ghsa;
using StellaOps.Concelier.Connector.Ghsa.Configuration;
using Xunit;
namespace StellaOps.Concelier.Connector.Ghsa.Tests.Ghsa;
public sealed class GhsaDependencyInjectionRoutineTests
{
[Fact]
public void Register_ConfiguresConnectorAndScheduler()
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddOptions();
services.AddSourceCommon();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["concelier:sources:ghsa:apiToken"] = "test-token",
["concelier:sources:ghsa:pageSize"] = "25",
["concelier:sources:ghsa:maxPagesPerFetch"] = "3",
["concelier:sources:ghsa:initialBackfill"] = "1.00:00:00",
})
.Build();
var routine = new GhsaDependencyInjectionRoutine();
routine.Register(services, configuration);
services.Configure<JobSchedulerOptions>(_ => { });
var provider = services.BuildServiceProvider(validateScopes: true);
var ghsaOptions = provider.GetRequiredService<IOptions<GhsaOptions>>().Value;
Assert.Equal("test-token", ghsaOptions.ApiToken);
Assert.Equal(25, ghsaOptions.PageSize);
Assert.Equal(TimeSpan.FromDays(1), ghsaOptions.InitialBackfill);
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.True(schedulerOptions.Definitions.TryGetValue(GhsaJobKinds.Fetch, out var fetchDefinition));
Assert.True(schedulerOptions.Definitions.TryGetValue(GhsaJobKinds.Parse, out var parseDefinition));
Assert.True(schedulerOptions.Definitions.TryGetValue(GhsaJobKinds.Map, out var mapDefinition));
Assert.Equal(typeof(GhsaFetchJob), fetchDefinition.JobType);
Assert.Equal(TimeSpan.FromMinutes(6), fetchDefinition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(4), fetchDefinition.LeaseDuration);
Assert.Equal("1,11,21,31,41,51 * * * *", fetchDefinition.CronExpression);
Assert.True(fetchDefinition.Enabled);
Assert.Equal(typeof(GhsaParseJob), parseDefinition.JobType);
Assert.Equal(TimeSpan.FromMinutes(5), parseDefinition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(4), parseDefinition.LeaseDuration);
Assert.Equal("3,13,23,33,43,53 * * * *", parseDefinition.CronExpression);
Assert.True(parseDefinition.Enabled);
Assert.Equal(typeof(GhsaMapJob), mapDefinition.JobType);
Assert.Equal(TimeSpan.FromMinutes(5), mapDefinition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(4), mapDefinition.LeaseDuration);
Assert.Equal("5,15,25,35,45,55 * * * *", mapDefinition.CronExpression);
Assert.True(mapDefinition.Enabled);
}
}

View File

@@ -1,35 +1,35 @@
using System;
using StellaOps.Concelier.Connector.Ghsa.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Ghsa.Tests.Ghsa;
public class GhsaDiagnosticsTests : IDisposable
{
private readonly GhsaDiagnostics diagnostics = new();
[Fact]
public void RecordRateLimit_PersistsSnapshot()
{
var snapshot = new GhsaRateLimitSnapshot(
Phase: "list",
Resource: "core",
Limit: 5000,
Remaining: 100,
Used: 4900,
ResetAt: DateTimeOffset.UtcNow.AddMinutes(1),
ResetAfter: TimeSpan.FromMinutes(1),
RetryAfter: TimeSpan.FromSeconds(10));
diagnostics.RecordRateLimit(snapshot);
var stored = diagnostics.GetLastRateLimitSnapshot();
Assert.NotNull(stored);
Assert.Equal(snapshot, stored);
}
public void Dispose()
{
diagnostics.Dispose();
}
}
using System;
using StellaOps.Concelier.Connector.Ghsa.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Ghsa.Tests.Ghsa;
public class GhsaDiagnosticsTests : IDisposable
{
private readonly GhsaDiagnostics diagnostics = new();
[Fact]
public void RecordRateLimit_PersistsSnapshot()
{
var snapshot = new GhsaRateLimitSnapshot(
Phase: "list",
Resource: "core",
Limit: 5000,
Remaining: 100,
Used: 4900,
ResetAt: DateTimeOffset.UtcNow.AddMinutes(1),
ResetAfter: TimeSpan.FromMinutes(1),
RetryAfter: TimeSpan.FromSeconds(10));
diagnostics.RecordRateLimit(snapshot);
var stored = diagnostics.GetLastRateLimitSnapshot();
Assert.NotNull(stored);
Assert.Equal(snapshot, stored);
}
public void Dispose()
{
diagnostics.Dispose();
}
}

View File

@@ -1,60 +1,60 @@
using System.Collections.Generic;
using System.Globalization;
using StellaOps.Concelier.Connector.Ghsa.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Ghsa.Tests.Ghsa;
public class GhsaRateLimitParserTests
{
[Fact]
public void TryParse_ReturnsSnapshot_WhenHeadersPresent()
{
var now = DateTimeOffset.UtcNow;
var reset = now.AddMinutes(5);
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["X-RateLimit-Limit"] = "5000",
["X-RateLimit-Remaining"] = "42",
["X-RateLimit-Used"] = "4958",
["X-RateLimit-Reset"] = reset.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
["X-RateLimit-Resource"] = "core"
};
var snapshot = GhsaRateLimitParser.TryParse(headers, now, "list");
Assert.True(snapshot.HasValue);
Assert.Equal("list", snapshot.Value.Phase);
Assert.Equal("core", snapshot.Value.Resource);
Assert.Equal(5000, snapshot.Value.Limit);
Assert.Equal(42, snapshot.Value.Remaining);
Assert.Equal(4958, snapshot.Value.Used);
Assert.NotNull(snapshot.Value.ResetAfter);
Assert.True(snapshot.Value.ResetAfter!.Value.TotalMinutes <= 5.1 && snapshot.Value.ResetAfter.Value.TotalMinutes >= 4.9);
}
[Fact]
public void TryParse_ReturnsNull_WhenHeadersMissing()
{
var snapshot = GhsaRateLimitParser.TryParse(null, DateTimeOffset.UtcNow, "list");
Assert.Null(snapshot);
}
[Fact]
public void TryParse_HandlesRetryAfter()
{
var now = DateTimeOffset.UtcNow;
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Retry-After"] = "60"
};
var snapshot = GhsaRateLimitParser.TryParse(headers, now, "detail");
Assert.True(snapshot.HasValue);
Assert.Equal("detail", snapshot.Value.Phase);
Assert.NotNull(snapshot.Value.RetryAfter);
Assert.Equal(60, Math.Round(snapshot.Value.RetryAfter!.Value.TotalSeconds));
}
}
using System.Collections.Generic;
using System.Globalization;
using StellaOps.Concelier.Connector.Ghsa.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Ghsa.Tests.Ghsa;
public class GhsaRateLimitParserTests
{
[Fact]
public void TryParse_ReturnsSnapshot_WhenHeadersPresent()
{
var now = DateTimeOffset.UtcNow;
var reset = now.AddMinutes(5);
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["X-RateLimit-Limit"] = "5000",
["X-RateLimit-Remaining"] = "42",
["X-RateLimit-Used"] = "4958",
["X-RateLimit-Reset"] = reset.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
["X-RateLimit-Resource"] = "core"
};
var snapshot = GhsaRateLimitParser.TryParse(headers, now, "list");
Assert.True(snapshot.HasValue);
Assert.Equal("list", snapshot.Value.Phase);
Assert.Equal("core", snapshot.Value.Resource);
Assert.Equal(5000, snapshot.Value.Limit);
Assert.Equal(42, snapshot.Value.Remaining);
Assert.Equal(4958, snapshot.Value.Used);
Assert.NotNull(snapshot.Value.ResetAfter);
Assert.True(snapshot.Value.ResetAfter!.Value.TotalMinutes <= 5.1 && snapshot.Value.ResetAfter.Value.TotalMinutes >= 4.9);
}
[Fact]
public void TryParse_ReturnsNull_WhenHeadersMissing()
{
var snapshot = GhsaRateLimitParser.TryParse(null, DateTimeOffset.UtcNow, "list");
Assert.Null(snapshot);
}
[Fact]
public void TryParse_HandlesRetryAfter()
{
var now = DateTimeOffset.UtcNow;
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Retry-After"] = "60"
};
var snapshot = GhsaRateLimitParser.TryParse(headers, now, "detail");
Assert.True(snapshot.HasValue);
Assert.Equal("detail", snapshot.Value.Phase);
Assert.NotNull(snapshot.Value.RetryAfter);
Assert.Equal(60, Math.Round(snapshot.Value.RetryAfter!.Value.TotalSeconds));
}
}

View File

@@ -1,94 +1,94 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Ics.Cisa;
using StellaOps.Concelier.Connector.Ics.Cisa.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Ics.Cisa.Tests.IcsCisa;
public class IcsCisaConnectorMappingTests
{
private static readonly DateTimeOffset RecordedAt = new(2025, 10, 14, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void BuildReferences_MergesFeedAndDetailAttachments()
{
var dto = new IcsCisaAdvisoryDto
{
AdvisoryId = "ICSA-25-123-01",
Title = "Sample Advisory",
Link = "https://www.cisa.gov/news-events/ics-advisories/icsa-25-123-01",
Summary = "Summary",
DescriptionHtml = "<p>Summary</p>",
Published = RecordedAt,
Updated = RecordedAt,
IsMedical = false,
References = new[]
{
"https://example.org/advisory",
"https://www.cisa.gov/news-events/ics-advisories/icsa-25-123-01"
},
Attachments = new List<IcsCisaAttachmentDto>
{
new() { Title = "PDF Attachment", Url = "https://files.cisa.gov/docs/icsa-25-123-01.pdf" },
}
};
var references = IcsCisaConnector.BuildReferences(dto, RecordedAt);
Assert.Equal(3, references.Count);
Assert.Contains(references, reference => reference.Kind == "attachment" && reference.Url == "https://files.cisa.gov/docs/icsa-25-123-01.pdf");
Assert.Contains(references, reference => reference.Url == "https://example.org/advisory");
Assert.Contains(references, reference => reference.Url == "https://www.cisa.gov/news-events/ics-advisories/icsa-25-123-01");
}
[Fact]
public void BuildMitigationReferences_ProducesReferences()
{
var dto = new IcsCisaAdvisoryDto
{
AdvisoryId = "ICSA-25-999-01",
Title = "Mitigation Test",
Link = "https://www.cisa.gov/news-events/ics-advisories/icsa-25-999-01",
Mitigations = new[] { "Apply firmware 9.9.1", "Limit network access" },
Published = RecordedAt,
Updated = RecordedAt,
IsMedical = false,
};
var references = IcsCisaConnector.BuildMitigationReferences(dto, RecordedAt);
Assert.Equal(2, references.Count);
var first = references.First();
Assert.Equal("mitigation", first.Kind);
Assert.Equal("icscisa-mitigation", first.SourceTag);
Assert.EndsWith("#mitigation-1", first.Url, StringComparison.Ordinal);
Assert.Contains("Apply firmware", first.Summary);
}
[Fact]
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Ics.Cisa;
using StellaOps.Concelier.Connector.Ics.Cisa.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Ics.Cisa.Tests.IcsCisa;
public class IcsCisaConnectorMappingTests
{
private static readonly DateTimeOffset RecordedAt = new(2025, 10, 14, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void BuildReferences_MergesFeedAndDetailAttachments()
{
var dto = new IcsCisaAdvisoryDto
{
AdvisoryId = "ICSA-25-123-01",
Title = "Sample Advisory",
Link = "https://www.cisa.gov/news-events/ics-advisories/icsa-25-123-01",
Summary = "Summary",
DescriptionHtml = "<p>Summary</p>",
Published = RecordedAt,
Updated = RecordedAt,
IsMedical = false,
References = new[]
{
"https://example.org/advisory",
"https://www.cisa.gov/news-events/ics-advisories/icsa-25-123-01"
},
Attachments = new List<IcsCisaAttachmentDto>
{
new() { Title = "PDF Attachment", Url = "https://files.cisa.gov/docs/icsa-25-123-01.pdf" },
}
};
var references = IcsCisaConnector.BuildReferences(dto, RecordedAt);
Assert.Equal(3, references.Count);
Assert.Contains(references, reference => reference.Kind == "attachment" && reference.Url == "https://files.cisa.gov/docs/icsa-25-123-01.pdf");
Assert.Contains(references, reference => reference.Url == "https://example.org/advisory");
Assert.Contains(references, reference => reference.Url == "https://www.cisa.gov/news-events/ics-advisories/icsa-25-123-01");
}
[Fact]
public void BuildMitigationReferences_ProducesReferences()
{
var dto = new IcsCisaAdvisoryDto
{
AdvisoryId = "ICSA-25-999-01",
Title = "Mitigation Test",
Link = "https://www.cisa.gov/news-events/ics-advisories/icsa-25-999-01",
Mitigations = new[] { "Apply firmware 9.9.1", "Limit network access" },
Published = RecordedAt,
Updated = RecordedAt,
IsMedical = false,
};
var references = IcsCisaConnector.BuildMitigationReferences(dto, RecordedAt);
Assert.Equal(2, references.Count);
var first = references.First();
Assert.Equal("mitigation", first.Kind);
Assert.Equal("icscisa-mitigation", first.SourceTag);
Assert.EndsWith("#mitigation-1", first.Url, StringComparison.Ordinal);
Assert.Contains("Apply firmware", first.Summary);
}
[Fact]
public void BuildAffectedPackages_EmitsProductRangesWithSemVer()
{
var dto = new IcsCisaAdvisoryDto
{
AdvisoryId = "ICSA-25-456-02",
Title = "Vendor Advisory",
Link = "https://www.cisa.gov/news-events/ics-advisories/icsa-25-456-02",
DescriptionHtml = "",
Summary = null,
Published = RecordedAt,
Vendors = new[] { "Example Corp" },
Products = new[] { "ControlSuite 4.2" }
};
var packages = IcsCisaConnector.BuildAffectedPackages(dto, RecordedAt);
var productPackage = Assert.Single(packages);
Assert.Equal(AffectedPackageTypes.IcsVendor, productPackage.Type);
Assert.Equal("ControlSuite", productPackage.Identifier);
Title = "Vendor Advisory",
Link = "https://www.cisa.gov/news-events/ics-advisories/icsa-25-456-02",
DescriptionHtml = "",
Summary = null,
Published = RecordedAt,
Vendors = new[] { "Example Corp" },
Products = new[] { "ControlSuite 4.2" }
};
var packages = IcsCisaConnector.BuildAffectedPackages(dto, RecordedAt);
var productPackage = Assert.Single(packages);
Assert.Equal(AffectedPackageTypes.IcsVendor, productPackage.Type);
Assert.Equal("ControlSuite", productPackage.Identifier);
var range = Assert.Single(productPackage.VersionRanges);
Assert.Equal("product", range.RangeKind);
Assert.Equal("4.2", range.RangeExpression);

View File

@@ -1,38 +1,38 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Concelier.Connector.Ics.Cisa.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Ics.Cisa.Tests.IcsCisa;
public class IcsCisaFeedParserTests
{
[Fact]
public void Parse_ReturnsAdvisories()
{
var parser = new IcsCisaFeedParser();
using var stream = File.OpenRead(Path.Combine("IcsCisa", "Fixtures", "sample-feed.xml"));
var advisories = parser.Parse(stream, isMedicalTopic: false, topicUri: new Uri("https://content.govdelivery.com/accounts/USDHSCISA/topics.rss"));
Assert.Equal(2, advisories.Count);
var first = advisories.First();
Console.WriteLine("Description:" + first.DescriptionHtml);
Console.WriteLine("Attachments:" + string.Join(",", first.Attachments.Select(a => a.Url)));
Console.WriteLine("References:" + string.Join(",", first.References));
Assert.Equal("ICSA-25-123-01", first.AdvisoryId);
Assert.Contains("CVE-2024-12345", first.CveIds);
Assert.Contains("Example Corp", first.Vendors);
Assert.Contains("ControlSuite 4.2", first.Products);
Assert.Contains(first.Attachments, attachment => attachment.Url == "https://example.com/security/icsa-25-123-01.pdf");
Assert.Contains(first.References, reference => reference == "https://www.cisa.gov/news-events/ics-advisories/icsa-25-123-01");
var second = advisories.Last();
Assert.True(second.IsMedical);
Assert.Contains("CVE-2025-11111", second.CveIds);
Assert.Contains("HealthTech", second.Vendors);
}
}
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Concelier.Connector.Ics.Cisa.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Ics.Cisa.Tests.IcsCisa;
public class IcsCisaFeedParserTests
{
[Fact]
public void Parse_ReturnsAdvisories()
{
var parser = new IcsCisaFeedParser();
using var stream = File.OpenRead(Path.Combine("IcsCisa", "Fixtures", "sample-feed.xml"));
var advisories = parser.Parse(stream, isMedicalTopic: false, topicUri: new Uri("https://content.govdelivery.com/accounts/USDHSCISA/topics.rss"));
Assert.Equal(2, advisories.Count);
var first = advisories.First();
Console.WriteLine("Description:" + first.DescriptionHtml);
Console.WriteLine("Attachments:" + string.Join(",", first.Attachments.Select(a => a.Url)));
Console.WriteLine("References:" + string.Join(",", first.References));
Assert.Equal("ICSA-25-123-01", first.AdvisoryId);
Assert.Contains("CVE-2024-12345", first.CveIds);
Assert.Contains("Example Corp", first.Vendors);
Assert.Contains("ControlSuite 4.2", first.Products);
Assert.Contains(first.Attachments, attachment => attachment.Url == "https://example.com/security/icsa-25-123-01.pdf");
Assert.Contains(first.References, reference => reference == "https://www.cisa.gov/news-events/ics-advisories/icsa-25-123-01");
var second = advisories.Last();
Assert.True(second.IsMedical);
Assert.Contains("CVE-2025-11111", second.CveIds);
Assert.Contains("HealthTech", second.Vendors);
}
}

View File

@@ -1,8 +1,8 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -14,9 +14,9 @@ using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.Ics.Cisa.Tests;
namespace StellaOps.Concelier.Connector.Ics.Cisa.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class IcsCisaConnectorTests
{
@@ -26,7 +26,7 @@ public sealed class IcsCisaConnectorTests
{
_fixture = fixture ?? throw new ArgumentNullException(nameof(fixture));
}
[Fact]
public async Task FetchParseMap_EndToEnd_ProducesCanonicalAdvisories()
{
@@ -42,37 +42,37 @@ public sealed class IcsCisaConnectorTests
var advisoryStore = harness.ServiceProvider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
Assert.Equal(2, advisories.Count);
var icsa = Assert.Single(advisories, advisory => advisory.AdvisoryKey == "ICSA-25-123-01");
Assert.Contains("CVE-2024-12345", icsa.Aliases);
Assert.Contains(icsa.References, reference => reference.Url == "https://example.com/security/icsa-25-123-01");
Assert.Contains(icsa.References, reference => reference.Url == "https://files.cisa.gov/docs/icsa-25-123-01.pdf" && reference.Kind == "attachment");
var icsaMitigations = icsa.References.Where(reference => reference.Kind == "mitigation").ToList();
Assert.Equal(2, icsaMitigations.Count);
Assert.Contains("Apply ControlSuite firmware version 4.2.1 or later.", icsaMitigations[0].Summary, StringComparison.Ordinal);
Assert.EndsWith("#mitigation-1", icsaMitigations[0].Url, StringComparison.Ordinal);
Assert.Contains("Restrict network access", icsaMitigations[1].Summary, StringComparison.Ordinal);
var controlSuitePackage = Assert.Single(icsa.AffectedPackages, package => string.Equals(package.Identifier, "ControlSuite", StringComparison.OrdinalIgnoreCase));
var controlSuiteRange = Assert.Single(controlSuitePackage.VersionRanges);
Assert.Equal("product", controlSuiteRange.RangeKind);
Assert.Equal("4.2", controlSuiteRange.RangeExpression);
Assert.NotNull(controlSuiteRange.Primitives);
Assert.NotNull(controlSuiteRange.Primitives!.SemVer);
Assert.Equal("4.2.0", controlSuiteRange.Primitives.SemVer!.ExactValue);
Assert.True(controlSuiteRange.Primitives.VendorExtensions!.TryGetValue("ics.product", out var controlSuiteProduct) && controlSuiteProduct == "ControlSuite");
Assert.True(controlSuiteRange.Primitives.VendorExtensions!.TryGetValue("ics.version", out var controlSuiteVersion) && controlSuiteVersion == "4.2");
Assert.True(controlSuiteRange.Primitives.VendorExtensions!.TryGetValue("ics.vendors", out var controlSuiteVendors) && controlSuiteVendors == "Example Corp");
var icsma = Assert.Single(advisories, advisory => advisory.AdvisoryKey == "ICSMA-25-045-01");
Assert.Contains("CVE-2025-11111", icsma.Aliases);
var icsmaMitigation = Assert.Single(icsma.References.Where(reference => reference.Kind == "mitigation"));
Assert.Contains("Contact HealthTech support", icsmaMitigation.Summary, StringComparison.Ordinal);
Assert.Contains(icsma.References, reference => reference.Url == "https://www.cisa.gov/sites/default/files/2025-10/ICSMA-25-045-01_Supplement.pdf");
var infusionPackage = Assert.Single(icsma.AffectedPackages, package => string.Equals(package.Identifier, "InfusionManager", StringComparison.OrdinalIgnoreCase));
var infusionRange = Assert.Single(infusionPackage.VersionRanges);
Assert.Contains("CVE-2024-12345", icsa.Aliases);
Assert.Contains(icsa.References, reference => reference.Url == "https://example.com/security/icsa-25-123-01");
Assert.Contains(icsa.References, reference => reference.Url == "https://files.cisa.gov/docs/icsa-25-123-01.pdf" && reference.Kind == "attachment");
var icsaMitigations = icsa.References.Where(reference => reference.Kind == "mitigation").ToList();
Assert.Equal(2, icsaMitigations.Count);
Assert.Contains("Apply ControlSuite firmware version 4.2.1 or later.", icsaMitigations[0].Summary, StringComparison.Ordinal);
Assert.EndsWith("#mitigation-1", icsaMitigations[0].Url, StringComparison.Ordinal);
Assert.Contains("Restrict network access", icsaMitigations[1].Summary, StringComparison.Ordinal);
var controlSuitePackage = Assert.Single(icsa.AffectedPackages, package => string.Equals(package.Identifier, "ControlSuite", StringComparison.OrdinalIgnoreCase));
var controlSuiteRange = Assert.Single(controlSuitePackage.VersionRanges);
Assert.Equal("product", controlSuiteRange.RangeKind);
Assert.Equal("4.2", controlSuiteRange.RangeExpression);
Assert.NotNull(controlSuiteRange.Primitives);
Assert.NotNull(controlSuiteRange.Primitives!.SemVer);
Assert.Equal("4.2.0", controlSuiteRange.Primitives.SemVer!.ExactValue);
Assert.True(controlSuiteRange.Primitives.VendorExtensions!.TryGetValue("ics.product", out var controlSuiteProduct) && controlSuiteProduct == "ControlSuite");
Assert.True(controlSuiteRange.Primitives.VendorExtensions!.TryGetValue("ics.version", out var controlSuiteVersion) && controlSuiteVersion == "4.2");
Assert.True(controlSuiteRange.Primitives.VendorExtensions!.TryGetValue("ics.vendors", out var controlSuiteVendors) && controlSuiteVendors == "Example Corp");
var icsma = Assert.Single(advisories, advisory => advisory.AdvisoryKey == "ICSMA-25-045-01");
Assert.Contains("CVE-2025-11111", icsma.Aliases);
var icsmaMitigation = Assert.Single(icsma.References.Where(reference => reference.Kind == "mitigation"));
Assert.Contains("Contact HealthTech support", icsmaMitigation.Summary, StringComparison.Ordinal);
Assert.Contains(icsma.References, reference => reference.Url == "https://www.cisa.gov/sites/default/files/2025-10/ICSMA-25-045-01_Supplement.pdf");
var infusionPackage = Assert.Single(icsma.AffectedPackages, package => string.Equals(package.Identifier, "InfusionManager", StringComparison.OrdinalIgnoreCase));
var infusionRange = Assert.Single(infusionPackage.VersionRanges);
Assert.Equal("2.1", infusionRange.RangeExpression);
}
@@ -107,11 +107,11 @@ public sealed class IcsCisaConnectorTests
var icsmaDetail = new Uri("https://www.cisa.gov/news-events/ics-medical-advisories/icsma-25-045-01", UriKind.Absolute);
handler.AddResponse(icsmaDetail, () => CreateTextResponse("IcsCisa/Fixtures/icsma-25-045-01.html", "text/html"));
}
private static HttpResponseMessage CreateTextResponse(string relativePath, string contentType)
{
var fullPath = Path.Combine(AppContext.BaseDirectory, relativePath);
var content = File.ReadAllText(fullPath);
private static HttpResponseMessage CreateTextResponse(string relativePath, string contentType)
{
var fullPath = Path.Combine(AppContext.BaseDirectory, relativePath);
var content = File.ReadAllText(fullPath);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(content, Encoding.UTF8, contentType),

View File

@@ -1,345 +1,345 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Ics.Kaspersky;
using StellaOps.Concelier.Connector.Ics.Kaspersky.Configuration;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Ics.Kaspersky;
using StellaOps.Concelier.Connector.Ics.Kaspersky.Configuration;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Testing;
namespace StellaOps.Concelier.Connector.Ics.Kaspersky.Tests;
namespace StellaOps.Concelier.Connector.Ics.Kaspersky.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class KasperskyConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private ServiceProvider? _serviceProvider;
public KasperskyConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 10, 20, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_CreatesSnapshot()
{
var options = new KasperskyOptions
{
FeedUri = new Uri("https://ics-cert.example/feed-advisories/", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(30),
WindowOverlap = TimeSpan.FromDays(1),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
_handler.AddTextResponse(options.FeedUri, ReadFixture("feed-page1.xml"), "application/rss+xml");
var detailUri = new Uri("https://ics-cert.example/advisories/acme-controller-2024/");
_handler.AddTextResponse(detailUri, ReadFixture("detail-acme-controller-2024.html"), "text/html");
var connector = new KasperskyConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
Assert.Single(advisories);
var canonical = SnapshotSerializer.ToSnapshot(advisories.Single());
var expected = ReadFixture("expected-advisory.json");
var normalizedExpected = NormalizeLineEndings(expected);
var normalizedActual = NormalizeLineEndings(canonical);
if (!string.Equals(normalizedExpected, normalizedActual, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Source", "Ics", "Kaspersky", "Fixtures", "expected-advisory.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, canonical);
}
Assert.Equal(normalizedExpected, normalizedActual);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(KasperskyConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(KasperskyConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pending)
? pending.AsBsonArray
: new BsonArray();
Assert.Empty(pendingDocuments);
}
[Fact]
public async Task FetchFailure_RecordsBackoff()
{
var options = new KasperskyOptions
{
FeedUri = new Uri("https://ics-cert.example/feed-advisories/", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(30),
WindowOverlap = TimeSpan.FromDays(1),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
_handler.AddResponse(options.FeedUri, () => new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("feed error", Encoding.UTF8, "text/plain"),
});
var connector = new KasperskyConnectorPlugin().Create(provider);
await Assert.ThrowsAsync<HttpRequestException>(() => connector.FetchAsync(provider, CancellationToken.None));
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(KasperskyConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.Equal(1, state!.FailCount);
Assert.NotNull(state.LastFailureReason);
Assert.Contains("500", state.LastFailureReason, StringComparison.Ordinal);
Assert.True(state.BackoffUntil.HasValue);
Assert.True(state.BackoffUntil!.Value > _timeProvider.GetUtcNow());
}
[Fact]
public async Task Fetch_NotModifiedMaintainsDocumentState()
{
var options = new KasperskyOptions
{
FeedUri = new Uri("https://ics-cert.example/feed-advisories/", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(30),
WindowOverlap = TimeSpan.FromDays(1),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
var feedXml = ReadFixture("feed-page1.xml");
var detailUri = new Uri("https://ics-cert.example/advisories/acme-controller-2024/");
var detailHtml = ReadFixture("detail-acme-controller-2024.html");
var etag = new EntityTagHeaderValue("\"ics-2024-acme\"");
var lastModified = new DateTimeOffset(2024, 10, 15, 10, 0, 0, TimeSpan.Zero);
_handler.AddTextResponse(options.FeedUri, feedXml, "application/rss+xml");
_handler.AddResponse(detailUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(detailHtml, Encoding.UTF8, "text/html"),
};
response.Headers.ETag = etag;
response.Content.Headers.LastModified = lastModified;
return response;
});
var connector = new KasperskyConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(KasperskyConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
_handler.AddTextResponse(options.FeedUri, feedXml, "application/rss+xml");
_handler.AddResponse(detailUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = etag;
return response;
});
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
document = await documentStore.FindBySourceAndUriAsync(KasperskyConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(KasperskyConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
Assert.Equal(0, pendingDocs.AsBsonArray.Count);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings));
Assert.Equal(0, pendingMappings.AsBsonArray.Count);
}
[Fact]
public async Task Fetch_DuplicateContentSkipsRequeue()
{
var options = new KasperskyOptions
{
FeedUri = new Uri("https://ics-cert.example/feed-advisories/", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(30),
WindowOverlap = TimeSpan.FromDays(1),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
var feedXml = ReadFixture("feed-page1.xml");
var detailUri = new Uri("https://ics-cert.example/advisories/acme-controller-2024/");
var detailHtml = ReadFixture("detail-acme-controller-2024.html");
_handler.AddTextResponse(options.FeedUri, feedXml, "application/rss+xml");
_handler.AddTextResponse(detailUri, detailHtml, "text/html");
var connector = new KasperskyConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(KasperskyConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
_handler.AddTextResponse(options.FeedUri, feedXml, "application/rss+xml");
_handler.AddTextResponse(detailUri, detailHtml, "text/html");
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
document = await documentStore.FindBySourceAndUriAsync(KasperskyConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(KasperskyConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocs = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue.AsBsonArray
: new BsonArray();
Assert.Empty(pendingDocs);
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue.AsBsonArray
: new BsonArray();
Assert.Empty(pendingMappings);
}
private async Task EnsureServiceProviderAsync(KasperskyOptions template)
{
if (_serviceProvider is not null)
{
await ResetDatabaseAsync();
return;
}
public sealed class KasperskyConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private ServiceProvider? _serviceProvider;
public KasperskyConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 10, 20, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_CreatesSnapshot()
{
var options = new KasperskyOptions
{
FeedUri = new Uri("https://ics-cert.example/feed-advisories/", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(30),
WindowOverlap = TimeSpan.FromDays(1),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
_handler.AddTextResponse(options.FeedUri, ReadFixture("feed-page1.xml"), "application/rss+xml");
var detailUri = new Uri("https://ics-cert.example/advisories/acme-controller-2024/");
_handler.AddTextResponse(detailUri, ReadFixture("detail-acme-controller-2024.html"), "text/html");
var connector = new KasperskyConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
Assert.Single(advisories);
var canonical = SnapshotSerializer.ToSnapshot(advisories.Single());
var expected = ReadFixture("expected-advisory.json");
var normalizedExpected = NormalizeLineEndings(expected);
var normalizedActual = NormalizeLineEndings(canonical);
if (!string.Equals(normalizedExpected, normalizedActual, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Source", "Ics", "Kaspersky", "Fixtures", "expected-advisory.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, canonical);
}
Assert.Equal(normalizedExpected, normalizedActual);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(KasperskyConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(KasperskyConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pending)
? pending.AsDocumentArray
: new DocumentArray();
Assert.Empty(pendingDocuments);
}
[Fact]
public async Task FetchFailure_RecordsBackoff()
{
var options = new KasperskyOptions
{
FeedUri = new Uri("https://ics-cert.example/feed-advisories/", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(30),
WindowOverlap = TimeSpan.FromDays(1),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
_handler.AddResponse(options.FeedUri, () => new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("feed error", Encoding.UTF8, "text/plain"),
});
var connector = new KasperskyConnectorPlugin().Create(provider);
await Assert.ThrowsAsync<HttpRequestException>(() => connector.FetchAsync(provider, CancellationToken.None));
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(KasperskyConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.Equal(1, state!.FailCount);
Assert.NotNull(state.LastFailureReason);
Assert.Contains("500", state.LastFailureReason, StringComparison.Ordinal);
Assert.True(state.BackoffUntil.HasValue);
Assert.True(state.BackoffUntil!.Value > _timeProvider.GetUtcNow());
}
[Fact]
public async Task Fetch_NotModifiedMaintainsDocumentState()
{
var options = new KasperskyOptions
{
FeedUri = new Uri("https://ics-cert.example/feed-advisories/", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(30),
WindowOverlap = TimeSpan.FromDays(1),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
var feedXml = ReadFixture("feed-page1.xml");
var detailUri = new Uri("https://ics-cert.example/advisories/acme-controller-2024/");
var detailHtml = ReadFixture("detail-acme-controller-2024.html");
var etag = new EntityTagHeaderValue("\"ics-2024-acme\"");
var lastModified = new DateTimeOffset(2024, 10, 15, 10, 0, 0, TimeSpan.Zero);
_handler.AddTextResponse(options.FeedUri, feedXml, "application/rss+xml");
_handler.AddResponse(detailUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(detailHtml, Encoding.UTF8, "text/html"),
};
response.Headers.ETag = etag;
response.Content.Headers.LastModified = lastModified;
return response;
});
var connector = new KasperskyConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(KasperskyConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
_handler.AddTextResponse(options.FeedUri, feedXml, "application/rss+xml");
_handler.AddResponse(detailUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = etag;
return response;
});
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
document = await documentStore.FindBySourceAndUriAsync(KasperskyConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(KasperskyConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
Assert.Equal(0, pendingDocs.AsDocumentArray.Count);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMappings));
Assert.Equal(0, pendingMappings.AsDocumentArray.Count);
}
[Fact]
public async Task Fetch_DuplicateContentSkipsRequeue()
{
var options = new KasperskyOptions
{
FeedUri = new Uri("https://ics-cert.example/feed-advisories/", UriKind.Absolute),
WindowSize = TimeSpan.FromDays(30),
WindowOverlap = TimeSpan.FromDays(1),
MaxPagesPerFetch = 1,
RequestDelay = TimeSpan.Zero,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
_handler.Clear();
var feedXml = ReadFixture("feed-page1.xml");
var detailUri = new Uri("https://ics-cert.example/advisories/acme-controller-2024/");
var detailHtml = ReadFixture("detail-acme-controller-2024.html");
_handler.AddTextResponse(options.FeedUri, feedXml, "application/rss+xml");
_handler.AddTextResponse(detailUri, detailHtml, "text/html");
var connector = new KasperskyConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(KasperskyConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
_handler.AddTextResponse(options.FeedUri, feedXml, "application/rss+xml");
_handler.AddTextResponse(detailUri, detailHtml, "text/html");
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
document = await documentStore.FindBySourceAndUriAsync(KasperskyConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(KasperskyConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocs = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue.AsDocumentArray
: new DocumentArray();
Assert.Empty(pendingDocs);
var pendingMappings = state.Cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue.AsDocumentArray
: new DocumentArray();
Assert.Empty(pendingMappings);
}
private async Task EnsureServiceProviderAsync(KasperskyOptions template)
{
if (_serviceProvider is not null)
{
await ResetDatabaseAsync();
return;
}
await _fixture.TruncateAllTablesAsync();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddKasperskyIcsConnector(opts =>
{
opts.FeedUri = template.FeedUri;
opts.WindowSize = template.WindowSize;
opts.WindowOverlap = template.WindowOverlap;
opts.MaxPagesPerFetch = template.MaxPagesPerFetch;
opts.RequestDelay = template.RequestDelay;
});
services.Configure<HttpClientFactoryOptions>(KasperskyOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
_serviceProvider = services.BuildServiceProvider();
services.AddSourceCommon();
services.AddKasperskyIcsConnector(opts =>
{
opts.FeedUri = template.FeedUri;
opts.WindowSize = template.WindowSize;
opts.WindowOverlap = template.WindowOverlap;
opts.MaxPagesPerFetch = template.MaxPagesPerFetch;
opts.RequestDelay = template.RequestDelay;
});
services.Configure<HttpClientFactoryOptions>(KasperskyOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
_serviceProvider = services.BuildServiceProvider();
}
private Task ResetDatabaseAsync()
=> _fixture.TruncateAllTablesAsync();
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "Ics", "Kaspersky", "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var fallback = Path.Combine(baseDirectory, "Kaspersky", "Fixtures", filename);
return File.ReadAllText(fallback);
}
private static string NormalizeLineEndings(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
_serviceProvider?.Dispose();
}
}
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "Ics", "Kaspersky", "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var fallback = Path.Combine(baseDirectory, "Kaspersky", "Fixtures", filename);
return File.ReadAllText(fallback);
}
private static string NormalizeLineEndings(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
_serviceProvider?.Dispose();
}
}
}

View File

@@ -1,16 +1,16 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Http;
@@ -26,57 +26,57 @@ using StellaOps.Concelier.Storage.JpFlags;
using StellaOps.Concelier.Storage.Postgres;
using Xunit.Abstractions;
using StellaOps.Concelier.Testing;
namespace StellaOps.Concelier.Connector.Jvn.Tests;
namespace StellaOps.Concelier.Connector.Jvn.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class JvnConnectorTests : IAsyncLifetime
{
private const string VulnId = "JVNDB-2024-123456";
private readonly ConcelierPostgresFixture _fixture;
private readonly ITestOutputHelper _output;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private ServiceProvider? _serviceProvider;
public JvnConnectorTests(ConcelierPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 3, 10, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesDeterministicSnapshot()
{
var options = new JvnOptions
{
WindowSize = TimeSpan.FromDays(1),
WindowOverlap = TimeSpan.FromHours(6),
PageSize = 10,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
var now = _timeProvider.GetUtcNow();
var windowStart = now - options.WindowSize;
var windowEnd = now;
var overviewUri = BuildOverviewUri(options, windowStart, windowEnd, startItem: 1);
_handler.AddTextResponse(overviewUri, ReadFixture("jvnrss-window1.xml"), "application/xml");
var detailUri = BuildDetailUri(options, VulnId);
_handler.AddTextResponse(detailUri, ReadFixture("vuldef-JVNDB-2024-123456.xml"), "application/xml");
var connector = new JvnConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
var stateAfterFetch = await provider.GetRequiredService<ISourceStateRepository>()
.TryGetAsync(JvnConnectorPlugin.SourceName, CancellationToken.None);
public sealed class JvnConnectorTests : IAsyncLifetime
{
private const string VulnId = "JVNDB-2024-123456";
private readonly ConcelierPostgresFixture _fixture;
private readonly ITestOutputHelper _output;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private ServiceProvider? _serviceProvider;
public JvnConnectorTests(ConcelierPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 3, 10, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesDeterministicSnapshot()
{
var options = new JvnOptions
{
WindowSize = TimeSpan.FromDays(1),
WindowOverlap = TimeSpan.FromHours(6),
PageSize = 10,
};
await EnsureServiceProviderAsync(options);
var provider = _serviceProvider!;
var now = _timeProvider.GetUtcNow();
var windowStart = now - options.WindowSize;
var windowEnd = now;
var overviewUri = BuildOverviewUri(options, windowStart, windowEnd, startItem: 1);
_handler.AddTextResponse(overviewUri, ReadFixture("jvnrss-window1.xml"), "application/xml");
var detailUri = BuildDetailUri(options, VulnId);
_handler.AddTextResponse(detailUri, ReadFixture("vuldef-JVNDB-2024-123456.xml"), "application/xml");
var connector = new JvnConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
var stateAfterFetch = await provider.GetRequiredService<ISourceStateRepository>()
.TryGetAsync(JvnConnectorPlugin.SourceName, CancellationToken.None);
if (stateAfterFetch?.Cursor is not null)
{
_output.WriteLine($"Fetch state cursor: {stateAfterFetch.Cursor.ToJson()}");
@@ -84,10 +84,10 @@ public sealed class JvnConnectorTests : IAsyncLifetime
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
var stateAfterParse = await provider.GetRequiredService<ISourceStateRepository>()
.TryGetAsync(JvnConnectorPlugin.SourceName, CancellationToken.None);
_output.WriteLine($"Parse state failure reason: {stateAfterParse?.LastFailureReason ?? "<none>"}");
var stateAfterParse = await provider.GetRequiredService<ISourceStateRepository>()
.TryGetAsync(JvnConnectorPlugin.SourceName, CancellationToken.None);
_output.WriteLine($"Parse state failure reason: {stateAfterParse?.LastFailureReason ?? "<none>"}");
if (stateAfterParse?.Cursor is not null)
{
_output.WriteLine($"Parse state cursor: {stateAfterParse.Cursor.ToJson()}");
@@ -99,173 +99,173 @@ public sealed class JvnConnectorTests : IAsyncLifetime
var singleAdvisory = await advisoryStore.FindAsync(VulnId, CancellationToken.None);
Assert.NotNull(singleAdvisory);
_output.WriteLine($"singleAdvisory null? {singleAdvisory is null}");
var canonical = SnapshotSerializer.ToSnapshot(singleAdvisory!).Replace("\r\n", "\n");
var expected = ReadFixture("expected-advisory.json").Replace("\r\n", "\n");
if (!string.Equals(expected, canonical, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Jvn", "Fixtures", "expected-advisory.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, canonical);
}
Assert.Equal(expected, canonical);
var jpFlagStore = provider.GetRequiredService<IJpFlagStore>();
var jpFlag = await jpFlagStore.FindAsync(VulnId, CancellationToken.None);
Assert.NotNull(jpFlag);
Assert.Equal("product", jpFlag!.Category);
Assert.Equal("vulnerable", jpFlag.VendorStatus);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(JvnConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(JvnConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
Assert.Empty(pendingDocs.AsBsonArray);
}
private async Task EnsureServiceProviderAsync(JvnOptions template)
{
if (_serviceProvider is not null)
{
await ResetDatabaseAsync();
return;
}
var canonical = SnapshotSerializer.ToSnapshot(singleAdvisory!).Replace("\r\n", "\n");
var expected = ReadFixture("expected-advisory.json").Replace("\r\n", "\n");
if (!string.Equals(expected, canonical, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Jvn", "Fixtures", "expected-advisory.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, canonical);
}
Assert.Equal(expected, canonical);
var jpFlagStore = provider.GetRequiredService<IJpFlagStore>();
var jpFlag = await jpFlagStore.FindAsync(VulnId, CancellationToken.None);
Assert.NotNull(jpFlag);
Assert.Equal("product", jpFlag!.Category);
Assert.Equal("vulnerable", jpFlag.VendorStatus);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(JvnConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(JvnConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs));
Assert.Empty(pendingDocs.AsDocumentArray);
}
private async Task EnsureServiceProviderAsync(JvnOptions template)
{
if (_serviceProvider is not null)
{
await ResetDatabaseAsync();
return;
}
await _fixture.TruncateAllTablesAsync();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddJvnConnector(opts =>
{
opts.BaseEndpoint = template.BaseEndpoint;
opts.WindowSize = template.WindowSize;
opts.WindowOverlap = template.WindowOverlap;
opts.PageSize = template.PageSize;
opts.RequestDelay = TimeSpan.Zero;
});
services.Configure<HttpClientFactoryOptions>(JvnOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
services.AddSourceCommon();
services.AddJvnConnector(opts =>
{
opts.BaseEndpoint = template.BaseEndpoint;
opts.WindowSize = template.WindowSize;
opts.WindowOverlap = template.WindowOverlap;
opts.PageSize = template.PageSize;
opts.RequestDelay = TimeSpan.Zero;
});
services.Configure<HttpClientFactoryOptions>(JvnOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
_serviceProvider = services.BuildServiceProvider();
}
private Task ResetDatabaseAsync()
=> _fixture.TruncateAllTablesAsync();
private static Uri BuildOverviewUri(JvnOptions options, DateTimeOffset windowStart, DateTimeOffset windowEnd, int startItem)
{
var (startYear, startMonth, startDay) = ToTokyoDateParts(windowStart);
var (endYear, endMonth, endDay) = ToTokyoDateParts(windowEnd);
var parameters = new List<KeyValuePair<string, string>>
{
new("method", "getVulnOverviewList"),
new("feed", "hnd"),
new("lang", "en"),
new("rangeDatePublished", "n"),
new("rangeDatePublic", "n"),
new("rangeDateFirstPublished", "n"),
new("dateFirstPublishedStartY", startYear),
new("dateFirstPublishedStartM", startMonth),
new("dateFirstPublishedStartD", startDay),
new("dateFirstPublishedEndY", endYear),
new("dateFirstPublishedEndM", endMonth),
new("dateFirstPublishedEndD", endDay),
new("startItem", startItem.ToString(CultureInfo.InvariantCulture)),
new("maxCountItem", options.PageSize.ToString(CultureInfo.InvariantCulture)),
};
return BuildUri(options.BaseEndpoint, parameters);
}
private static Uri BuildDetailUri(JvnOptions options, string vulnId)
{
var parameters = new List<KeyValuePair<string, string>>
{
new("method", "getVulnDetailInfo"),
new("feed", "hnd"),
new("lang", "en"),
new("vulnId", vulnId),
};
return BuildUri(options.BaseEndpoint, parameters);
}
private static Uri BuildUri(Uri baseEndpoint, IEnumerable<KeyValuePair<string, string>> parameters)
{
var query = string.Join(
"&",
parameters.Select(parameter =>
$"{WebUtility.UrlEncode(parameter.Key)}={WebUtility.UrlEncode(parameter.Value)}"));
var builder = new UriBuilder(baseEndpoint)
{
Query = query,
};
return builder.Uri;
}
private static (string Year, string Month, string Day) ToTokyoDateParts(DateTimeOffset timestamp)
{
var local = timestamp.ToOffset(TimeSpan.FromHours(9)).Date;
return (
local.Year.ToString("D4", CultureInfo.InvariantCulture),
local.Month.ToString("D2", CultureInfo.InvariantCulture),
local.Day.ToString("D2", CultureInfo.InvariantCulture));
}
private static string ReadFixture(string filename)
{
var path = ResolveFixturePath(filename);
return File.ReadAllText(path);
}
private static string ResolveFixturePath(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "Jvn", "Fixtures", filename);
if (File.Exists(primary))
{
return primary;
}
return Path.Combine(baseDirectory, "Jvn", "Fixtures", filename);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
_serviceProvider?.Dispose();
}
}
}
private static Uri BuildOverviewUri(JvnOptions options, DateTimeOffset windowStart, DateTimeOffset windowEnd, int startItem)
{
var (startYear, startMonth, startDay) = ToTokyoDateParts(windowStart);
var (endYear, endMonth, endDay) = ToTokyoDateParts(windowEnd);
var parameters = new List<KeyValuePair<string, string>>
{
new("method", "getVulnOverviewList"),
new("feed", "hnd"),
new("lang", "en"),
new("rangeDatePublished", "n"),
new("rangeDatePublic", "n"),
new("rangeDateFirstPublished", "n"),
new("dateFirstPublishedStartY", startYear),
new("dateFirstPublishedStartM", startMonth),
new("dateFirstPublishedStartD", startDay),
new("dateFirstPublishedEndY", endYear),
new("dateFirstPublishedEndM", endMonth),
new("dateFirstPublishedEndD", endDay),
new("startItem", startItem.ToString(CultureInfo.InvariantCulture)),
new("maxCountItem", options.PageSize.ToString(CultureInfo.InvariantCulture)),
};
return BuildUri(options.BaseEndpoint, parameters);
}
private static Uri BuildDetailUri(JvnOptions options, string vulnId)
{
var parameters = new List<KeyValuePair<string, string>>
{
new("method", "getVulnDetailInfo"),
new("feed", "hnd"),
new("lang", "en"),
new("vulnId", vulnId),
};
return BuildUri(options.BaseEndpoint, parameters);
}
private static Uri BuildUri(Uri baseEndpoint, IEnumerable<KeyValuePair<string, string>> parameters)
{
var query = string.Join(
"&",
parameters.Select(parameter =>
$"{WebUtility.UrlEncode(parameter.Key)}={WebUtility.UrlEncode(parameter.Value)}"));
var builder = new UriBuilder(baseEndpoint)
{
Query = query,
};
return builder.Uri;
}
private static (string Year, string Month, string Day) ToTokyoDateParts(DateTimeOffset timestamp)
{
var local = timestamp.ToOffset(TimeSpan.FromHours(9)).Date;
return (
local.Year.ToString("D4", CultureInfo.InvariantCulture),
local.Month.ToString("D2", CultureInfo.InvariantCulture),
local.Day.ToString("D2", CultureInfo.InvariantCulture));
}
private static string ReadFixture(string filename)
{
var path = ResolveFixturePath(filename);
return File.ReadAllText(path);
}
private static string ResolveFixturePath(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "Jvn", "Fixtures", filename);
if (File.Exists(primary))
{
return primary;
}
return Path.Combine(baseDirectory, "Jvn", "Fixtures", filename);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
if (_serviceProvider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
_serviceProvider?.Dispose();
}
}
}

View File

@@ -1,13 +1,13 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
@@ -19,64 +19,64 @@ using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.Kev.Tests;
namespace StellaOps.Concelier.Connector.Kev.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class KevConnectorTests : IAsyncLifetime
{
private static readonly Uri FeedUri = new("https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json");
private const string CatalogEtag = "\"kev-2025-10-09\"";
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
public KevConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesDeterministicSnapshot()
{
await using var provider = await BuildServiceProviderAsync();
SeedCatalogResponse();
var connector = provider.GetRequiredService<KevConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.NotEmpty(advisories);
var ordered = advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray();
var snapshot = SnapshotSerializer.ToSnapshot(ordered);
WriteOrAssertSnapshot(snapshot, "kev-advisories.snapshot.json");
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(KevConnectorPlugin.SourceName, FeedUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
SeedNotModifiedResponse();
await connector.FetchAsync(provider, CancellationToken.None);
_handler.AssertNoPendingResponses();
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(KevConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.Equal("2025.10.09", state!.Cursor.TryGetValue("catalogVersion", out var versionValue) ? versionValue.AsString : null);
Assert.True(state.Cursor.TryGetValue("catalogReleased", out var releasedValue) && releasedValue.BsonType is BsonType.DateTime);
Assert.True(IsEmptyArray(state.Cursor, "pendingDocuments"));
Assert.True(IsEmptyArray(state.Cursor, "pendingMappings"));
}
public sealed class KevConnectorTests : IAsyncLifetime
{
private static readonly Uri FeedUri = new("https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json");
private const string CatalogEtag = "\"kev-2025-10-09\"";
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
public KevConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesDeterministicSnapshot()
{
await using var provider = await BuildServiceProviderAsync();
SeedCatalogResponse();
var connector = provider.GetRequiredService<KevConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.NotEmpty(advisories);
var ordered = advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray();
var snapshot = SnapshotSerializer.ToSnapshot(ordered);
WriteOrAssertSnapshot(snapshot, "kev-advisories.snapshot.json");
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(KevConnectorPlugin.SourceName, FeedUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
SeedNotModifiedResponse();
await connector.FetchAsync(provider, CancellationToken.None);
_handler.AssertNoPendingResponses();
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(KevConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.Equal("2025.10.09", state!.Cursor.TryGetValue("catalogVersion", out var versionValue) ? versionValue.AsString : null);
Assert.True(state.Cursor.TryGetValue("catalogReleased", out var releasedValue) && releasedValue.DocumentType is DocumentType.DateTime);
Assert.True(IsEmptyArray(state.Cursor, "pendingDocuments"));
Assert.True(IsEmptyArray(state.Cursor, "pendingMappings"));
}
private async Task<ServiceProvider> BuildServiceProviderAsync()
{
await _fixture.TruncateAllTablesAsync(CancellationToken.None);
@@ -96,118 +96,118 @@ public sealed class KevConnectorTests : IAsyncLifetime
services.AddSourceCommon();
services.AddKevConnector(options =>
{
options.FeedUri = FeedUri;
options.RequestTimeout = TimeSpan.FromSeconds(10);
});
services.Configure<HttpClientFactoryOptions>(KevOptions.HttpClientName, builderOptions =>
{
options.FeedUri = FeedUri;
options.RequestTimeout = TimeSpan.FromSeconds(10);
});
services.Configure<HttpClientFactoryOptions>(KevOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
});
return services.BuildServiceProvider();
}
private void SeedCatalogResponse()
{
var payload = ReadFixture("kev-catalog.json");
_handler.AddResponse(FeedUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue(CatalogEtag);
response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 9, 16, 52, 28, TimeSpan.Zero);
return response;
});
}
private void SeedNotModifiedResponse()
{
_handler.AddResponse(FeedUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = new EntityTagHeaderValue(CatalogEtag);
return response;
});
}
private static bool IsEmptyArray(BsonDocument document, string field)
{
if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
{
return false;
}
return array.Count == 0;
}
private static string ReadFixture(string filename)
{
var path = GetExistingFixturePath(filename);
return File.ReadAllText(path);
}
private static void WriteOrAssertSnapshot(string snapshot, string filename)
{
if (ShouldUpdateFixtures())
{
var target = GetWritableFixturePath(filename);
File.WriteAllText(target, snapshot);
return;
}
var expected = ReadFixture(filename);
var normalizedExpected = Normalize(expected);
var normalizedSnapshot = Normalize(snapshot);
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(Path.GetDirectoryName(GetWritableFixturePath(filename))!, Path.GetFileNameWithoutExtension(filename) + ".actual.json");
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(normalizedExpected, normalizedSnapshot);
}
private static bool ShouldUpdateFixtures()
{
var value = Environment.GetEnvironmentVariable("UPDATE_KEV_FIXTURES");
return string.Equals(value, "1", StringComparison.Ordinal) || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
private static string GetExistingFixturePath(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primary = Path.Combine(baseDir, "Source", "Kev", "Fixtures", filename);
if (File.Exists(primary))
{
return primary;
}
var fallback = Path.Combine(baseDir, "Kev", "Fixtures", filename);
if (File.Exists(fallback))
{
return fallback;
}
throw new FileNotFoundException($"Unable to locate KEV fixture '{filename}'.");
}
private static string GetWritableFixturePath(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primaryDir = Path.Combine(baseDir, "Source", "Kev", "Fixtures");
Directory.CreateDirectory(primaryDir);
return Path.Combine(primaryDir, filename);
}
public Task InitializeAsync() => Task.CompletedTask;
private void SeedCatalogResponse()
{
var payload = ReadFixture("kev-catalog.json");
_handler.AddResponse(FeedUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
response.Headers.ETag = new EntityTagHeaderValue(CatalogEtag);
response.Content.Headers.LastModified = new DateTimeOffset(2025, 10, 9, 16, 52, 28, TimeSpan.Zero);
return response;
});
}
private void SeedNotModifiedResponse()
{
_handler.AddResponse(FeedUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
response.Headers.ETag = new EntityTagHeaderValue(CatalogEtag);
return response;
});
}
private static bool IsEmptyArray(DocumentObject document, string field)
{
if (!document.TryGetValue(field, out var value) || value is not DocumentArray array)
{
return false;
}
return array.Count == 0;
}
private static string ReadFixture(string filename)
{
var path = GetExistingFixturePath(filename);
return File.ReadAllText(path);
}
private static void WriteOrAssertSnapshot(string snapshot, string filename)
{
if (ShouldUpdateFixtures())
{
var target = GetWritableFixturePath(filename);
File.WriteAllText(target, snapshot);
return;
}
var expected = ReadFixture(filename);
var normalizedExpected = Normalize(expected);
var normalizedSnapshot = Normalize(snapshot);
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(Path.GetDirectoryName(GetWritableFixturePath(filename))!, Path.GetFileNameWithoutExtension(filename) + ".actual.json");
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(normalizedExpected, normalizedSnapshot);
}
private static bool ShouldUpdateFixtures()
{
var value = Environment.GetEnvironmentVariable("UPDATE_KEV_FIXTURES");
return string.Equals(value, "1", StringComparison.Ordinal) || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
private static string GetExistingFixturePath(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primary = Path.Combine(baseDir, "Source", "Kev", "Fixtures", filename);
if (File.Exists(primary))
{
return primary;
}
var fallback = Path.Combine(baseDir, "Kev", "Fixtures", filename);
if (File.Exists(fallback))
{
return fallback;
}
throw new FileNotFoundException($"Unable to locate KEV fixture '{filename}'.");
}
private static string GetWritableFixturePath(string filename)
{
var baseDir = AppContext.BaseDirectory;
var primaryDir = Path.Combine(baseDir, "Source", "Kev", "Fixtures");
Directory.CreateDirectory(primaryDir);
return Path.Combine(primaryDir, filename);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await _fixture.TruncateAllTablesAsync(CancellationToken.None);

View File

@@ -1,93 +1,93 @@
using System;
using System.Linq;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Kev;
using StellaOps.Concelier.Connector.Kev.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Kev.Tests;
public sealed class KevMapperTests
{
[Fact]
public void Map_BuildsVendorRangePrimitivesWithDueDate()
{
var catalog = new KevCatalogDto
{
CatalogVersion = "2025.10.09",
DateReleased = new DateTimeOffset(2025, 10, 9, 16, 52, 28, TimeSpan.Zero),
Vulnerabilities = new[]
{
new KevVulnerabilityDto
{
CveId = "CVE-2021-43798",
VendorProject = "Grafana Labs",
Product = "Grafana",
VulnerabilityName = "Grafana Path Traversal Vulnerability",
DateAdded = "2025-10-09",
ShortDescription = "Grafana contains a path traversal vulnerability that could allow access to local files.",
RequiredAction = "Apply mitigations per vendor instructions or discontinue use.",
DueDate = "2025-10-30",
KnownRansomwareCampaignUse = "Unknown",
Notes = "https://grafana.com/security/advisory; https://nvd.nist.gov/vuln/detail/CVE-2021-43798",
Cwes = new[] { "CWE-22" }
}
}
};
var feedUri = new Uri("https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json");
var fetchedAt = new DateTimeOffset(2025, 10, 9, 17, 0, 0, TimeSpan.Zero);
var validatedAt = fetchedAt.AddMinutes(1);
var advisories = KevMapper.Map(catalog, KevConnectorPlugin.SourceName, feedUri, fetchedAt, validatedAt);
var advisory = Assert.Single(advisories);
Assert.True(advisory.ExploitKnown);
Assert.Contains("cve-2021-43798", advisory.Aliases, StringComparer.OrdinalIgnoreCase);
var affected = Assert.Single(advisory.AffectedPackages);
Assert.Equal(AffectedPackageTypes.Vendor, affected.Type);
Assert.Equal("Grafana Labs::Grafana", affected.Identifier);
Assert.Collection(
affected.NormalizedVersions,
rule =>
{
Assert.Equal("kev.catalog", rule.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.Exact, rule.Type);
Assert.Equal("2025.10.09", rule.Value);
Assert.Equal("Grafana Labs::Grafana", rule.Notes);
},
rule =>
{
Assert.Equal("kev.date-added", rule.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.Exact, rule.Type);
Assert.Equal("2025-10-09", rule.Value);
},
rule =>
{
Assert.Equal("kev.due-date", rule.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThanOrEqual, rule.Type);
Assert.Equal("2025-10-30", rule.Max);
Assert.True(rule.MaxInclusive);
});
var range = Assert.Single(affected.VersionRanges);
Assert.Equal(AffectedPackageTypes.Vendor, range.RangeKind);
var primitives = range.Primitives;
Assert.NotNull(primitives);
Assert.True(primitives!.HasVendorExtensions);
var extensions = primitives!.VendorExtensions!;
Assert.Equal("Grafana Labs", extensions["kev.vendorProject"]);
Assert.Equal("Grafana", extensions["kev.product"]);
Assert.Equal("2025-10-30", extensions["kev.dueDate"]);
Assert.Equal("Unknown", extensions["kev.knownRansomwareCampaignUse"]);
Assert.Equal("CWE-22", extensions["kev.cwe"]);
var references = advisory.References.Select(reference => reference.Url).ToArray();
Assert.Contains("https://grafana.com/security/advisory", references);
Assert.Contains("https://nvd.nist.gov/vuln/detail/CVE-2021-43798", references);
Assert.Contains("https://www.cisa.gov/known-exploited-vulnerabilities-catalog?search=CVE-2021-43798", references);
}
}
using System;
using System.Linq;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Kev;
using StellaOps.Concelier.Connector.Kev.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Kev.Tests;
public sealed class KevMapperTests
{
[Fact]
public void Map_BuildsVendorRangePrimitivesWithDueDate()
{
var catalog = new KevCatalogDto
{
CatalogVersion = "2025.10.09",
DateReleased = new DateTimeOffset(2025, 10, 9, 16, 52, 28, TimeSpan.Zero),
Vulnerabilities = new[]
{
new KevVulnerabilityDto
{
CveId = "CVE-2021-43798",
VendorProject = "Grafana Labs",
Product = "Grafana",
VulnerabilityName = "Grafana Path Traversal Vulnerability",
DateAdded = "2025-10-09",
ShortDescription = "Grafana contains a path traversal vulnerability that could allow access to local files.",
RequiredAction = "Apply mitigations per vendor instructions or discontinue use.",
DueDate = "2025-10-30",
KnownRansomwareCampaignUse = "Unknown",
Notes = "https://grafana.com/security/advisory; https://nvd.nist.gov/vuln/detail/CVE-2021-43798",
Cwes = new[] { "CWE-22" }
}
}
};
var feedUri = new Uri("https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json");
var fetchedAt = new DateTimeOffset(2025, 10, 9, 17, 0, 0, TimeSpan.Zero);
var validatedAt = fetchedAt.AddMinutes(1);
var advisories = KevMapper.Map(catalog, KevConnectorPlugin.SourceName, feedUri, fetchedAt, validatedAt);
var advisory = Assert.Single(advisories);
Assert.True(advisory.ExploitKnown);
Assert.Contains("cve-2021-43798", advisory.Aliases, StringComparer.OrdinalIgnoreCase);
var affected = Assert.Single(advisory.AffectedPackages);
Assert.Equal(AffectedPackageTypes.Vendor, affected.Type);
Assert.Equal("Grafana Labs::Grafana", affected.Identifier);
Assert.Collection(
affected.NormalizedVersions,
rule =>
{
Assert.Equal("kev.catalog", rule.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.Exact, rule.Type);
Assert.Equal("2025.10.09", rule.Value);
Assert.Equal("Grafana Labs::Grafana", rule.Notes);
},
rule =>
{
Assert.Equal("kev.date-added", rule.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.Exact, rule.Type);
Assert.Equal("2025-10-09", rule.Value);
},
rule =>
{
Assert.Equal("kev.due-date", rule.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThanOrEqual, rule.Type);
Assert.Equal("2025-10-30", rule.Max);
Assert.True(rule.MaxInclusive);
});
var range = Assert.Single(affected.VersionRanges);
Assert.Equal(AffectedPackageTypes.Vendor, range.RangeKind);
var primitives = range.Primitives;
Assert.NotNull(primitives);
Assert.True(primitives!.HasVendorExtensions);
var extensions = primitives!.VendorExtensions!;
Assert.Equal("Grafana Labs", extensions["kev.vendorProject"]);
Assert.Equal("Grafana", extensions["kev.product"]);
Assert.Equal("2025-10-30", extensions["kev.dueDate"]);
Assert.Equal("Unknown", extensions["kev.knownRansomwareCampaignUse"]);
Assert.Equal("CWE-22", extensions["kev.cwe"]);
var references = advisory.References.Select(reference => reference.Url).ToArray();
Assert.Contains("https://grafana.com/security/advisory", references);
Assert.Contains("https://nvd.nist.gov/vuln/detail/CVE-2021-43798", references);
Assert.Contains("https://www.cisa.gov/known-exploited-vulnerabilities-catalog?search=CVE-2021-43798", references);
}
}

View File

@@ -14,7 +14,7 @@ using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
@@ -99,9 +99,9 @@ public sealed class KisaConnectorTests : IAsyncLifetime
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
pendingDocs!.AsBsonArray.Should().BeEmpty();
pendingDocs!.AsDocumentArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();
pendingMappings!.AsBsonArray.Should().BeEmpty();
pendingMappings!.AsDocumentArray.Should().BeEmpty();
}
[Fact]

View File

@@ -1,136 +1,136 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Nvd;
using StellaOps.Concelier.Connector.Nvd.Configuration;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Testing;
using StellaOps.Concelier.Testing;
using System.Net;
namespace StellaOps.Concelier.Connector.Nvd.Tests;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Nvd;
using StellaOps.Concelier.Connector.Nvd.Configuration;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Testing;
using StellaOps.Concelier.Testing;
using System.Net;
namespace StellaOps.Concelier.Connector.Nvd.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class NvdConnectorHarnessTests : IAsyncLifetime
{
private readonly ConnectorTestHarness _harness;
public NvdConnectorHarnessTests(ConcelierPostgresFixture fixture)
{
_harness = new ConnectorTestHarness(fixture, new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero), NvdOptions.HttpClientName);
}
[Fact]
public async Task FetchAsync_MultiPagePersistsStartIndexMetadata()
{
await _harness.ResetAsync();
var options = new NvdOptions
{
BaseEndpoint = new Uri("https://nvd.example.test/api"),
WindowSize = TimeSpan.FromHours(1),
WindowOverlap = TimeSpan.FromMinutes(5),
InitialBackfill = TimeSpan.FromHours(2),
};
var timeProvider = _harness.TimeProvider;
var handler = _harness.Handler;
var windowStart = timeProvider.GetUtcNow() - options.InitialBackfill;
var windowEnd = windowStart + options.WindowSize;
var firstUri = BuildRequestUri(options, windowStart, windowEnd);
var secondUri = BuildRequestUri(options, windowStart, windowEnd, startIndex: 2);
var thirdUri = BuildRequestUri(options, windowStart, windowEnd, startIndex: 4);
handler.AddJsonResponse(firstUri, ReadFixture("nvd-multipage-1.json"));
handler.AddJsonResponse(secondUri, ReadFixture("nvd-multipage-2.json"));
handler.AddJsonResponse(thirdUri, ReadFixture("nvd-multipage-3.json"));
await _harness.EnsureServiceProviderAsync(services =>
{
services.AddNvdConnector(opts =>
{
opts.BaseEndpoint = options.BaseEndpoint;
opts.WindowSize = options.WindowSize;
opts.WindowOverlap = options.WindowOverlap;
opts.InitialBackfill = options.InitialBackfill;
});
});
var provider = _harness.ServiceProvider;
var connector = new NvdConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var firstDocument = await documentStore.FindBySourceAndUriAsync(NvdConnectorPlugin.SourceName, firstUri.ToString(), CancellationToken.None);
Assert.NotNull(firstDocument);
Assert.Equal("0", firstDocument!.Metadata["startIndex"]);
var secondDocument = await documentStore.FindBySourceAndUriAsync(NvdConnectorPlugin.SourceName, secondUri.ToString(), CancellationToken.None);
Assert.NotNull(secondDocument);
Assert.Equal("2", secondDocument!.Metadata["startIndex"]);
var thirdDocument = await documentStore.FindBySourceAndUriAsync(NvdConnectorPlugin.SourceName, thirdUri.ToString(), CancellationToken.None);
Assert.NotNull(thirdDocument);
Assert.Equal("4", thirdDocument!.Metadata["startIndex"]);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(NvdConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pending)
? pending.AsBsonArray
: new BsonArray();
Assert.Equal(3, pendingDocuments.Count);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => _harness.ResetAsync();
private static Uri BuildRequestUri(NvdOptions options, DateTimeOffset start, DateTimeOffset end, int startIndex = 0)
{
var builder = new UriBuilder(options.BaseEndpoint);
var parameters = new Dictionary<string, string>
{
["lastModifiedStartDate"] = start.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK"),
["lastModifiedEndDate"] = end.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK"),
["resultsPerPage"] = "2000",
};
if (startIndex > 0)
{
parameters["startIndex"] = startIndex.ToString(CultureInfo.InvariantCulture);
}
builder.Query = string.Join("&", parameters.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
return builder.Uri;
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "Nvd", "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var secondary = Path.Combine(baseDirectory, "Nvd", "Fixtures", filename);
if (File.Exists(secondary))
{
return File.ReadAllText(secondary);
}
throw new FileNotFoundException($"Fixture '{filename}' was not found in the test output directory.");
}
}
public sealed class NvdConnectorHarnessTests : IAsyncLifetime
{
private readonly ConnectorTestHarness _harness;
public NvdConnectorHarnessTests(ConcelierPostgresFixture fixture)
{
_harness = new ConnectorTestHarness(fixture, new DateTimeOffset(2024, 1, 2, 12, 0, 0, TimeSpan.Zero), NvdOptions.HttpClientName);
}
[Fact]
public async Task FetchAsync_MultiPagePersistsStartIndexMetadata()
{
await _harness.ResetAsync();
var options = new NvdOptions
{
BaseEndpoint = new Uri("https://nvd.example.test/api"),
WindowSize = TimeSpan.FromHours(1),
WindowOverlap = TimeSpan.FromMinutes(5),
InitialBackfill = TimeSpan.FromHours(2),
};
var timeProvider = _harness.TimeProvider;
var handler = _harness.Handler;
var windowStart = timeProvider.GetUtcNow() - options.InitialBackfill;
var windowEnd = windowStart + options.WindowSize;
var firstUri = BuildRequestUri(options, windowStart, windowEnd);
var secondUri = BuildRequestUri(options, windowStart, windowEnd, startIndex: 2);
var thirdUri = BuildRequestUri(options, windowStart, windowEnd, startIndex: 4);
handler.AddJsonResponse(firstUri, ReadFixture("nvd-multipage-1.json"));
handler.AddJsonResponse(secondUri, ReadFixture("nvd-multipage-2.json"));
handler.AddJsonResponse(thirdUri, ReadFixture("nvd-multipage-3.json"));
await _harness.EnsureServiceProviderAsync(services =>
{
services.AddNvdConnector(opts =>
{
opts.BaseEndpoint = options.BaseEndpoint;
opts.WindowSize = options.WindowSize;
opts.WindowOverlap = options.WindowOverlap;
opts.InitialBackfill = options.InitialBackfill;
});
});
var provider = _harness.ServiceProvider;
var connector = new NvdConnectorPlugin().Create(provider);
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var firstDocument = await documentStore.FindBySourceAndUriAsync(NvdConnectorPlugin.SourceName, firstUri.ToString(), CancellationToken.None);
Assert.NotNull(firstDocument);
Assert.Equal("0", firstDocument!.Metadata["startIndex"]);
var secondDocument = await documentStore.FindBySourceAndUriAsync(NvdConnectorPlugin.SourceName, secondUri.ToString(), CancellationToken.None);
Assert.NotNull(secondDocument);
Assert.Equal("2", secondDocument!.Metadata["startIndex"]);
var thirdDocument = await documentStore.FindBySourceAndUriAsync(NvdConnectorPlugin.SourceName, thirdUri.ToString(), CancellationToken.None);
Assert.NotNull(thirdDocument);
Assert.Equal("4", thirdDocument!.Metadata["startIndex"]);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(NvdConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pending)
? pending.AsDocumentArray
: new DocumentArray();
Assert.Equal(3, pendingDocuments.Count);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => _harness.ResetAsync();
private static Uri BuildRequestUri(NvdOptions options, DateTimeOffset start, DateTimeOffset end, int startIndex = 0)
{
var builder = new UriBuilder(options.BaseEndpoint);
var parameters = new Dictionary<string, string>
{
["lastModifiedStartDate"] = start.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK"),
["lastModifiedEndDate"] = end.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK"),
["resultsPerPage"] = "2000",
};
if (startIndex > 0)
{
parameters["startIndex"] = startIndex.ToString(CultureInfo.InvariantCulture);
}
builder.Query = string.Join("&", parameters.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
return builder.Uri;
}
private static string ReadFixture(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "Nvd", "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var secondary = Path.Combine(baseDirectory, "Nvd", "Fixtures", filename);
if (File.Exists(secondary))
{
return File.ReadAllText(secondary);
}
throw new FileNotFoundException($"Fixture '{filename}' was not found in the test output directory.");
}
}

View File

@@ -1,98 +1,98 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Concelier.Core;
using StellaOps.Concelier.Exporter.Json;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Connector.Nvd.Tests.Nvd;
public sealed class NvdMergeExportParityTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task CanonicalMerge_PreservesCreditsAndReferences_ExporterMaintainsParity()
{
var ghsa = LoadFixture("credit-parity.ghsa.json");
var osv = LoadFixture("credit-parity.osv.json");
var nvd = LoadFixture("credit-parity.nvd.json");
var merger = new CanonicalMerger();
var result = merger.Merge("CVE-2025-5555", ghsa, nvd, osv);
var merged = result.Advisory;
Assert.NotNull(merged);
var creditKeys = merged!.Credits
.Select(static credit => $"{credit.Role}|{credit.DisplayName}|{string.Join("|", credit.Contacts.OrderBy(static c => c, StringComparer.Ordinal))}")
.ToHashSet(StringComparer.Ordinal);
Assert.Equal(2, creditKeys.Count);
Assert.Contains("reporter|Alice Researcher|mailto:alice.researcher@example.com", creditKeys);
Assert.Contains("remediation_developer|Bob Maintainer|https://github.com/acme/bob-maintainer", creditKeys);
var referenceUrls = merged.References.Select(static reference => reference.Url).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Equal(5, referenceUrls.Count);
Assert.Contains($"https://github.com/advisories/GHSA-credit-parity", referenceUrls);
Assert.Contains("https://example.com/ghsa/patch", referenceUrls);
Assert.Contains($"https://osv.dev/vulnerability/GHSA-credit-parity", referenceUrls);
Assert.Contains($"https://services.nvd.nist.gov/vuln/detail/CVE-2025-5555", referenceUrls);
Assert.Contains("https://example.com/nvd/reference", referenceUrls);
using var tempDirectory = new TempDirectory();
var options = new JsonExportOptions { OutputRoot = tempDirectory.Path };
var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
var exportResult = await builder.WriteAsync(new[] { merged }, new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero));
Assert.Single(exportResult.Files);
var exportFile = exportResult.Files[0];
var exportPath = Path.Combine(exportResult.ExportDirectory, exportFile.RelativePath.Replace('/', Path.DirectorySeparatorChar));
Assert.True(File.Exists(exportPath));
var exported = JsonSerializer.Deserialize<Advisory>(await File.ReadAllTextAsync(exportPath), SerializerOptions);
Assert.NotNull(exported);
var exportedCredits = exported!.Credits
.Select(static credit => $"{credit.Role}|{credit.DisplayName}|{string.Join("|", credit.Contacts.OrderBy(static c => c, StringComparer.Ordinal))}")
.ToHashSet(StringComparer.Ordinal);
Assert.Equal(creditKeys, exportedCredits);
var exportedReferences = exported.References.Select(static reference => reference.Url).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Equal(referenceUrls, exportedReferences);
}
private static Advisory LoadFixture(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "Nvd", "Fixtures", fileName);
return JsonSerializer.Deserialize<Advisory>(File.ReadAllText(path), SerializerOptions)
?? throw new InvalidOperationException($"Failed to deserialize fixture '{fileName}'.");
}
private sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = Directory.CreateTempSubdirectory("nvd-merge-export").FullName;
}
public string Path { get; }
public void Dispose()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
// best effort cleanup
}
}
}
}
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Concelier.Core;
using StellaOps.Concelier.Exporter.Json;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Connector.Nvd.Tests.Nvd;
public sealed class NvdMergeExportParityTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task CanonicalMerge_PreservesCreditsAndReferences_ExporterMaintainsParity()
{
var ghsa = LoadFixture("credit-parity.ghsa.json");
var osv = LoadFixture("credit-parity.osv.json");
var nvd = LoadFixture("credit-parity.nvd.json");
var merger = new CanonicalMerger();
var result = merger.Merge("CVE-2025-5555", ghsa, nvd, osv);
var merged = result.Advisory;
Assert.NotNull(merged);
var creditKeys = merged!.Credits
.Select(static credit => $"{credit.Role}|{credit.DisplayName}|{string.Join("|", credit.Contacts.OrderBy(static c => c, StringComparer.Ordinal))}")
.ToHashSet(StringComparer.Ordinal);
Assert.Equal(2, creditKeys.Count);
Assert.Contains("reporter|Alice Researcher|mailto:alice.researcher@example.com", creditKeys);
Assert.Contains("remediation_developer|Bob Maintainer|https://github.com/acme/bob-maintainer", creditKeys);
var referenceUrls = merged.References.Select(static reference => reference.Url).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Equal(5, referenceUrls.Count);
Assert.Contains($"https://github.com/advisories/GHSA-credit-parity", referenceUrls);
Assert.Contains("https://example.com/ghsa/patch", referenceUrls);
Assert.Contains($"https://osv.dev/vulnerability/GHSA-credit-parity", referenceUrls);
Assert.Contains($"https://services.nvd.nist.gov/vuln/detail/CVE-2025-5555", referenceUrls);
Assert.Contains("https://example.com/nvd/reference", referenceUrls);
using var tempDirectory = new TempDirectory();
var options = new JsonExportOptions { OutputRoot = tempDirectory.Path };
var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
var exportResult = await builder.WriteAsync(new[] { merged }, new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero));
Assert.Single(exportResult.Files);
var exportFile = exportResult.Files[0];
var exportPath = Path.Combine(exportResult.ExportDirectory, exportFile.RelativePath.Replace('/', Path.DirectorySeparatorChar));
Assert.True(File.Exists(exportPath));
var exported = JsonSerializer.Deserialize<Advisory>(await File.ReadAllTextAsync(exportPath), SerializerOptions);
Assert.NotNull(exported);
var exportedCredits = exported!.Credits
.Select(static credit => $"{credit.Role}|{credit.DisplayName}|{string.Join("|", credit.Contacts.OrderBy(static c => c, StringComparer.Ordinal))}")
.ToHashSet(StringComparer.Ordinal);
Assert.Equal(creditKeys, exportedCredits);
var exportedReferences = exported.References.Select(static reference => reference.Url).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Equal(referenceUrls, exportedReferences);
}
private static Advisory LoadFixture(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "Nvd", "Fixtures", fileName);
return JsonSerializer.Deserialize<Advisory>(File.ReadAllText(path), SerializerOptions)
?? throw new InvalidOperationException($"Failed to deserialize fixture '{fileName}'.");
}
private sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = Directory.CreateTempSubdirectory("nvd-merge-export").FullName;
}
public string Path { get; }
public void Dispose()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
// best effort cleanup
}
}
}
}

View File

@@ -1,5 +1,5 @@
using System.Text.Json;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Osv.Internal;
using StellaOps.Concelier.Storage;
@@ -99,7 +99,7 @@ public sealed class OsvConflictFixtureTests
SourceName: OsvConnectorPlugin.SourceName,
Format: "osv.v1",
SchemaVersion: "osv.v1",
Payload: new BsonDocument("id", dto.Id),
Payload: new DocumentObject("id", dto.Id),
CreatedAt: new DateTimeOffset(2025, 3, 6, 12, 0, 0, TimeSpan.Zero),
ValidatedAt: new DateTimeOffset(2025, 3, 6, 12, 5, 0, TimeSpan.Zero));

View File

@@ -1,240 +1,240 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Reflection;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Osv;
using StellaOps.Concelier.Connector.Osv.Internal;
using StellaOps.Concelier.Normalization.Identifiers;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using Xunit;
namespace StellaOps.Concelier.Connector.Osv.Tests;
public sealed class OsvMapperTests
{
[Fact]
public void Map_NormalizesAliasesReferencesAndRanges()
{
var published = DateTimeOffset.UtcNow.AddDays(-2);
var modified = DateTimeOffset.UtcNow.AddDays(-1);
using var databaseSpecificJson = JsonDocument.Parse("{}");
using var ecosystemSpecificJson = JsonDocument.Parse("{}");
var dto = new OsvVulnerabilityDto
{
Id = "OSV-2025-TEST",
Summary = "Test summary",
Details = "Longer details for the advisory.",
Published = published,
Modified = modified,
Aliases = new[] { "CVE-2025-0001", "CVE-2025-0001", "GHSA-xxxx" },
Related = new[] { "CVE-2025-0002" },
References = new[]
{
new OsvReferenceDto { Url = "https://example.com/advisory", Type = "ADVISORY" },
new OsvReferenceDto { Url = "https://example.com/advisory", Type = "ADVISORY" },
new OsvReferenceDto { Url = "https://example.com/patch", Type = "PATCH" },
},
DatabaseSpecific = databaseSpecificJson.RootElement,
Severity = new[]
{
new OsvSeverityDto { Type = "CVSS_V3", Score = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" },
},
Affected = new[]
{
new OsvAffectedPackageDto
{
Package = new OsvPackageDto
{
Ecosystem = "PyPI",
Name = "example",
Purl = "pkg:pypi/example",
},
Ranges = new[]
{
new OsvRangeDto
{
Type = "SEMVER",
Events = new[]
{
new OsvEventDto { Introduced = "0" },
new OsvEventDto { Fixed = "1.0.1" },
}
}
},
EcosystemSpecific = ecosystemSpecificJson.RootElement,
}
}
};
var document = new DocumentRecord(
Guid.NewGuid(),
OsvConnectorPlugin.SourceName,
"https://osv.dev/vulnerability/OSV-2025-TEST",
DateTimeOffset.UtcNow,
"sha256",
DocumentStatuses.PendingParse,
"application/json",
null,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["osv.ecosystem"] = "PyPI",
},
null,
modified,
null,
null);
var payload = BsonDocument.Parse(JsonSerializer.Serialize(dto, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
}));
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, OsvConnectorPlugin.SourceName, "osv.v1", payload, DateTimeOffset.UtcNow);
var advisory = OsvMapper.Map(dto, document, dtoRecord, "PyPI");
Assert.Equal(dto.Id, advisory.AdvisoryKey);
Assert.Contains("CVE-2025-0002", advisory.Aliases);
Assert.Equal(4, advisory.Aliases.Length);
Assert.Equal(2, advisory.References.Length);
Assert.Equal("https://example.com/advisory", advisory.References[0].Url);
Assert.Equal("https://example.com/patch", advisory.References[1].Url);
Assert.Single(advisory.AffectedPackages);
var affected = advisory.AffectedPackages[0];
Assert.Equal(AffectedPackageTypes.SemVer, affected.Type);
Assert.Single(affected.VersionRanges);
Assert.Equal("0", affected.VersionRanges[0].IntroducedVersion);
Assert.Equal("1.0.1", affected.VersionRanges[0].FixedVersion);
var semver = affected.VersionRanges[0].Primitives?.SemVer;
Assert.NotNull(semver);
Assert.Equal("0", semver!.Introduced);
Assert.True(semver.IntroducedInclusive);
Assert.Equal("1.0.1", semver.Fixed);
Assert.False(semver.FixedInclusive);
Assert.Single(advisory.CvssMetrics);
Assert.Equal("3.1", advisory.CvssMetrics[0].Version);
}
[Fact]
public void Map_AssignsSeverityFallbackWhenCvssVectorUnsupported()
{
using var databaseSpecificJson = JsonDocument.Parse("""
{
"severity": "MODERATE",
"cwe_ids": ["CWE-290"]
}
""");
var dto = new OsvVulnerabilityDto
{
Id = "OSV-CVSS4",
Summary = "Severity-only advisory",
Details = "OSV entry that lacks a parsable CVSS vector.",
Published = DateTimeOffset.UtcNow.AddDays(-10),
Modified = DateTimeOffset.UtcNow.AddDays(-5),
DatabaseSpecific = databaseSpecificJson.RootElement,
Severity = new[]
{
new OsvSeverityDto
{
Type = "CVSS_V4",
Score = "CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N"
}
}
};
var (document, dtoRecord) = CreateDocumentAndDtoRecord(dto, "PyPI");
var advisory = OsvMapper.Map(dto, document, dtoRecord, "PyPI");
Assert.True(advisory.CvssMetrics.IsEmpty);
Assert.Equal("medium", advisory.Severity);
Assert.Equal("osv:severity/medium", advisory.CanonicalMetricId);
var weakness = Assert.Single(advisory.Cwes);
var provenance = Assert.Single(weakness.Provenance);
Assert.Equal("database_specific.cwe_ids", provenance.DecisionReason);
}
[Theory]
[InlineData("Go", "github.com/example/project", "pkg:golang/github.com/example/project")]
[InlineData("PyPI", "social_auth_app_django", "pkg:pypi/social-auth-app-django")]
[InlineData("npm", "@Scope/Package", "pkg:npm/%40scope/package")]
[InlineData("Maven", "org.example:library", "pkg:maven/org.example/library")]
[InlineData("crates", "serde", "pkg:cargo/serde")]
public void Map_InfersCanonicalPackageUrlWhenPurlMissing(string ecosystem, string packageName, string expectedIdentifier)
{
var dto = new OsvVulnerabilityDto
{
Id = $"OSV-{ecosystem}-PURL",
Summary = "Test advisory",
Details = "Details",
Published = DateTimeOffset.UtcNow.AddDays(-1),
Modified = DateTimeOffset.UtcNow,
Affected = new[]
{
new OsvAffectedPackageDto
{
Package = new OsvPackageDto
{
Ecosystem = ecosystem,
Name = packageName,
Purl = null,
},
Ranges = null,
}
}
};
if (string.Equals(ecosystem, "npm", StringComparison.OrdinalIgnoreCase))
{
Assert.True(IdentifierNormalizer.TryNormalizePackageUrl("pkg:npm/%40scope/package", out var canonical));
Assert.Equal(expectedIdentifier, canonical);
}
var method = typeof(OsvMapper).GetMethod("DetermineIdentifier", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
var directIdentifier = method!.Invoke(null, new object?[] { dto.Affected![0].Package!, ecosystem }) as string;
Assert.Equal(expectedIdentifier, directIdentifier);
var (document, dtoRecord) = CreateDocumentAndDtoRecord(dto, ecosystem);
var advisory = OsvMapper.Map(dto, document, dtoRecord, ecosystem);
var affected = Assert.Single(advisory.AffectedPackages);
Assert.Equal(expectedIdentifier, affected.Identifier);
}
private static (DocumentRecord Document, DtoRecord DtoRecord) CreateDocumentAndDtoRecord(OsvVulnerabilityDto dto, string ecosystem)
{
var recordedAt = DateTimeOffset.UtcNow;
var document = new DocumentRecord(
Guid.NewGuid(),
OsvConnectorPlugin.SourceName,
$"https://osv.dev/vulnerability/{dto.Id}",
recordedAt,
"sha256",
DocumentStatuses.PendingParse,
"application/json",
null,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["osv.ecosystem"] = ecosystem,
},
null,
dto.Modified,
null,
null);
var payload = new BsonDocument("id", dto.Id);
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, OsvConnectorPlugin.SourceName, "osv.v1", payload, recordedAt);
return (document, dtoRecord);
}
}
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Reflection;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Osv;
using StellaOps.Concelier.Connector.Osv.Internal;
using StellaOps.Concelier.Normalization.Identifiers;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using Xunit;
namespace StellaOps.Concelier.Connector.Osv.Tests;
public sealed class OsvMapperTests
{
[Fact]
public void Map_NormalizesAliasesReferencesAndRanges()
{
var published = DateTimeOffset.UtcNow.AddDays(-2);
var modified = DateTimeOffset.UtcNow.AddDays(-1);
using var databaseSpecificJson = JsonDocument.Parse("{}");
using var ecosystemSpecificJson = JsonDocument.Parse("{}");
var dto = new OsvVulnerabilityDto
{
Id = "OSV-2025-TEST",
Summary = "Test summary",
Details = "Longer details for the advisory.",
Published = published,
Modified = modified,
Aliases = new[] { "CVE-2025-0001", "CVE-2025-0001", "GHSA-xxxx" },
Related = new[] { "CVE-2025-0002" },
References = new[]
{
new OsvReferenceDto { Url = "https://example.com/advisory", Type = "ADVISORY" },
new OsvReferenceDto { Url = "https://example.com/advisory", Type = "ADVISORY" },
new OsvReferenceDto { Url = "https://example.com/patch", Type = "PATCH" },
},
DatabaseSpecific = databaseSpecificJson.RootElement,
Severity = new[]
{
new OsvSeverityDto { Type = "CVSS_V3", Score = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" },
},
Affected = new[]
{
new OsvAffectedPackageDto
{
Package = new OsvPackageDto
{
Ecosystem = "PyPI",
Name = "example",
Purl = "pkg:pypi/example",
},
Ranges = new[]
{
new OsvRangeDto
{
Type = "SEMVER",
Events = new[]
{
new OsvEventDto { Introduced = "0" },
new OsvEventDto { Fixed = "1.0.1" },
}
}
},
EcosystemSpecific = ecosystemSpecificJson.RootElement,
}
}
};
var document = new DocumentRecord(
Guid.NewGuid(),
OsvConnectorPlugin.SourceName,
"https://osv.dev/vulnerability/OSV-2025-TEST",
DateTimeOffset.UtcNow,
"sha256",
DocumentStatuses.PendingParse,
"application/json",
null,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["osv.ecosystem"] = "PyPI",
},
null,
modified,
null,
null);
var payload = DocumentObject.Parse(JsonSerializer.Serialize(dto, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
}));
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, OsvConnectorPlugin.SourceName, "osv.v1", payload, DateTimeOffset.UtcNow);
var advisory = OsvMapper.Map(dto, document, dtoRecord, "PyPI");
Assert.Equal(dto.Id, advisory.AdvisoryKey);
Assert.Contains("CVE-2025-0002", advisory.Aliases);
Assert.Equal(4, advisory.Aliases.Length);
Assert.Equal(2, advisory.References.Length);
Assert.Equal("https://example.com/advisory", advisory.References[0].Url);
Assert.Equal("https://example.com/patch", advisory.References[1].Url);
Assert.Single(advisory.AffectedPackages);
var affected = advisory.AffectedPackages[0];
Assert.Equal(AffectedPackageTypes.SemVer, affected.Type);
Assert.Single(affected.VersionRanges);
Assert.Equal("0", affected.VersionRanges[0].IntroducedVersion);
Assert.Equal("1.0.1", affected.VersionRanges[0].FixedVersion);
var semver = affected.VersionRanges[0].Primitives?.SemVer;
Assert.NotNull(semver);
Assert.Equal("0", semver!.Introduced);
Assert.True(semver.IntroducedInclusive);
Assert.Equal("1.0.1", semver.Fixed);
Assert.False(semver.FixedInclusive);
Assert.Single(advisory.CvssMetrics);
Assert.Equal("3.1", advisory.CvssMetrics[0].Version);
}
[Fact]
public void Map_AssignsSeverityFallbackWhenCvssVectorUnsupported()
{
using var databaseSpecificJson = JsonDocument.Parse("""
{
"severity": "MODERATE",
"cwe_ids": ["CWE-290"]
}
""");
var dto = new OsvVulnerabilityDto
{
Id = "OSV-CVSS4",
Summary = "Severity-only advisory",
Details = "OSV entry that lacks a parsable CVSS vector.",
Published = DateTimeOffset.UtcNow.AddDays(-10),
Modified = DateTimeOffset.UtcNow.AddDays(-5),
DatabaseSpecific = databaseSpecificJson.RootElement,
Severity = new[]
{
new OsvSeverityDto
{
Type = "CVSS_V4",
Score = "CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N"
}
}
};
var (document, dtoRecord) = CreateDocumentAndDtoRecord(dto, "PyPI");
var advisory = OsvMapper.Map(dto, document, dtoRecord, "PyPI");
Assert.True(advisory.CvssMetrics.IsEmpty);
Assert.Equal("medium", advisory.Severity);
Assert.Equal("osv:severity/medium", advisory.CanonicalMetricId);
var weakness = Assert.Single(advisory.Cwes);
var provenance = Assert.Single(weakness.Provenance);
Assert.Equal("database_specific.cwe_ids", provenance.DecisionReason);
}
[Theory]
[InlineData("Go", "github.com/example/project", "pkg:golang/github.com/example/project")]
[InlineData("PyPI", "social_auth_app_django", "pkg:pypi/social-auth-app-django")]
[InlineData("npm", "@Scope/Package", "pkg:npm/%40scope/package")]
[InlineData("Maven", "org.example:library", "pkg:maven/org.example/library")]
[InlineData("crates", "serde", "pkg:cargo/serde")]
public void Map_InfersCanonicalPackageUrlWhenPurlMissing(string ecosystem, string packageName, string expectedIdentifier)
{
var dto = new OsvVulnerabilityDto
{
Id = $"OSV-{ecosystem}-PURL",
Summary = "Test advisory",
Details = "Details",
Published = DateTimeOffset.UtcNow.AddDays(-1),
Modified = DateTimeOffset.UtcNow,
Affected = new[]
{
new OsvAffectedPackageDto
{
Package = new OsvPackageDto
{
Ecosystem = ecosystem,
Name = packageName,
Purl = null,
},
Ranges = null,
}
}
};
if (string.Equals(ecosystem, "npm", StringComparison.OrdinalIgnoreCase))
{
Assert.True(IdentifierNormalizer.TryNormalizePackageUrl("pkg:npm/%40scope/package", out var canonical));
Assert.Equal(expectedIdentifier, canonical);
}
var method = typeof(OsvMapper).GetMethod("DetermineIdentifier", BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
var directIdentifier = method!.Invoke(null, new object?[] { dto.Affected![0].Package!, ecosystem }) as string;
Assert.Equal(expectedIdentifier, directIdentifier);
var (document, dtoRecord) = CreateDocumentAndDtoRecord(dto, ecosystem);
var advisory = OsvMapper.Map(dto, document, dtoRecord, ecosystem);
var affected = Assert.Single(advisory.AffectedPackages);
Assert.Equal(expectedIdentifier, affected.Identifier);
}
private static (DocumentRecord Document, DtoRecord DtoRecord) CreateDocumentAndDtoRecord(OsvVulnerabilityDto dto, string ecosystem)
{
var recordedAt = DateTimeOffset.UtcNow;
var document = new DocumentRecord(
Guid.NewGuid(),
OsvConnectorPlugin.SourceName,
$"https://osv.dev/vulnerability/{dto.Id}",
recordedAt,
"sha256",
DocumentStatuses.PendingParse,
"application/json",
null,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["osv.ecosystem"] = ecosystem,
},
null,
dto.Modified,
null,
null);
var payload = new DocumentObject("id", dto.Id);
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, OsvConnectorPlugin.SourceName, "osv.v1", payload, recordedAt);
return (document, dtoRecord);
}
}

View File

@@ -1,141 +1,141 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Osv;
using StellaOps.Concelier.Connector.Osv.Internal;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Connector.Common;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Connector.Osv.Tests;
public sealed class OsvSnapshotTests
{
private static readonly DateTimeOffset BaselinePublished = new(2025, 1, 5, 12, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset BaselineModified = new(2025, 1, 8, 6, 30, 0, TimeSpan.Zero);
private static readonly DateTimeOffset BaselineFetched = new(2025, 1, 8, 7, 0, 0, TimeSpan.Zero);
private readonly ITestOutputHelper _output;
public OsvSnapshotTests(ITestOutputHelper output)
{
_output = output;
}
[Theory]
[InlineData("PyPI", "pkg:pypi/requests", "requests", "osv-pypi.snapshot.json")]
[InlineData("npm", "pkg:npm/%40scope%2Fleft-pad", "@scope/left-pad", "osv-npm.snapshot.json")]
public void Map_ProducesExpectedSnapshot(string ecosystem, string purl, string packageName, string snapshotFile)
{
var dto = CreateDto(ecosystem, purl, packageName);
var document = CreateDocumentRecord(ecosystem);
var dtoRecord = CreateDtoRecord(document, dto);
var advisory = OsvMapper.Map(dto, document, dtoRecord, ecosystem);
var actual = SnapshotSerializer.ToSnapshot(advisory).Trim();
var snapshotPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", snapshotFile);
var expected = File.Exists(snapshotPath) ? File.ReadAllText(snapshotPath).Trim() : string.Empty;
if (!string.Equals(actual, expected, StringComparison.Ordinal))
{
_output.WriteLine(actual);
}
Assert.False(string.IsNullOrEmpty(expected), $"Snapshot '{snapshotFile}' not found or empty.");
using var expectedJson = JsonDocument.Parse(expected);
using var actualJson = JsonDocument.Parse(actual);
Assert.True(JsonElement.DeepEquals(actualJson.RootElement, expectedJson.RootElement), "OSV snapshot mismatch.");
}
private static OsvVulnerabilityDto CreateDto(string ecosystem, string purl, string packageName)
{
return new OsvVulnerabilityDto
{
Id = $"OSV-2025-{ecosystem}-0001",
Summary = $"{ecosystem} package vulnerability",
Details = $"Detailed description for {ecosystem} package {packageName}.",
Published = BaselinePublished,
Modified = BaselineModified,
Aliases = new[] { $"CVE-2025-11{ecosystem.Length}", $"GHSA-{ecosystem.Length}abc-{ecosystem.Length}def-{ecosystem.Length}ghi" },
Related = new[] { $"OSV-RELATED-{ecosystem}-42" },
References = new[]
{
new OsvReferenceDto { Url = $"https://example.com/{ecosystem}/advisory", Type = "ADVISORY" },
new OsvReferenceDto { Url = $"https://example.com/{ecosystem}/fix", Type = "FIX" },
},
Severity = new[]
{
new OsvSeverityDto { Type = "CVSS_V3", Score = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" },
},
Affected = new[]
{
new OsvAffectedPackageDto
{
Package = new OsvPackageDto
{
Ecosystem = ecosystem,
Name = packageName,
Purl = purl,
},
Ranges = new[]
{
new OsvRangeDto
{
Type = "SEMVER",
Events = new[]
{
new OsvEventDto { Introduced = "0" },
new OsvEventDto { Fixed = "2.0.0" },
}
}
},
Versions = new[] { "1.0.0", "1.5.0" },
EcosystemSpecific = ParseElement("{\"severity\":\"high\"}"),
}
},
DatabaseSpecific = ParseElement("{\"source\":\"osv.dev\"}"),
};
}
private static DocumentRecord CreateDocumentRecord(string ecosystem)
=> new(
Guid.Parse("11111111-1111-1111-1111-111111111111"),
OsvConnectorPlugin.SourceName,
$"https://osv.dev/vulnerability/OSV-2025-{ecosystem}-0001",
BaselineFetched,
"sha256-osv-snapshot",
DocumentStatuses.PendingParse,
"application/json",
null,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["osv.ecosystem"] = ecosystem,
},
"\"osv-etag\"",
BaselineModified,
null,
null);
private static DtoRecord CreateDtoRecord(DocumentRecord document, OsvVulnerabilityDto dto)
{
var payload = BsonDocument.Parse(JsonSerializer.Serialize(dto, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
}));
return new DtoRecord(Guid.Parse("22222222-2222-2222-2222-222222222222"), document.Id, OsvConnectorPlugin.SourceName, "osv.v1", payload, BaselineModified);
}
private static JsonElement ParseElement(string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Osv;
using StellaOps.Concelier.Connector.Osv.Internal;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Connector.Common;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Connector.Osv.Tests;
public sealed class OsvSnapshotTests
{
private static readonly DateTimeOffset BaselinePublished = new(2025, 1, 5, 12, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset BaselineModified = new(2025, 1, 8, 6, 30, 0, TimeSpan.Zero);
private static readonly DateTimeOffset BaselineFetched = new(2025, 1, 8, 7, 0, 0, TimeSpan.Zero);
private readonly ITestOutputHelper _output;
public OsvSnapshotTests(ITestOutputHelper output)
{
_output = output;
}
[Theory]
[InlineData("PyPI", "pkg:pypi/requests", "requests", "osv-pypi.snapshot.json")]
[InlineData("npm", "pkg:npm/%40scope%2Fleft-pad", "@scope/left-pad", "osv-npm.snapshot.json")]
public void Map_ProducesExpectedSnapshot(string ecosystem, string purl, string packageName, string snapshotFile)
{
var dto = CreateDto(ecosystem, purl, packageName);
var document = CreateDocumentRecord(ecosystem);
var dtoRecord = CreateDtoRecord(document, dto);
var advisory = OsvMapper.Map(dto, document, dtoRecord, ecosystem);
var actual = SnapshotSerializer.ToSnapshot(advisory).Trim();
var snapshotPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", snapshotFile);
var expected = File.Exists(snapshotPath) ? File.ReadAllText(snapshotPath).Trim() : string.Empty;
if (!string.Equals(actual, expected, StringComparison.Ordinal))
{
_output.WriteLine(actual);
}
Assert.False(string.IsNullOrEmpty(expected), $"Snapshot '{snapshotFile}' not found or empty.");
using var expectedJson = JsonDocument.Parse(expected);
using var actualJson = JsonDocument.Parse(actual);
Assert.True(JsonElement.DeepEquals(actualJson.RootElement, expectedJson.RootElement), "OSV snapshot mismatch.");
}
private static OsvVulnerabilityDto CreateDto(string ecosystem, string purl, string packageName)
{
return new OsvVulnerabilityDto
{
Id = $"OSV-2025-{ecosystem}-0001",
Summary = $"{ecosystem} package vulnerability",
Details = $"Detailed description for {ecosystem} package {packageName}.",
Published = BaselinePublished,
Modified = BaselineModified,
Aliases = new[] { $"CVE-2025-11{ecosystem.Length}", $"GHSA-{ecosystem.Length}abc-{ecosystem.Length}def-{ecosystem.Length}ghi" },
Related = new[] { $"OSV-RELATED-{ecosystem}-42" },
References = new[]
{
new OsvReferenceDto { Url = $"https://example.com/{ecosystem}/advisory", Type = "ADVISORY" },
new OsvReferenceDto { Url = $"https://example.com/{ecosystem}/fix", Type = "FIX" },
},
Severity = new[]
{
new OsvSeverityDto { Type = "CVSS_V3", Score = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" },
},
Affected = new[]
{
new OsvAffectedPackageDto
{
Package = new OsvPackageDto
{
Ecosystem = ecosystem,
Name = packageName,
Purl = purl,
},
Ranges = new[]
{
new OsvRangeDto
{
Type = "SEMVER",
Events = new[]
{
new OsvEventDto { Introduced = "0" },
new OsvEventDto { Fixed = "2.0.0" },
}
}
},
Versions = new[] { "1.0.0", "1.5.0" },
EcosystemSpecific = ParseElement("{\"severity\":\"high\"}"),
}
},
DatabaseSpecific = ParseElement("{\"source\":\"osv.dev\"}"),
};
}
private static DocumentRecord CreateDocumentRecord(string ecosystem)
=> new(
Guid.Parse("11111111-1111-1111-1111-111111111111"),
OsvConnectorPlugin.SourceName,
$"https://osv.dev/vulnerability/OSV-2025-{ecosystem}-0001",
BaselineFetched,
"sha256-osv-snapshot",
DocumentStatuses.PendingParse,
"application/json",
null,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["osv.ecosystem"] = ecosystem,
},
"\"osv-etag\"",
BaselineModified,
null,
null);
private static DtoRecord CreateDtoRecord(DocumentRecord document, OsvVulnerabilityDto dto)
{
var payload = DocumentObject.Parse(JsonSerializer.Serialize(dto, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
}));
return new DtoRecord(Guid.Parse("22222222-2222-2222-2222-222222222222"), document.Id, OsvConnectorPlugin.SourceName, "osv.v1", payload, BaselineModified);
}
private static JsonElement ParseElement(string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
}

View File

@@ -13,7 +13,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Ru.Bdu;
@@ -189,7 +189,7 @@ public sealed class RuBduConnectorSnapshotTests : IAsyncLifetime
var document = await documentStore.FindAsync(record.DocumentId, CancellationToken.None);
Assert.NotNull(document);
var payload = BsonTypeMapper.MapToDotNetValue(record.Payload);
var payload = DocumentTypeMapper.MapToDotNetValue(record.Payload);
entries.Add(new
{
DocumentUri = document!.Uri,

View File

@@ -1,95 +1,95 @@
using System.Collections.Immutable;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Ru.Bdu.Internal;
using StellaOps.Concelier.Storage;
using Xunit;
namespace StellaOps.Concelier.Connector.Ru.Bdu.Tests;
public sealed class RuBduMapperTests
{
[Fact]
public void Map_ConstructsCanonicalAdvisory()
{
var dto = new RuBduVulnerabilityDto(
Identifier: "BDU:2025-12345",
Name: "Уязвимость тестового продукта",
Description: "Описание",
Solution: "Обновить",
IdentifyDate: new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
SeverityText: "Высокий уровень опасности",
CvssVector: "AV:N/AC:L/Au:N/C:P/I:P/A:P",
CvssScore: 7.5,
Cvss3Vector: "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
Cvss3Score: 9.8,
ExploitStatus: "Существует",
IncidentCount: 2,
FixStatus: "Уязвимость устранена",
VulStatus: "Подтверждена производителем",
VulClass: null,
VulState: null,
Other: null,
Software: new[]
{
new RuBduSoftwareDto(
"ООО Вендор",
"Продукт",
"1.2.3;1.2.4",
"Windows",
new[] { "ПО программно-аппаратного средства АСУ ТП" }.ToImmutableArray())
}.ToImmutableArray(),
Environment: ImmutableArray<RuBduEnvironmentDto>.Empty,
Cwes: new[] { new RuBduCweDto("CWE-79", "XSS"), new RuBduCweDto("CWE-89", "SQL Injection") }.ToImmutableArray(),
Sources: new[]
{
"https://advisories.example/BDU-2025-12345",
"www.example.com/ru-bdu/BDU-2025-12345"
}.ToImmutableArray(),
Identifiers: new[]
{
new RuBduExternalIdentifierDto("CVE", "CVE-2025-12345", "https://nvd.nist.gov/vuln/detail/CVE-2025-12345"),
new RuBduExternalIdentifierDto("Positive Technologies Advisory", "PT-2025-001", "https://ptsecurity.com/PT-2025-001")
}.ToImmutableArray());
var document = new DocumentRecord(
Guid.NewGuid(),
RuBduConnectorPlugin.SourceName,
"https://bdu.fstec.ru/vul/2025-12345",
DateTimeOffset.UtcNow,
"abc",
DocumentStatuses.PendingMap,
"application/json",
null,
null,
null,
dto.IdentifyDate,
PayloadId: Guid.NewGuid());
var advisory = RuBduMapper.Map(dto, document, dto.IdentifyDate!.Value);
Assert.Equal("BDU:2025-12345", advisory.AdvisoryKey);
Assert.Contains("BDU:2025-12345", advisory.Aliases);
Assert.Contains("CVE-2025-12345", advisory.Aliases);
Assert.Equal("critical", advisory.Severity);
Assert.True(advisory.ExploitKnown);
var package = Assert.Single(advisory.AffectedPackages);
Assert.Equal(AffectedPackageTypes.IcsVendor, package.Type);
Assert.Equal(2, package.VersionRanges.Length);
Assert.Equal(2, package.NormalizedVersions.Length);
Assert.All(package.NormalizedVersions, rule => Assert.Equal("ru-bdu.raw", rule.Scheme));
Assert.Contains(package.NormalizedVersions, rule => rule.Value == "1.2.3");
Assert.Contains(package.NormalizedVersions, rule => rule.Value == "1.2.4");
Assert.Contains(package.Statuses, status => status.Status == AffectedPackageStatusCatalog.Affected);
Assert.Contains(package.Statuses, status => status.Status == AffectedPackageStatusCatalog.Fixed);
Assert.Equal(2, advisory.CvssMetrics.Length);
Assert.Contains(advisory.References, reference => reference.Url == "https://bdu.fstec.ru/vul/2025-12345" && reference.Kind == "details");
Assert.Contains(advisory.References, reference => reference.Url == "https://nvd.nist.gov/vuln/detail/CVE-2025-12345" && reference.Kind == "cve");
Assert.Contains(advisory.References, reference => reference.Url == "https://advisories.example/BDU-2025-12345" && reference.Kind == "source");
Assert.Contains(advisory.References, reference => reference.Url == "https://www.example.com/ru-bdu/BDU-2025-12345" && reference.Kind == "source");
Assert.Contains(advisory.References, reference => reference.SourceTag == "positivetechnologiesadvisory");
}
}
using System.Collections.Immutable;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Ru.Bdu.Internal;
using StellaOps.Concelier.Storage;
using Xunit;
namespace StellaOps.Concelier.Connector.Ru.Bdu.Tests;
public sealed class RuBduMapperTests
{
[Fact]
public void Map_ConstructsCanonicalAdvisory()
{
var dto = new RuBduVulnerabilityDto(
Identifier: "BDU:2025-12345",
Name: "Уязвимость тестового продукта",
Description: "Описание",
Solution: "Обновить",
IdentifyDate: new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
SeverityText: "Высокий уровень опасности",
CvssVector: "AV:N/AC:L/Au:N/C:P/I:P/A:P",
CvssScore: 7.5,
Cvss3Vector: "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
Cvss3Score: 9.8,
ExploitStatus: "Существует",
IncidentCount: 2,
FixStatus: "Уязвимость устранена",
VulStatus: "Подтверждена производителем",
VulClass: null,
VulState: null,
Other: null,
Software: new[]
{
new RuBduSoftwareDto(
"ООО Вендор",
"Продукт",
"1.2.3;1.2.4",
"Windows",
new[] { "ПО программно-аппаратного средства АСУ ТП" }.ToImmutableArray())
}.ToImmutableArray(),
Environment: ImmutableArray<RuBduEnvironmentDto>.Empty,
Cwes: new[] { new RuBduCweDto("CWE-79", "XSS"), new RuBduCweDto("CWE-89", "SQL Injection") }.ToImmutableArray(),
Sources: new[]
{
"https://advisories.example/BDU-2025-12345",
"www.example.com/ru-bdu/BDU-2025-12345"
}.ToImmutableArray(),
Identifiers: new[]
{
new RuBduExternalIdentifierDto("CVE", "CVE-2025-12345", "https://nvd.nist.gov/vuln/detail/CVE-2025-12345"),
new RuBduExternalIdentifierDto("Positive Technologies Advisory", "PT-2025-001", "https://ptsecurity.com/PT-2025-001")
}.ToImmutableArray());
var document = new DocumentRecord(
Guid.NewGuid(),
RuBduConnectorPlugin.SourceName,
"https://bdu.fstec.ru/vul/2025-12345",
DateTimeOffset.UtcNow,
"abc",
DocumentStatuses.PendingMap,
"application/json",
null,
null,
null,
dto.IdentifyDate,
PayloadId: Guid.NewGuid());
var advisory = RuBduMapper.Map(dto, document, dto.IdentifyDate!.Value);
Assert.Equal("BDU:2025-12345", advisory.AdvisoryKey);
Assert.Contains("BDU:2025-12345", advisory.Aliases);
Assert.Contains("CVE-2025-12345", advisory.Aliases);
Assert.Equal("critical", advisory.Severity);
Assert.True(advisory.ExploitKnown);
var package = Assert.Single(advisory.AffectedPackages);
Assert.Equal(AffectedPackageTypes.IcsVendor, package.Type);
Assert.Equal(2, package.VersionRanges.Length);
Assert.Equal(2, package.NormalizedVersions.Length);
Assert.All(package.NormalizedVersions, rule => Assert.Equal("ru-bdu.raw", rule.Scheme));
Assert.Contains(package.NormalizedVersions, rule => rule.Value == "1.2.3");
Assert.Contains(package.NormalizedVersions, rule => rule.Value == "1.2.4");
Assert.Contains(package.Statuses, status => status.Status == AffectedPackageStatusCatalog.Affected);
Assert.Contains(package.Statuses, status => status.Status == AffectedPackageStatusCatalog.Fixed);
Assert.Equal(2, advisory.CvssMetrics.Length);
Assert.Contains(advisory.References, reference => reference.Url == "https://bdu.fstec.ru/vul/2025-12345" && reference.Kind == "details");
Assert.Contains(advisory.References, reference => reference.Url == "https://nvd.nist.gov/vuln/detail/CVE-2025-12345" && reference.Kind == "cve");
Assert.Contains(advisory.References, reference => reference.Url == "https://advisories.example/BDU-2025-12345" && reference.Kind == "source");
Assert.Contains(advisory.References, reference => reference.Url == "https://www.example.com/ru-bdu/BDU-2025-12345" && reference.Kind == "source");
Assert.Contains(advisory.References, reference => reference.SourceTag == "positivetechnologiesadvisory");
}
}

View File

@@ -1,93 +1,93 @@
using System.IO;
using System.Xml.Linq;
using StellaOps.Concelier.Connector.Ru.Bdu.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Ru.Bdu.Tests;
public sealed class RuBduXmlParserTests
{
[Fact]
public void TryParse_ValidElement_ReturnsDto()
{
const string xml = """
<vul>
<identifier>BDU:2025-12345</identifier>
<name>Уязвимость тестового продукта</name>
<description>Описание уязвимости</description>
<solution>Обновить продукт</solution>
<identify_date>2025-10-10</identify_date>
<severity>Высокий уровень опасности</severity>
<exploit_status>Существует эксплойт</exploit_status>
<fix_status>Устранена</fix_status>
<vul_status>Подтверждена производителем</vul_status>
<vul_incident>1</vul_incident>
<cvss>
<vector score="7.5">AV:N/AC:L/Au:N/C:P/I:P/A:P</vector>
</cvss>
<cvss3>
<vector score="9.8">AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</vector>
</cvss3>
<vulnerable_software>
<soft>
<vendor>ООО «Вендор»</vendor>
<name>Продукт</name>
<version>1.2.3</version>
<platform>Windows</platform>
<types>
<type>ics</type>
</types>
</soft>
</vulnerable_software>
<sources>
https://advisories.example/BDU-2025-12345
https://mirror.example/ru-bdu/BDU-2025-12345
</sources>
<identifiers>
<identifier type="CVE" link="https://nvd.nist.gov/vuln/detail/CVE-2025-12345">CVE-2025-12345</identifier>
<identifier type="GHSA" link="https://github.com/advisories/GHSA-xxxx-yyyy-zzzz">GHSA-xxxx-yyyy-zzzz</identifier>
</identifiers>
<cwes>
<cwe>
<identifier>CWE-79</identifier>
<name>XSS</name>
</cwe>
</cwes>
</vul>
""";
var element = XElement.Parse(xml);
var dto = RuBduXmlParser.TryParse(element);
Assert.NotNull(dto);
Assert.Equal("BDU:2025-12345", dto!.Identifier);
Assert.Equal("Уязвимость тестового продукта", dto.Name);
Assert.Equal("AV:N/AC:L/Au:N/C:P/I:P/A:P", dto.CvssVector);
Assert.Equal(7.5, dto.CvssScore);
Assert.Equal("AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", dto.Cvss3Vector);
Assert.Equal(9.8, dto.Cvss3Score);
Assert.Single(dto.Software);
Assert.Single(dto.Cwes);
Assert.Equal(2, dto.Sources.Length);
Assert.Contains("https://advisories.example/BDU-2025-12345", dto.Sources);
Assert.Equal(2, dto.Identifiers.Length);
Assert.Contains(dto.Identifiers, identifier => identifier.Type == "CVE" && identifier.Value == "CVE-2025-12345");
Assert.Contains(dto.Identifiers, identifier => identifier.Type == "GHSA" && identifier.Link == "https://github.com/advisories/GHSA-xxxx-yyyy-zzzz");
}
[Fact]
public void TryParse_SampleArchiveEntries_ReturnDtos()
{
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Fixtures", "export-sample.xml"));
var document = XDocument.Load(path);
var vulnerabilities = document.Root?.Elements("vul");
Assert.NotNull(vulnerabilities);
foreach (var element in vulnerabilities!)
{
var dto = RuBduXmlParser.TryParse(element);
Assert.NotNull(dto);
Assert.False(string.IsNullOrWhiteSpace(dto!.Identifier));
}
}
}
using System.IO;
using System.Xml.Linq;
using StellaOps.Concelier.Connector.Ru.Bdu.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Ru.Bdu.Tests;
public sealed class RuBduXmlParserTests
{
[Fact]
public void TryParse_ValidElement_ReturnsDto()
{
const string xml = """
<vul>
<identifier>BDU:2025-12345</identifier>
<name>Уязвимость тестового продукта</name>
<description>Описание уязвимости</description>
<solution>Обновить продукт</solution>
<identify_date>2025-10-10</identify_date>
<severity>Высокий уровень опасности</severity>
<exploit_status>Существует эксплойт</exploit_status>
<fix_status>Устранена</fix_status>
<vul_status>Подтверждена производителем</vul_status>
<vul_incident>1</vul_incident>
<cvss>
<vector score="7.5">AV:N/AC:L/Au:N/C:P/I:P/A:P</vector>
</cvss>
<cvss3>
<vector score="9.8">AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</vector>
</cvss3>
<vulnerable_software>
<soft>
<vendor>ООО «Вендор»</vendor>
<name>Продукт</name>
<version>1.2.3</version>
<platform>Windows</platform>
<types>
<type>ics</type>
</types>
</soft>
</vulnerable_software>
<sources>
https://advisories.example/BDU-2025-12345
https://mirror.example/ru-bdu/BDU-2025-12345
</sources>
<identifiers>
<identifier type="CVE" link="https://nvd.nist.gov/vuln/detail/CVE-2025-12345">CVE-2025-12345</identifier>
<identifier type="GHSA" link="https://github.com/advisories/GHSA-xxxx-yyyy-zzzz">GHSA-xxxx-yyyy-zzzz</identifier>
</identifiers>
<cwes>
<cwe>
<identifier>CWE-79</identifier>
<name>XSS</name>
</cwe>
</cwes>
</vul>
""";
var element = XElement.Parse(xml);
var dto = RuBduXmlParser.TryParse(element);
Assert.NotNull(dto);
Assert.Equal("BDU:2025-12345", dto!.Identifier);
Assert.Equal("Уязвимость тестового продукта", dto.Name);
Assert.Equal("AV:N/AC:L/Au:N/C:P/I:P/A:P", dto.CvssVector);
Assert.Equal(7.5, dto.CvssScore);
Assert.Equal("AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", dto.Cvss3Vector);
Assert.Equal(9.8, dto.Cvss3Score);
Assert.Single(dto.Software);
Assert.Single(dto.Cwes);
Assert.Equal(2, dto.Sources.Length);
Assert.Contains("https://advisories.example/BDU-2025-12345", dto.Sources);
Assert.Equal(2, dto.Identifiers.Length);
Assert.Contains(dto.Identifiers, identifier => identifier.Type == "CVE" && identifier.Value == "CVE-2025-12345");
Assert.Contains(dto.Identifiers, identifier => identifier.Type == "GHSA" && identifier.Link == "https://github.com/advisories/GHSA-xxxx-yyyy-zzzz");
}
[Fact]
public void TryParse_SampleArchiveEntries_ReturnDtos()
{
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Fixtures", "export-sample.xml"));
var document = XDocument.Load(path);
var vulnerabilities = document.Root?.Elements("vul");
Assert.NotNull(vulnerabilities);
foreach (var element in vulnerabilities!)
{
var dto = RuBduXmlParser.TryParse(element);
Assert.NotNull(dto);
Assert.False(string.IsNullOrWhiteSpace(dto!.Identifier));
}
}
}

View File

@@ -1,24 +1,24 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Ru.Nkcki;
using StellaOps.Concelier.Connector.Ru.Nkcki.Configuration;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Ru.Nkcki;
using StellaOps.Concelier.Connector.Ru.Nkcki.Configuration;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
@@ -27,116 +27,116 @@ using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Cryptography.DependencyInjection;
using Xunit;
namespace StellaOps.Concelier.Connector.Ru.Nkcki.Tests;
namespace StellaOps.Concelier.Connector.Ru.Nkcki.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class RuNkckiConnectorTests : IAsyncLifetime
{
private static readonly Uri ListingUri = new("https://cert.gov.ru/materialy/uyazvimosti/");
private static readonly Uri ListingPage2Uri = new("https://cert.gov.ru/materialy/uyazvimosti/?PAGEN_1=2");
private static readonly Uri BulletinUri = new("https://cert.gov.ru/materialy/uyazvimosti/bulletin-sample.json.zip");
private static readonly Uri LegacyBulletinUri = new("https://cert.gov.ru/materialy/uyazvimosti/bulletin-legacy.json.zip");
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
public RuNkckiConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesExpectedSnapshot()
{
await using var provider = await BuildServiceProviderAsync();
SeedListingAndBulletin();
var connector = provider.GetRequiredService<RuNkckiConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var snapshot = SnapshotSerializer.ToSnapshot(advisories);
WriteOrAssertSnapshot(snapshot, "nkcki-advisories.snapshot.json");
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(RuNkckiConnectorPlugin.SourceName, "https://cert.gov.ru/materialy/uyazvimosti/2025-01001", CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(RuNkckiConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(IsEmptyArray(state!.Cursor, "pendingDocuments"));
Assert.True(IsEmptyArray(state.Cursor, "pendingMappings"));
}
[Fact]
public async Task Fetch_ReusesCachedBulletinWhenListingFails()
{
await using var provider = await BuildServiceProviderAsync();
SeedListingAndBulletin();
var connector = provider.GetRequiredService<RuNkckiConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
_handler.Clear();
for (var i = 0; i < 3; i++)
{
_handler.AddResponse(ListingUri, () => new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("error", Encoding.UTF8, "text/plain"),
});
}
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var before = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, before.Count);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var after = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(before.Select(advisory => advisory.AdvisoryKey).OrderBy(static key => key), after.Select(advisory => advisory.AdvisoryKey).OrderBy(static key => key));
_handler.AssertNoPendingResponses();
}
public sealed class RuNkckiConnectorTests : IAsyncLifetime
{
private static readonly Uri ListingUri = new("https://cert.gov.ru/materialy/uyazvimosti/");
private static readonly Uri ListingPage2Uri = new("https://cert.gov.ru/materialy/uyazvimosti/?PAGEN_1=2");
private static readonly Uri BulletinUri = new("https://cert.gov.ru/materialy/uyazvimosti/bulletin-sample.json.zip");
private static readonly Uri LegacyBulletinUri = new("https://cert.gov.ru/materialy/uyazvimosti/bulletin-legacy.json.zip");
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
public RuNkckiConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesExpectedSnapshot()
{
await using var provider = await BuildServiceProviderAsync();
SeedListingAndBulletin();
var connector = provider.GetRequiredService<RuNkckiConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var snapshot = SnapshotSerializer.ToSnapshot(advisories);
WriteOrAssertSnapshot(snapshot, "nkcki-advisories.snapshot.json");
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(RuNkckiConnectorPlugin.SourceName, "https://cert.gov.ru/materialy/uyazvimosti/2025-01001", CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(RuNkckiConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(IsEmptyArray(state!.Cursor, "pendingDocuments"));
Assert.True(IsEmptyArray(state.Cursor, "pendingMappings"));
}
[Fact]
public async Task Fetch_ReusesCachedBulletinWhenListingFails()
{
await using var provider = await BuildServiceProviderAsync();
SeedListingAndBulletin();
var connector = provider.GetRequiredService<RuNkckiConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
_handler.Clear();
for (var i = 0; i < 3; i++)
{
_handler.AddResponse(ListingUri, () => new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("error", Encoding.UTF8, "text/plain"),
});
}
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var before = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, before.Count);
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var after = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(before.Select(advisory => advisory.AdvisoryKey).OrderBy(static key => key), after.Select(advisory => advisory.AdvisoryKey).OrderBy(static key => key));
_handler.AssertNoPendingResponses();
}
private async Task<ServiceProvider> BuildServiceProviderAsync()
{
await _fixture.TruncateAllTablesAsync();
_handler.Clear();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddStellaOpsCrypto();
services.AddSourceCommon();
services.AddRuNkckiConnector(options =>
{
options.BaseAddress = new Uri("https://cert.gov.ru/");
options.ListingPath = "/materialy/uyazvimosti/";
services.AddStellaOpsCrypto();
services.AddSourceCommon();
services.AddRuNkckiConnector(options =>
{
options.BaseAddress = new Uri("https://cert.gov.ru/");
options.ListingPath = "/materialy/uyazvimosti/";
options.MaxBulletinsPerFetch = 2;
options.MaxListingPagesPerFetch = 2;
options.MaxVulnerabilitiesPerFetch = 50;
@@ -146,143 +146,143 @@ public sealed class RuNkckiConnectorTests : IAsyncLifetime
options.CacheDirectory = Path.Combine(cacheRoot, "ru-nkcki");
options.RequestDelay = TimeSpan.Zero;
});
services.Configure<HttpClientFactoryOptions>(RuNkckiOptions.HttpClientName, builderOptions =>
services.Configure<HttpClientFactoryOptions>(RuNkckiOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
});
return services.BuildServiceProvider();
}
private void SeedListingAndBulletin()
{
var listingHtml = ReadFixture("listing.html");
_handler.AddTextResponse(ListingUri, listingHtml, "text/html");
var listingPage2Html = ReadFixture("listing-page2.html");
_handler.AddTextResponse(ListingPage2Uri, listingPage2Html, "text/html");
var bulletinBytes = ReadBulletinFixture("bulletin-sample.json.zip");
_handler.AddResponse(BulletinUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(bulletinBytes),
};
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
response.Content.Headers.LastModified = new DateTimeOffset(2025, 9, 22, 0, 0, 0, TimeSpan.Zero);
return response;
});
var legacyBytes = ReadBulletinFixture("bulletin-legacy.json.zip");
_handler.AddResponse(LegacyBulletinUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(legacyBytes),
};
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
response.Content.Headers.LastModified = new DateTimeOffset(2024, 8, 2, 0, 0, 0, TimeSpan.Zero);
return response;
});
}
private static bool IsEmptyArray(BsonDocument document, string field)
{
if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
{
return false;
}
return array.Count == 0;
}
private static string ReadFixture(string filename)
{
var path = Path.Combine("Fixtures", filename);
var resolved = ResolveFixturePath(path);
return File.ReadAllText(resolved);
}
private static byte[] ReadBulletinFixture(string filename)
{
var path = Path.Combine("Fixtures", filename);
var resolved = ResolveFixturePath(path);
return File.ReadAllBytes(resolved);
}
private static string ResolveFixturePath(string relativePath)
{
var projectRoot = GetProjectRoot();
var projectPath = Path.Combine(projectRoot, relativePath);
if (File.Exists(projectPath))
{
return projectPath;
}
var binaryPath = Path.Combine(AppContext.BaseDirectory, relativePath);
if (File.Exists(binaryPath))
{
return Path.GetFullPath(binaryPath);
}
throw new FileNotFoundException($"Fixture not found: {relativePath}");
}
private static void WriteOrAssertSnapshot(string snapshot, string filename)
{
if (ShouldUpdateFixtures())
{
var path = GetWritableFixturePath(filename);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, snapshot);
return;
}
var expectedPath = ResolveFixturePath(Path.Combine("Fixtures", filename));
if (!File.Exists(expectedPath))
{
throw new FileNotFoundException($"Expected snapshot missing: {expectedPath}. Set UPDATE_NKCKI_FIXTURES=1 to generate.");
}
var expected = File.ReadAllText(expectedPath);
Assert.Equal(Normalize(expected), Normalize(snapshot));
}
private static string GetWritableFixturePath(string filename)
{
var projectRoot = GetProjectRoot();
return Path.Combine(projectRoot, "Fixtures", filename);
}
private static bool ShouldUpdateFixtures()
{
var value = Environment.GetEnvironmentVariable("UPDATE_NKCKI_FIXTURES");
return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
private static string Normalize(string text)
=> text.Replace("\r\n", "\n", StringComparison.Ordinal);
private static string GetProjectRoot()
{
var current = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(current))
{
var candidate = Path.Combine(current, "StellaOps.Concelier.Connector.Ru.Nkcki.Tests.csproj");
if (File.Exists(candidate))
{
return current;
}
current = Path.GetDirectoryName(current);
}
throw new InvalidOperationException("Unable to locate project root for Ru.Nkcki tests.");
}
private void SeedListingAndBulletin()
{
var listingHtml = ReadFixture("listing.html");
_handler.AddTextResponse(ListingUri, listingHtml, "text/html");
var listingPage2Html = ReadFixture("listing-page2.html");
_handler.AddTextResponse(ListingPage2Uri, listingPage2Html, "text/html");
var bulletinBytes = ReadBulletinFixture("bulletin-sample.json.zip");
_handler.AddResponse(BulletinUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(bulletinBytes),
};
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
response.Content.Headers.LastModified = new DateTimeOffset(2025, 9, 22, 0, 0, 0, TimeSpan.Zero);
return response;
});
var legacyBytes = ReadBulletinFixture("bulletin-legacy.json.zip");
_handler.AddResponse(LegacyBulletinUri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(legacyBytes),
};
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
response.Content.Headers.LastModified = new DateTimeOffset(2024, 8, 2, 0, 0, 0, TimeSpan.Zero);
return response;
});
}
private static bool IsEmptyArray(DocumentObject document, string field)
{
if (!document.TryGetValue(field, out var value) || value is not DocumentArray array)
{
return false;
}
return array.Count == 0;
}
private static string ReadFixture(string filename)
{
var path = Path.Combine("Fixtures", filename);
var resolved = ResolveFixturePath(path);
return File.ReadAllText(resolved);
}
private static byte[] ReadBulletinFixture(string filename)
{
var path = Path.Combine("Fixtures", filename);
var resolved = ResolveFixturePath(path);
return File.ReadAllBytes(resolved);
}
private static string ResolveFixturePath(string relativePath)
{
var projectRoot = GetProjectRoot();
var projectPath = Path.Combine(projectRoot, relativePath);
if (File.Exists(projectPath))
{
return projectPath;
}
var binaryPath = Path.Combine(AppContext.BaseDirectory, relativePath);
if (File.Exists(binaryPath))
{
return Path.GetFullPath(binaryPath);
}
throw new FileNotFoundException($"Fixture not found: {relativePath}");
}
private static void WriteOrAssertSnapshot(string snapshot, string filename)
{
if (ShouldUpdateFixtures())
{
var path = GetWritableFixturePath(filename);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, snapshot);
return;
}
var expectedPath = ResolveFixturePath(Path.Combine("Fixtures", filename));
if (!File.Exists(expectedPath))
{
throw new FileNotFoundException($"Expected snapshot missing: {expectedPath}. Set UPDATE_NKCKI_FIXTURES=1 to generate.");
}
var expected = File.ReadAllText(expectedPath);
Assert.Equal(Normalize(expected), Normalize(snapshot));
}
private static string GetWritableFixturePath(string filename)
{
var projectRoot = GetProjectRoot();
return Path.Combine(projectRoot, "Fixtures", filename);
}
private static bool ShouldUpdateFixtures()
{
var value = Environment.GetEnvironmentVariable("UPDATE_NKCKI_FIXTURES");
return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
private static string Normalize(string text)
=> text.Replace("\r\n", "\n", StringComparison.Ordinal);
private static string GetProjectRoot()
{
var current = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(current))
{
var candidate = Path.Combine(current, "StellaOps.Concelier.Connector.Ru.Nkcki.Tests.csproj");
if (File.Exists(candidate))
{
return current;
}
current = Path.GetDirectoryName(current);
}
throw new InvalidOperationException("Unable to locate project root for Ru.Nkcki tests.");
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()

View File

@@ -1,60 +1,60 @@
using System.Text.Json;
using StellaOps.Concelier.Connector.Ru.Nkcki.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Ru.Nkcki.Tests;
public sealed class RuNkckiJsonParserTests
{
[Fact]
public void Parse_WellFormedEntry_ReturnsDto()
{
const string json = """
{
"vuln_id": {"MITRE": "CVE-2025-0001", "FSTEC": "BDU:2025-00001"},
"date_published": "2025-09-01",
"date_updated": "2025-09-02",
"cvss_rating": "КРИТИЧЕСКИЙ",
"patch_available": true,
"description": "Test description",
"cwe": {"cwe_number": 79, "cwe_description": "Cross-site scripting"},
"product_category": ["Web", "CMS"],
"mitigation": ["Apply update", "Review configuration"],
"vulnerable_software": {
"software_text": "ExampleCMS <= 1.0",
"software": [{"vendor": "Example", "name": "ExampleCMS", "version": "<= 1.0"}],
"cpe": false
},
"cvss": {
"cvss_score": 8.8,
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
"cvss_score_v4": 5.5,
"cvss_vector_v4": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H"
},
"impact": "ACE",
"method_of_exploitation": "Special request",
"user_interaction": false,
"urls": ["https://example.com/advisory", {"url": "https://cert.gov.ru/materialy/uyazvimosti/2025-00001"}],
"tags": ["cms"]
}
""";
using var document = JsonDocument.Parse(json);
var dto = RuNkckiJsonParser.Parse(document.RootElement);
Assert.Equal("BDU:2025-00001", dto.FstecId);
Assert.Equal("CVE-2025-0001", dto.MitreId);
Assert.Equal(8.8, dto.CvssScore);
Assert.Equal("CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", dto.CvssVector);
Assert.True(dto.PatchAvailable);
Assert.Equal(79, dto.Cwe?.Number);
Assert.Contains("Web", dto.ProductCategories);
Assert.Contains("CMS", dto.ProductCategories);
Assert.Single(dto.VulnerableSoftwareEntries);
var entry = dto.VulnerableSoftwareEntries[0];
Assert.Equal("Example ExampleCMS", entry.Identifier);
Assert.Contains("<= 1.0", entry.RangeExpressions);
Assert.Equal(2, dto.Urls.Length);
Assert.Contains("cms", dto.Tags);
}
}
using System.Text.Json;
using StellaOps.Concelier.Connector.Ru.Nkcki.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Ru.Nkcki.Tests;
public sealed class RuNkckiJsonParserTests
{
[Fact]
public void Parse_WellFormedEntry_ReturnsDto()
{
const string json = """
{
"vuln_id": {"MITRE": "CVE-2025-0001", "FSTEC": "BDU:2025-00001"},
"date_published": "2025-09-01",
"date_updated": "2025-09-02",
"cvss_rating": "КРИТИЧЕСКИЙ",
"patch_available": true,
"description": "Test description",
"cwe": {"cwe_number": 79, "cwe_description": "Cross-site scripting"},
"product_category": ["Web", "CMS"],
"mitigation": ["Apply update", "Review configuration"],
"vulnerable_software": {
"software_text": "ExampleCMS <= 1.0",
"software": [{"vendor": "Example", "name": "ExampleCMS", "version": "<= 1.0"}],
"cpe": false
},
"cvss": {
"cvss_score": 8.8,
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
"cvss_score_v4": 5.5,
"cvss_vector_v4": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H"
},
"impact": "ACE",
"method_of_exploitation": "Special request",
"user_interaction": false,
"urls": ["https://example.com/advisory", {"url": "https://cert.gov.ru/materialy/uyazvimosti/2025-00001"}],
"tags": ["cms"]
}
""";
using var document = JsonDocument.Parse(json);
var dto = RuNkckiJsonParser.Parse(document.RootElement);
Assert.Equal("BDU:2025-00001", dto.FstecId);
Assert.Equal("CVE-2025-0001", dto.MitreId);
Assert.Equal(8.8, dto.CvssScore);
Assert.Equal("CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", dto.CvssVector);
Assert.True(dto.PatchAvailable);
Assert.Equal(79, dto.Cwe?.Number);
Assert.Contains("Web", dto.ProductCategories);
Assert.Contains("CMS", dto.ProductCategories);
Assert.Single(dto.VulnerableSoftwareEntries);
var entry = dto.VulnerableSoftwareEntries[0];
Assert.Equal("Example ExampleCMS", entry.Identifier);
Assert.Contains("<= 1.0", entry.RangeExpressions);
Assert.Equal(2, dto.Urls.Length);
Assert.Contains("cms", dto.Tags);
}
}

View File

@@ -1,81 +1,81 @@
using System.Collections.Immutable;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Ru.Nkcki.Internal;
using StellaOps.Concelier.Storage;
using Xunit;
using System.Reflection;
namespace StellaOps.Concelier.Connector.Ru.Nkcki.Tests;
public sealed class RuNkckiMapperTests
{
[Fact]
public void Map_ConstructsCanonicalAdvisory()
{
var softwareEntries = ImmutableArray.Create(
new RuNkckiSoftwareEntry(
"SampleVendor SampleSCADA",
"SampleVendor SampleSCADA <= 4.2",
ImmutableArray.Create("<= 4.2")));
var dto = new RuNkckiVulnerabilityDto(
FstecId: "BDU:2025-00001",
MitreId: "CVE-2025-0001",
DatePublished: new DateTimeOffset(2025, 9, 1, 0, 0, 0, TimeSpan.Zero),
DateUpdated: new DateTimeOffset(2025, 9, 2, 0, 0, 0, TimeSpan.Zero),
CvssRating: "КРИТИЧЕСКИЙ",
PatchAvailable: true,
Description: "Test NKCKI vulnerability",
Cwe: new RuNkckiCweDto(79, "Cross-site scripting"),
ProductCategories: ImmutableArray.Create("ICS", "Automation"),
Mitigation: "Apply update",
VulnerableSoftwareText: null,
VulnerableSoftwareHasCpe: false,
VulnerableSoftwareEntries: softwareEntries,
CvssScore: 8.8,
CvssVector: "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
CvssScoreV4: 6.4,
CvssVectorV4: "CVSS:4.0/AV:N/AC:H/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H",
Impact: "ACE",
MethodOfExploitation: "Special request",
UserInteraction: false,
Urls: ImmutableArray.Create("https://example.com/advisory", "https://cert.gov.ru/materialy/uyazvimosti/2025-00001"),
Tags: ImmutableArray.Create("ics"));
var document = new DocumentRecord(
Guid.NewGuid(),
RuNkckiConnectorPlugin.SourceName,
"https://cert.gov.ru/materialy/uyazvimosti/2025-00001",
DateTimeOffset.UtcNow,
"abc",
DocumentStatuses.PendingMap,
"application/json",
null,
null,
null,
dto.DateUpdated,
PayloadId: Guid.NewGuid());
Assert.Equal("КРИТИЧЕСКИЙ", dto.CvssRating);
var normalizeSeverity = typeof(RuNkckiMapper).GetMethod("NormalizeSeverity", BindingFlags.NonPublic | BindingFlags.Static)!;
var ratingSeverity = (string?)normalizeSeverity.Invoke(null, new object?[] { dto.CvssRating });
Assert.Equal("critical", ratingSeverity);
var advisory = RuNkckiMapper.Map(dto, document, dto.DateUpdated!.Value);
Assert.Contains("BDU:2025-00001", advisory.Aliases);
Assert.Contains("CVE-2025-0001", advisory.Aliases);
Assert.Equal("critical", advisory.Severity);
Assert.True(advisory.ExploitKnown);
Assert.Single(advisory.AffectedPackages);
var package = advisory.AffectedPackages[0];
Assert.Equal(AffectedPackageTypes.IcsVendor, package.Type);
Assert.Single(package.NormalizedVersions);
Assert.Equal(2, advisory.CvssMetrics.Length);
Assert.Contains(advisory.CvssMetrics, metric => metric.Version == "4.0");
Assert.Equal("critical", advisory.Severity);
Assert.Contains(advisory.References, reference => reference.Url.Contains("example.com", StringComparison.OrdinalIgnoreCase));
}
}
using System.Collections.Immutable;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Ru.Nkcki.Internal;
using StellaOps.Concelier.Storage;
using Xunit;
using System.Reflection;
namespace StellaOps.Concelier.Connector.Ru.Nkcki.Tests;
public sealed class RuNkckiMapperTests
{
[Fact]
public void Map_ConstructsCanonicalAdvisory()
{
var softwareEntries = ImmutableArray.Create(
new RuNkckiSoftwareEntry(
"SampleVendor SampleSCADA",
"SampleVendor SampleSCADA <= 4.2",
ImmutableArray.Create("<= 4.2")));
var dto = new RuNkckiVulnerabilityDto(
FstecId: "BDU:2025-00001",
MitreId: "CVE-2025-0001",
DatePublished: new DateTimeOffset(2025, 9, 1, 0, 0, 0, TimeSpan.Zero),
DateUpdated: new DateTimeOffset(2025, 9, 2, 0, 0, 0, TimeSpan.Zero),
CvssRating: "КРИТИЧЕСКИЙ",
PatchAvailable: true,
Description: "Test NKCKI vulnerability",
Cwe: new RuNkckiCweDto(79, "Cross-site scripting"),
ProductCategories: ImmutableArray.Create("ICS", "Automation"),
Mitigation: "Apply update",
VulnerableSoftwareText: null,
VulnerableSoftwareHasCpe: false,
VulnerableSoftwareEntries: softwareEntries,
CvssScore: 8.8,
CvssVector: "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
CvssScoreV4: 6.4,
CvssVectorV4: "CVSS:4.0/AV:N/AC:H/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H",
Impact: "ACE",
MethodOfExploitation: "Special request",
UserInteraction: false,
Urls: ImmutableArray.Create("https://example.com/advisory", "https://cert.gov.ru/materialy/uyazvimosti/2025-00001"),
Tags: ImmutableArray.Create("ics"));
var document = new DocumentRecord(
Guid.NewGuid(),
RuNkckiConnectorPlugin.SourceName,
"https://cert.gov.ru/materialy/uyazvimosti/2025-00001",
DateTimeOffset.UtcNow,
"abc",
DocumentStatuses.PendingMap,
"application/json",
null,
null,
null,
dto.DateUpdated,
PayloadId: Guid.NewGuid());
Assert.Equal("КРИТИЧЕСКИЙ", dto.CvssRating);
var normalizeSeverity = typeof(RuNkckiMapper).GetMethod("NormalizeSeverity", BindingFlags.NonPublic | BindingFlags.Static)!;
var ratingSeverity = (string?)normalizeSeverity.Invoke(null, new object?[] { dto.CvssRating });
Assert.Equal("critical", ratingSeverity);
var advisory = RuNkckiMapper.Map(dto, document, dto.DateUpdated!.Value);
Assert.Contains("BDU:2025-00001", advisory.Aliases);
Assert.Contains("CVE-2025-0001", advisory.Aliases);
Assert.Equal("critical", advisory.Severity);
Assert.True(advisory.ExploitKnown);
Assert.Single(advisory.AffectedPackages);
var package = advisory.AffectedPackages[0];
Assert.Equal(AffectedPackageTypes.IcsVendor, package.Type);
Assert.Single(package.NormalizedVersions);
Assert.Equal(2, advisory.CvssMetrics.Length);
Assert.Contains(advisory.CvssMetrics, metric => metric.Version == "4.0");
Assert.Equal("critical", advisory.Severity);
Assert.Contains(advisory.References, reference => reference.Url.Contains("example.com", StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -1,33 +1,33 @@
using System;
using System.IO;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
internal static class FixtureLoader
{
private static readonly string FixturesRoot = Path.Combine(AppContext.BaseDirectory, "Fixtures");
public static string Read(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new ArgumentException("Fixture path must be provided.", nameof(relativePath));
}
var normalized = relativePath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
var path = Path.Combine(FixturesRoot, normalized);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Fixture '{relativePath}' not found at '{path}'.", path);
}
var content = File.ReadAllText(path);
return NormalizeLineEndings(content);
}
public static string Normalize(string value) => NormalizeLineEndings(value);
private static string NormalizeLineEndings(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
}
using System;
using System.IO;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
internal static class FixtureLoader
{
private static readonly string FixturesRoot = Path.Combine(AppContext.BaseDirectory, "Fixtures");
public static string Read(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new ArgumentException("Fixture path must be provided.", nameof(relativePath));
}
var normalized = relativePath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
var path = Path.Combine(FixturesRoot, normalized);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Fixture '{relativePath}' not found at '{path}'.", path);
}
var content = File.ReadAllText(path);
return NormalizeLineEndings(content);
}
public static string Normalize(string value) => NormalizeLineEndings(value);
private static string NormalizeLineEndings(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
}

View File

@@ -1,47 +1,47 @@
using System;
using StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
public sealed class MirrorAdvisoryMapperTests
{
[Fact]
public void Map_ProducesCanonicalAdvisoryWithMirrorProvenance()
{
var bundle = SampleData.CreateBundle();
var bundleJson = CanonicalJsonSerializer.SerializeIndented(bundle);
Assert.Equal(
FixtureLoader.Read(SampleData.BundleFixture).TrimEnd(),
FixtureLoader.Normalize(bundleJson).TrimEnd());
var advisories = MirrorAdvisoryMapper.Map(bundle);
Assert.Single(advisories);
var advisory = advisories[0];
var expectedAdvisory = SampleData.CreateExpectedMappedAdvisory();
var expectedJson = CanonicalJsonSerializer.SerializeIndented(expectedAdvisory);
Assert.Equal(
FixtureLoader.Read(SampleData.AdvisoryFixture).TrimEnd(),
FixtureLoader.Normalize(expectedJson).TrimEnd());
var actualJson = CanonicalJsonSerializer.SerializeIndented(advisory);
Assert.Equal(
FixtureLoader.Normalize(expectedJson).TrimEnd(),
FixtureLoader.Normalize(actualJson).TrimEnd());
Assert.Contains(advisory.Aliases, alias => string.Equals(alias, advisory.AdvisoryKey, StringComparison.OrdinalIgnoreCase));
Assert.Contains(
advisory.Provenance,
provenance => string.Equals(provenance.Source, StellaOpsMirrorConnector.Source, StringComparison.Ordinal) &&
string.Equals(provenance.Kind, "map", StringComparison.Ordinal));
var package = Assert.Single(advisory.AffectedPackages);
Assert.Contains(
package.Provenance,
provenance => string.Equals(provenance.Source, StellaOpsMirrorConnector.Source, StringComparison.Ordinal) &&
string.Equals(provenance.Kind, "map", StringComparison.Ordinal));
}
}
using System;
using StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
public sealed class MirrorAdvisoryMapperTests
{
[Fact]
public void Map_ProducesCanonicalAdvisoryWithMirrorProvenance()
{
var bundle = SampleData.CreateBundle();
var bundleJson = CanonicalJsonSerializer.SerializeIndented(bundle);
Assert.Equal(
FixtureLoader.Read(SampleData.BundleFixture).TrimEnd(),
FixtureLoader.Normalize(bundleJson).TrimEnd());
var advisories = MirrorAdvisoryMapper.Map(bundle);
Assert.Single(advisories);
var advisory = advisories[0];
var expectedAdvisory = SampleData.CreateExpectedMappedAdvisory();
var expectedJson = CanonicalJsonSerializer.SerializeIndented(expectedAdvisory);
Assert.Equal(
FixtureLoader.Read(SampleData.AdvisoryFixture).TrimEnd(),
FixtureLoader.Normalize(expectedJson).TrimEnd());
var actualJson = CanonicalJsonSerializer.SerializeIndented(advisory);
Assert.Equal(
FixtureLoader.Normalize(expectedJson).TrimEnd(),
FixtureLoader.Normalize(actualJson).TrimEnd());
Assert.Contains(advisory.Aliases, alias => string.Equals(alias, advisory.AdvisoryKey, StringComparison.OrdinalIgnoreCase));
Assert.Contains(
advisory.Provenance,
provenance => string.Equals(provenance.Source, StellaOpsMirrorConnector.Source, StringComparison.Ordinal) &&
string.Equals(provenance.Kind, "map", StringComparison.Ordinal));
var package = Assert.Single(advisory.AffectedPackages);
Assert.Contains(
package.Provenance,
provenance => string.Equals(provenance.Source, StellaOpsMirrorConnector.Source, StringComparison.Ordinal) &&
string.Equals(provenance.Kind, "map", StringComparison.Ordinal));
}
}

View File

@@ -1,189 +1,189 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Concelier.Connector.StellaOpsMirror.Security;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
public sealed class MirrorSignatureVerifierTests
{
[Fact]
public async Task VerifyAsync_ValidSignaturePasses()
{
var provider = new DefaultCryptoProvider();
var key = CreateSigningKey("mirror-key");
provider.UpsertSigningKey(key);
var registry = new CryptoProviderRegistry(new[] { provider });
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance, new MemoryCache(new MemoryCacheOptions()));
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
var payload = payloadText.ToUtf8Bytes();
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
await verifier.VerifyAsync(payload, signature, CancellationToken.None);
}
[Fact]
public async Task VerifyAsync_InvalidSignatureThrows()
{
var provider = new DefaultCryptoProvider();
var key = CreateSigningKey("mirror-key");
provider.UpsertSigningKey(key);
var registry = new CryptoProviderRegistry(new[] { provider });
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance, new MemoryCache(new MemoryCacheOptions()));
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
var payload = payloadText.ToUtf8Bytes();
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
var tampered = signature.Replace('a', 'b');
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(payload, tampered, CancellationToken.None));
}
[Fact]
public async Task VerifyAsync_KeyMismatchThrows()
{
var provider = new DefaultCryptoProvider();
var key = CreateSigningKey("mirror-key");
provider.UpsertSigningKey(key);
var registry = new CryptoProviderRegistry(new[] { provider });
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance, new MemoryCache(new MemoryCacheOptions()));
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
var payload = payloadText.ToUtf8Bytes();
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(
payload,
signature,
expectedKeyId: "unexpected-key",
expectedProvider: null,
fallbackPublicKeyPath: null,
cancellationToken: CancellationToken.None));
}
[Fact]
public async Task VerifyAsync_ThrowsWhenProviderMissingKey()
{
var provider = new DefaultCryptoProvider();
var key = CreateSigningKey("mirror-key");
provider.UpsertSigningKey(key);
var registry = new CryptoProviderRegistry(new[] { provider });
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance, new MemoryCache(new MemoryCacheOptions()));
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
var payload = payloadText.ToUtf8Bytes();
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
provider.RemoveSigningKey(key.Reference.KeyId);
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(
payload,
signature,
expectedKeyId: key.Reference.KeyId,
expectedProvider: provider.Name,
fallbackPublicKeyPath: null,
cancellationToken: CancellationToken.None));
}
[Fact]
public async Task VerifyAsync_UsesCachedPublicKeyWhenFileRemoved()
{
var provider = new DefaultCryptoProvider();
var signingKey = CreateSigningKey("mirror-key");
provider.UpsertSigningKey(signingKey);
var registry = new CryptoProviderRegistry(new[] { provider });
var memoryCache = new MemoryCache(new MemoryCacheOptions());
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance, memoryCache);
var payload = "{\"advisories\":[]}";
var (signature, _) = await CreateDetachedJwsAsync(provider, signingKey.Reference.KeyId, payload.ToUtf8Bytes());
provider.RemoveSigningKey(signingKey.Reference.KeyId);
var pemPath = WritePublicKeyPem(signingKey);
try
{
await verifier.VerifyAsync(payload.ToUtf8Bytes(), signature, expectedKeyId: signingKey.Reference.KeyId, expectedProvider: "default", fallbackPublicKeyPath: pemPath, cancellationToken: CancellationToken.None);
File.Delete(pemPath);
await verifier.VerifyAsync(payload.ToUtf8Bytes(), signature, expectedKeyId: signingKey.Reference.KeyId, expectedProvider: "default", fallbackPublicKeyPath: pemPath, cancellationToken: CancellationToken.None);
}
finally
{
if (File.Exists(pemPath))
{
File.Delete(pemPath);
}
}
}
private static CryptoSigningKey CreateSigningKey(string keyId)
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
return new CryptoSigningKey(new CryptoKeyReference(keyId), SignatureAlgorithms.Es256, in parameters, DateTimeOffset.UtcNow);
}
private static string WritePublicKeyPem(CryptoSigningKey signingKey)
{
using var ecdsa = ECDsa.Create(signingKey.PublicParameters);
var info = ecdsa.ExportSubjectPublicKeyInfo();
var pem = PemEncoding.Write("PUBLIC KEY", info);
var path = Path.Combine(Path.GetTempPath(), $"stellaops-mirror-{Guid.NewGuid():N}.pem");
File.WriteAllText(path, pem);
return path;
}
private static async Task<(string Signature, DateTimeOffset SignedAt)> CreateDetachedJwsAsync(
DefaultCryptoProvider provider,
string keyId,
ReadOnlyMemory<byte> payload)
{
var signer = provider.GetSigner(SignatureAlgorithms.Es256, new CryptoKeyReference(keyId));
var header = new Dictionary<string, object?>
{
["alg"] = SignatureAlgorithms.Es256,
["kid"] = keyId,
["provider"] = provider.Name,
["typ"] = "application/vnd.stellaops.concelier.mirror-bundle+jws",
["b64"] = false,
["crit"] = new[] { "b64" }
};
var headerJson = System.Text.Json.JsonSerializer.Serialize(header);
var protectedHeader = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(headerJson);
var signingInput = BuildSigningInput(protectedHeader, payload.Span);
var signatureBytes = await signer.SignAsync(signingInput, CancellationToken.None).ConfigureAwait(false);
var encodedSignature = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(signatureBytes);
return (string.Concat(protectedHeader, "..", encodedSignature), DateTimeOffset.UtcNow);
}
private static ReadOnlyMemory<byte> BuildSigningInput(string encodedHeader, ReadOnlySpan<byte> payload)
{
var headerBytes = System.Text.Encoding.ASCII.GetBytes(encodedHeader);
var buffer = new byte[headerBytes.Length + 1 + payload.Length];
headerBytes.CopyTo(buffer.AsSpan());
buffer[headerBytes.Length] = (byte)'.';
payload.CopyTo(buffer.AsSpan(headerBytes.Length + 1));
return buffer;
}
}
file static class Utf8Extensions
{
public static ReadOnlyMemory<byte> ToUtf8Bytes(this string value)
=> System.Text.Encoding.UTF8.GetBytes(value);
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Concelier.Connector.StellaOpsMirror.Security;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
public sealed class MirrorSignatureVerifierTests
{
[Fact]
public async Task VerifyAsync_ValidSignaturePasses()
{
var provider = new DefaultCryptoProvider();
var key = CreateSigningKey("mirror-key");
provider.UpsertSigningKey(key);
var registry = new CryptoProviderRegistry(new[] { provider });
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance, new MemoryCache(new MemoryCacheOptions()));
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
var payload = payloadText.ToUtf8Bytes();
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
await verifier.VerifyAsync(payload, signature, CancellationToken.None);
}
[Fact]
public async Task VerifyAsync_InvalidSignatureThrows()
{
var provider = new DefaultCryptoProvider();
var key = CreateSigningKey("mirror-key");
provider.UpsertSigningKey(key);
var registry = new CryptoProviderRegistry(new[] { provider });
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance, new MemoryCache(new MemoryCacheOptions()));
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
var payload = payloadText.ToUtf8Bytes();
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
var tampered = signature.Replace('a', 'b');
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(payload, tampered, CancellationToken.None));
}
[Fact]
public async Task VerifyAsync_KeyMismatchThrows()
{
var provider = new DefaultCryptoProvider();
var key = CreateSigningKey("mirror-key");
provider.UpsertSigningKey(key);
var registry = new CryptoProviderRegistry(new[] { provider });
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance, new MemoryCache(new MemoryCacheOptions()));
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
var payload = payloadText.ToUtf8Bytes();
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(
payload,
signature,
expectedKeyId: "unexpected-key",
expectedProvider: null,
fallbackPublicKeyPath: null,
cancellationToken: CancellationToken.None));
}
[Fact]
public async Task VerifyAsync_ThrowsWhenProviderMissingKey()
{
var provider = new DefaultCryptoProvider();
var key = CreateSigningKey("mirror-key");
provider.UpsertSigningKey(key);
var registry = new CryptoProviderRegistry(new[] { provider });
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance, new MemoryCache(new MemoryCacheOptions()));
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
var payload = payloadText.ToUtf8Bytes();
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
provider.RemoveSigningKey(key.Reference.KeyId);
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(
payload,
signature,
expectedKeyId: key.Reference.KeyId,
expectedProvider: provider.Name,
fallbackPublicKeyPath: null,
cancellationToken: CancellationToken.None));
}
[Fact]
public async Task VerifyAsync_UsesCachedPublicKeyWhenFileRemoved()
{
var provider = new DefaultCryptoProvider();
var signingKey = CreateSigningKey("mirror-key");
provider.UpsertSigningKey(signingKey);
var registry = new CryptoProviderRegistry(new[] { provider });
var memoryCache = new MemoryCache(new MemoryCacheOptions());
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance, memoryCache);
var payload = "{\"advisories\":[]}";
var (signature, _) = await CreateDetachedJwsAsync(provider, signingKey.Reference.KeyId, payload.ToUtf8Bytes());
provider.RemoveSigningKey(signingKey.Reference.KeyId);
var pemPath = WritePublicKeyPem(signingKey);
try
{
await verifier.VerifyAsync(payload.ToUtf8Bytes(), signature, expectedKeyId: signingKey.Reference.KeyId, expectedProvider: "default", fallbackPublicKeyPath: pemPath, cancellationToken: CancellationToken.None);
File.Delete(pemPath);
await verifier.VerifyAsync(payload.ToUtf8Bytes(), signature, expectedKeyId: signingKey.Reference.KeyId, expectedProvider: "default", fallbackPublicKeyPath: pemPath, cancellationToken: CancellationToken.None);
}
finally
{
if (File.Exists(pemPath))
{
File.Delete(pemPath);
}
}
}
private static CryptoSigningKey CreateSigningKey(string keyId)
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
return new CryptoSigningKey(new CryptoKeyReference(keyId), SignatureAlgorithms.Es256, in parameters, DateTimeOffset.UtcNow);
}
private static string WritePublicKeyPem(CryptoSigningKey signingKey)
{
using var ecdsa = ECDsa.Create(signingKey.PublicParameters);
var info = ecdsa.ExportSubjectPublicKeyInfo();
var pem = PemEncoding.Write("PUBLIC KEY", info);
var path = Path.Combine(Path.GetTempPath(), $"stellaops-mirror-{Guid.NewGuid():N}.pem");
File.WriteAllText(path, pem);
return path;
}
private static async Task<(string Signature, DateTimeOffset SignedAt)> CreateDetachedJwsAsync(
DefaultCryptoProvider provider,
string keyId,
ReadOnlyMemory<byte> payload)
{
var signer = provider.GetSigner(SignatureAlgorithms.Es256, new CryptoKeyReference(keyId));
var header = new Dictionary<string, object?>
{
["alg"] = SignatureAlgorithms.Es256,
["kid"] = keyId,
["provider"] = provider.Name,
["typ"] = "application/vnd.stellaops.concelier.mirror-bundle+jws",
["b64"] = false,
["crit"] = new[] { "b64" }
};
var headerJson = System.Text.Json.JsonSerializer.Serialize(header);
var protectedHeader = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(headerJson);
var signingInput = BuildSigningInput(protectedHeader, payload.Span);
var signatureBytes = await signer.SignAsync(signingInput, CancellationToken.None).ConfigureAwait(false);
var encodedSignature = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(signatureBytes);
return (string.Concat(protectedHeader, "..", encodedSignature), DateTimeOffset.UtcNow);
}
private static ReadOnlyMemory<byte> BuildSigningInput(string encodedHeader, ReadOnlySpan<byte> payload)
{
var headerBytes = System.Text.Encoding.ASCII.GetBytes(encodedHeader);
var buffer = new byte[headerBytes.Length + 1 + payload.Length];
headerBytes.CopyTo(buffer.AsSpan());
buffer[headerBytes.Length] = (byte)'.';
payload.CopyTo(buffer.AsSpan(headerBytes.Length + 1));
return buffer;
}
}
file static class Utf8Extensions
{
public static ReadOnlyMemory<byte> ToUtf8Bytes(this string value)
=> System.Text.Encoding.UTF8.GetBytes(value);
}

View File

@@ -1,265 +1,265 @@
using System;
using System.Globalization;
using StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
internal static class SampleData
{
public const string BundleFixture = "mirror-bundle.sample.json";
public const string AdvisoryFixture = "mirror-advisory.expected.json";
public const string TargetRepository = "mirror-primary";
public const string DomainId = "primary";
public const string AdvisoryKey = "CVE-2025-1111";
public const string GhsaAlias = "GHSA-xxxx-xxxx-xxxx";
public static DateTimeOffset GeneratedAt { get; } = new(2025, 10, 19, 12, 0, 0, TimeSpan.Zero);
public static MirrorBundleDocument CreateBundle()
=> new(
SchemaVersion: 1,
GeneratedAt: GeneratedAt,
TargetRepository: TargetRepository,
DomainId: DomainId,
DisplayName: "Primary Mirror",
AdvisoryCount: 1,
Advisories: new[] { CreateSourceAdvisory() },
Sources: new[]
{
new MirrorSourceSummary("ghsa", GeneratedAt, GeneratedAt, 1)
});
public static Advisory CreateExpectedMappedAdvisory()
{
var baseAdvisory = CreateSourceAdvisory();
var recordedAt = GeneratedAt.ToUniversalTime();
var mirrorValue = BuildMirrorValue(recordedAt);
var topProvenance = baseAdvisory.Provenance.Add(new AdvisoryProvenance(
StellaOpsMirrorConnector.Source,
"map",
mirrorValue,
recordedAt,
new[]
{
ProvenanceFieldMasks.Advisory,
ProvenanceFieldMasks.References,
ProvenanceFieldMasks.Credits,
ProvenanceFieldMasks.CvssMetrics,
ProvenanceFieldMasks.Weaknesses,
}));
var package = baseAdvisory.AffectedPackages[0];
var packageProvenance = package.Provenance.Add(new AdvisoryProvenance(
StellaOpsMirrorConnector.Source,
"map",
$"{mirrorValue};package={package.Identifier}",
recordedAt,
new[]
{
ProvenanceFieldMasks.AffectedPackages,
ProvenanceFieldMasks.VersionRanges,
ProvenanceFieldMasks.PackageStatuses,
ProvenanceFieldMasks.NormalizedVersions,
}));
var updatedPackage = new AffectedPackage(
package.Type,
package.Identifier,
package.Platform,
package.VersionRanges,
package.Statuses,
packageProvenance,
package.NormalizedVersions);
return new Advisory(
AdvisoryKey,
baseAdvisory.Title,
baseAdvisory.Summary,
baseAdvisory.Language,
baseAdvisory.Published,
baseAdvisory.Modified,
baseAdvisory.Severity,
baseAdvisory.ExploitKnown,
new[] { AdvisoryKey, GhsaAlias },
baseAdvisory.Credits,
baseAdvisory.References,
new[] { updatedPackage },
baseAdvisory.CvssMetrics,
topProvenance,
baseAdvisory.Description,
baseAdvisory.Cwes,
baseAdvisory.CanonicalMetricId);
}
private static Advisory CreateSourceAdvisory()
{
var recordedAt = GeneratedAt.ToUniversalTime();
var reference = new AdvisoryReference(
"https://example.com/advisory",
"advisory",
"vendor",
"Vendor bulletin",
new AdvisoryProvenance(
"ghsa",
"map",
"reference",
recordedAt,
new[]
{
ProvenanceFieldMasks.References,
}));
var credit = new AdvisoryCredit(
"Security Researcher",
"reporter",
new[] { "mailto:researcher@example.com" },
new AdvisoryProvenance(
"ghsa",
"map",
"credit",
recordedAt,
new[]
{
ProvenanceFieldMasks.Credits,
}));
var semVerPrimitive = new SemVerPrimitive(
Introduced: "1.0.0",
IntroducedInclusive: true,
Fixed: "1.2.0",
FixedInclusive: false,
LastAffected: null,
LastAffectedInclusive: true,
ConstraintExpression: ">=1.0.0,<1.2.0",
ExactValue: null);
var range = new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: "1.0.0",
fixedVersion: "1.2.0",
lastAffectedVersion: null,
rangeExpression: ">=1.0.0,<1.2.0",
provenance: new AdvisoryProvenance(
"ghsa",
"map",
"range",
recordedAt,
new[]
{
ProvenanceFieldMasks.VersionRanges,
}),
primitives: new RangePrimitives(semVerPrimitive, null, null, null));
var status = new AffectedPackageStatus(
"fixed",
new AdvisoryProvenance(
"ghsa",
"map",
"status",
recordedAt,
new[]
{
ProvenanceFieldMasks.PackageStatuses,
}));
var normalizedRule = new NormalizedVersionRule(
scheme: "semver",
type: "range",
min: "1.0.0",
minInclusive: true,
max: "1.2.0",
maxInclusive: false,
value: null,
notes: null);
var package = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1.0.0",
platform: null,
versionRanges: new[] { range },
statuses: new[] { status },
provenance: new[]
{
new AdvisoryProvenance(
"ghsa",
"map",
"package",
recordedAt,
new[]
{
ProvenanceFieldMasks.AffectedPackages,
})
},
normalizedVersions: new[] { normalizedRule });
var cvss = new CvssMetric(
"3.1",
"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
9.8,
"critical",
new AdvisoryProvenance(
"ghsa",
"map",
"cvss",
recordedAt,
new[]
{
ProvenanceFieldMasks.CvssMetrics,
}));
var weakness = new AdvisoryWeakness(
"cwe",
"CWE-79",
"Cross-site Scripting",
"https://cwe.mitre.org/data/definitions/79.html",
new[]
{
new AdvisoryProvenance(
"ghsa",
"map",
"cwe",
recordedAt,
new[]
{
ProvenanceFieldMasks.Weaknesses,
})
});
var advisory = new Advisory(
AdvisoryKey,
"Sample Mirror Advisory",
"Upstream advisory replicated through StellaOps mirror.",
"en",
published: new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
modified: new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
severity: "high",
exploitKnown: false,
aliases: new[] { GhsaAlias },
credits: new[] { credit },
references: new[] { reference },
affectedPackages: new[] { package },
cvssMetrics: new[] { cvss },
provenance: new[]
{
new AdvisoryProvenance(
"ghsa",
"map",
"advisory",
recordedAt,
new[]
{
ProvenanceFieldMasks.Advisory,
})
},
description: "Deterministic test payload distributed via mirror.",
cwes: new[] { weakness },
canonicalMetricId: "cvss::ghsa::CVE-2025-1111");
return CanonicalJsonSerializer.Normalize(advisory);
}
private static string BuildMirrorValue(DateTimeOffset recordedAt)
=> $"domain={DomainId};repository={TargetRepository};generated={recordedAt.ToString("O", CultureInfo.InvariantCulture)}";
}
using System;
using System.Globalization;
using StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
internal static class SampleData
{
public const string BundleFixture = "mirror-bundle.sample.json";
public const string AdvisoryFixture = "mirror-advisory.expected.json";
public const string TargetRepository = "mirror-primary";
public const string DomainId = "primary";
public const string AdvisoryKey = "CVE-2025-1111";
public const string GhsaAlias = "GHSA-xxxx-xxxx-xxxx";
public static DateTimeOffset GeneratedAt { get; } = new(2025, 10, 19, 12, 0, 0, TimeSpan.Zero);
public static MirrorBundleDocument CreateBundle()
=> new(
SchemaVersion: 1,
GeneratedAt: GeneratedAt,
TargetRepository: TargetRepository,
DomainId: DomainId,
DisplayName: "Primary Mirror",
AdvisoryCount: 1,
Advisories: new[] { CreateSourceAdvisory() },
Sources: new[]
{
new MirrorSourceSummary("ghsa", GeneratedAt, GeneratedAt, 1)
});
public static Advisory CreateExpectedMappedAdvisory()
{
var baseAdvisory = CreateSourceAdvisory();
var recordedAt = GeneratedAt.ToUniversalTime();
var mirrorValue = BuildMirrorValue(recordedAt);
var topProvenance = baseAdvisory.Provenance.Add(new AdvisoryProvenance(
StellaOpsMirrorConnector.Source,
"map",
mirrorValue,
recordedAt,
new[]
{
ProvenanceFieldMasks.Advisory,
ProvenanceFieldMasks.References,
ProvenanceFieldMasks.Credits,
ProvenanceFieldMasks.CvssMetrics,
ProvenanceFieldMasks.Weaknesses,
}));
var package = baseAdvisory.AffectedPackages[0];
var packageProvenance = package.Provenance.Add(new AdvisoryProvenance(
StellaOpsMirrorConnector.Source,
"map",
$"{mirrorValue};package={package.Identifier}",
recordedAt,
new[]
{
ProvenanceFieldMasks.AffectedPackages,
ProvenanceFieldMasks.VersionRanges,
ProvenanceFieldMasks.PackageStatuses,
ProvenanceFieldMasks.NormalizedVersions,
}));
var updatedPackage = new AffectedPackage(
package.Type,
package.Identifier,
package.Platform,
package.VersionRanges,
package.Statuses,
packageProvenance,
package.NormalizedVersions);
return new Advisory(
AdvisoryKey,
baseAdvisory.Title,
baseAdvisory.Summary,
baseAdvisory.Language,
baseAdvisory.Published,
baseAdvisory.Modified,
baseAdvisory.Severity,
baseAdvisory.ExploitKnown,
new[] { AdvisoryKey, GhsaAlias },
baseAdvisory.Credits,
baseAdvisory.References,
new[] { updatedPackage },
baseAdvisory.CvssMetrics,
topProvenance,
baseAdvisory.Description,
baseAdvisory.Cwes,
baseAdvisory.CanonicalMetricId);
}
private static Advisory CreateSourceAdvisory()
{
var recordedAt = GeneratedAt.ToUniversalTime();
var reference = new AdvisoryReference(
"https://example.com/advisory",
"advisory",
"vendor",
"Vendor bulletin",
new AdvisoryProvenance(
"ghsa",
"map",
"reference",
recordedAt,
new[]
{
ProvenanceFieldMasks.References,
}));
var credit = new AdvisoryCredit(
"Security Researcher",
"reporter",
new[] { "mailto:researcher@example.com" },
new AdvisoryProvenance(
"ghsa",
"map",
"credit",
recordedAt,
new[]
{
ProvenanceFieldMasks.Credits,
}));
var semVerPrimitive = new SemVerPrimitive(
Introduced: "1.0.0",
IntroducedInclusive: true,
Fixed: "1.2.0",
FixedInclusive: false,
LastAffected: null,
LastAffectedInclusive: true,
ConstraintExpression: ">=1.0.0,<1.2.0",
ExactValue: null);
var range = new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: "1.0.0",
fixedVersion: "1.2.0",
lastAffectedVersion: null,
rangeExpression: ">=1.0.0,<1.2.0",
provenance: new AdvisoryProvenance(
"ghsa",
"map",
"range",
recordedAt,
new[]
{
ProvenanceFieldMasks.VersionRanges,
}),
primitives: new RangePrimitives(semVerPrimitive, null, null, null));
var status = new AffectedPackageStatus(
"fixed",
new AdvisoryProvenance(
"ghsa",
"map",
"status",
recordedAt,
new[]
{
ProvenanceFieldMasks.PackageStatuses,
}));
var normalizedRule = new NormalizedVersionRule(
scheme: "semver",
type: "range",
min: "1.0.0",
minInclusive: true,
max: "1.2.0",
maxInclusive: false,
value: null,
notes: null);
var package = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1.0.0",
platform: null,
versionRanges: new[] { range },
statuses: new[] { status },
provenance: new[]
{
new AdvisoryProvenance(
"ghsa",
"map",
"package",
recordedAt,
new[]
{
ProvenanceFieldMasks.AffectedPackages,
})
},
normalizedVersions: new[] { normalizedRule });
var cvss = new CvssMetric(
"3.1",
"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
9.8,
"critical",
new AdvisoryProvenance(
"ghsa",
"map",
"cvss",
recordedAt,
new[]
{
ProvenanceFieldMasks.CvssMetrics,
}));
var weakness = new AdvisoryWeakness(
"cwe",
"CWE-79",
"Cross-site Scripting",
"https://cwe.mitre.org/data/definitions/79.html",
new[]
{
new AdvisoryProvenance(
"ghsa",
"map",
"cwe",
recordedAt,
new[]
{
ProvenanceFieldMasks.Weaknesses,
})
});
var advisory = new Advisory(
AdvisoryKey,
"Sample Mirror Advisory",
"Upstream advisory replicated through StellaOps mirror.",
"en",
published: new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
modified: new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
severity: "high",
exploitKnown: false,
aliases: new[] { GhsaAlias },
credits: new[] { credit },
references: new[] { reference },
affectedPackages: new[] { package },
cvssMetrics: new[] { cvss },
provenance: new[]
{
new AdvisoryProvenance(
"ghsa",
"map",
"advisory",
recordedAt,
new[]
{
ProvenanceFieldMasks.Advisory,
})
},
description: "Deterministic test payload distributed via mirror.",
cwes: new[] { weakness },
canonicalMetricId: "cvss::ghsa::CVE-2025-1111");
return CanonicalJsonSerializer.Normalize(advisory);
}
private static string BuildMirrorValue(DateTimeOffset recordedAt)
=> $"domain={DomainId};repository={TargetRepository};generated={recordedAt.ToString("O", CultureInfo.InvariantCulture)}";
}

View File

@@ -12,7 +12,7 @@ using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Testing;
@@ -88,20 +88,20 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
Assert.NotNull(state);
var cursorDocument = state!.Cursor ?? new BsonDocument();
var cursorDocument = state!.Cursor ?? new DocumentObject();
var digestValue = cursorDocument.TryGetValue("bundleDigest", out var digestBson) ? digestBson.AsString : string.Empty;
Assert.Equal(NormalizeDigest(bundleDigest), NormalizeDigest(digestValue));
var pendingDocumentsArray = cursorDocument.TryGetValue("pendingDocuments", out var pendingDocsBson) && pendingDocsBson is BsonArray pendingArray
var pendingDocumentsArray = cursorDocument.TryGetValue("pendingDocuments", out var pendingDocsBson) && pendingDocsBson is DocumentArray pendingArray
? pendingArray
: new BsonArray();
: new DocumentArray();
Assert.Single(pendingDocumentsArray);
var pendingDocumentId = Guid.Parse(pendingDocumentsArray[0].AsString);
Assert.Equal(bundleDocument.Id, pendingDocumentId);
var pendingMappingsArray = cursorDocument.TryGetValue("pendingMappings", out var pendingMappingsBson) && pendingMappingsBson is BsonArray mappingsArray
var pendingMappingsArray = cursorDocument.TryGetValue("pendingMappings", out var pendingMappingsBson) && pendingMappingsBson is DocumentArray mappingsArray
? mappingsArray
: new BsonArray();
: new DocumentArray();
Assert.Empty(pendingMappingsArray);
}
@@ -240,7 +240,7 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(StellaOpsMirrorConnector.Source, CancellationToken.None);
Assert.NotNull(state);
var cursor = state!.Cursor ?? new BsonDocument();
var cursor = state!.Cursor ?? new DocumentObject();
Assert.True(state.FailCount >= 1);
Assert.False(cursor.Contains("bundleDigest"));
}

View File

@@ -1,26 +1,26 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Vndr.Adobe;
using StellaOps.Concelier.Connector.Vndr.Adobe.Configuration;
using StellaOps.Concelier.Connector.Vndr.Adobe;
using StellaOps.Concelier.Connector.Vndr.Adobe.Configuration;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
@@ -28,432 +28,432 @@ using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.PsirtFlags;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Testing;
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Tests;
namespace StellaOps.Concelier.Connector.Vndr.Adobe.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class AdobeConnectorFetchTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
public AdobeConnectorFetchTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 9, 10, 0, 0, 0, TimeSpan.Zero));
}
[Fact]
public async Task Fetch_WindowsIndexAndPersistsCursor()
{
var handler = new CannedHttpMessageHandler();
await using var provider = await BuildServiceProviderAsync(handler);
SeedIndex(handler);
SeedDetail(handler);
var connector = provider.GetRequiredService<AdobeConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrAdobeConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = state!.Cursor;
var pendingDocuments = ExtractGuidList(cursor, "pendingDocuments");
Assert.Equal(2, pendingDocuments.Count);
// Re-seed responses to simulate unchanged fetch
SeedIndex(handler);
SeedDetail(handler);
await connector.FetchAsync(provider, CancellationToken.None);
state = await stateRepository.TryGetAsync(VndrAdobeConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
cursor = state!.Cursor;
var afterPending = ExtractGuidList(cursor, "pendingDocuments");
Assert.Equal(pendingDocuments.OrderBy(static id => id), afterPending.OrderBy(static id => id));
var fetchCache = cursor.TryGetValue("fetchCache", out var fetchCacheValue) && fetchCacheValue is BsonDocument cacheDoc
? cacheDoc.Elements.Select(static e => e.Name).ToArray()
: Array.Empty<string>();
Assert.Contains("https://helpx.adobe.com/security/products/acrobat/apsb25-85.html", fetchCache);
Assert.Contains("https://helpx.adobe.com/security/products/premiere_pro/apsb25-87.html", fetchCache);
}
[Fact]
public async Task Parse_ProducesDtoAndClearsPendingDocuments()
{
var handler = new CannedHttpMessageHandler();
await using var provider = await BuildServiceProviderAsync(handler);
SeedIndex(handler);
SeedDetail(handler);
var connector = provider.GetRequiredService<AdobeConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var dtoStore = provider.GetRequiredService<IDtoStore>();
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var psirtStore = provider.GetRequiredService<IPsirtFlagStore>();
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var document = await documentStore.FindBySourceAndUriAsync(
VndrAdobeConnectorPlugin.SourceName,
"https://helpx.adobe.com/security/products/acrobat/apsb25-85.html",
CancellationToken.None);
Assert.NotNull(document);
var dtoRecord = await dtoStore.FindByDocumentIdAsync(document!.Id, CancellationToken.None);
Assert.NotNull(dtoRecord);
Assert.Equal("adobe.bulletin.v1", dtoRecord!.SchemaVersion);
var payload = dtoRecord.Payload;
Assert.Equal("APSB25-85", payload.GetValue("advisoryId").AsString);
Assert.Equal("https://helpx.adobe.com/security/products/acrobat/apsb25-85.html", payload.GetValue("detailUrl").AsString);
var products = payload.GetValue("products").AsBsonArray
.Select(static value => value.AsBsonDocument)
.ToArray();
Assert.NotEmpty(products);
var acrobatWindowsProduct = Assert.Single(
products,
static doc => string.Equals(doc.GetValue("product").AsString, "Acrobat DC", StringComparison.Ordinal)
&& string.Equals(doc.GetValue("platform").AsString, "Windows", StringComparison.Ordinal));
Assert.Equal("25.001.20672 and earlier", acrobatWindowsProduct.GetValue("affectedVersion").AsString);
Assert.Equal("25.001.20680", acrobatWindowsProduct.GetValue("updatedVersion").AsString);
var acrobatMacProduct = Assert.Single(
products,
static doc => string.Equals(doc.GetValue("product").AsString, "Acrobat DC", StringComparison.Ordinal)
&& string.Equals(doc.GetValue("platform").AsString, "macOS", StringComparison.Ordinal));
Assert.Equal("25.001.20668 and earlier", acrobatMacProduct.GetValue("affectedVersion").AsString);
Assert.Equal("25.001.20678", acrobatMacProduct.GetValue("updatedVersion").AsString);
var state = await stateRepository.TryGetAsync(VndrAdobeConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = state!.Cursor;
Assert.True(!cursor.TryGetValue("pendingDocuments", out _)
|| cursor.GetValue("pendingDocuments").AsBsonArray.Count == 0);
Assert.True(!cursor.TryGetValue("pendingMappings", out _)
|| cursor.GetValue("pendingMappings").AsBsonArray.Count == 0);
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var acrobatAdvisory = advisories.Single(a => a.AdvisoryKey == "APSB25-85");
Assert.Contains("APSB25-85", acrobatAdvisory.Aliases);
Assert.Equal(
acrobatAdvisory.References.Select(static r => r.Url).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
acrobatAdvisory.References.Length);
var acrobatWindowsPackage = Assert.Single(
acrobatAdvisory.AffectedPackages,
pkg => string.Equals(pkg.Identifier, "Acrobat DC", StringComparison.Ordinal)
&& string.Equals(pkg.Platform, "Windows", StringComparison.Ordinal));
var acrobatWindowsRange = Assert.Single(acrobatWindowsPackage.VersionRanges);
Assert.Equal("vendor", acrobatWindowsRange.RangeKind);
Assert.Equal("25.001.20680", acrobatWindowsRange.FixedVersion);
Assert.Equal("25.001.20672", acrobatWindowsRange.LastAffectedVersion);
Assert.NotNull(acrobatWindowsRange.Primitives);
var windowsExtensions = acrobatWindowsRange.Primitives!.VendorExtensions;
Assert.NotNull(windowsExtensions);
Assert.True(windowsExtensions!.TryGetValue("adobe.affected.raw", out var rawAffectedWin));
Assert.Equal("25.001.20672 and earlier", rawAffectedWin);
Assert.True(windowsExtensions.TryGetValue("adobe.updated.raw", out var rawUpdatedWin));
Assert.Equal("25.001.20680", rawUpdatedWin);
Assert.Contains(
AffectedPackageStatusCatalog.Fixed,
acrobatWindowsPackage.Statuses.Select(static status => status.Status));
var windowsNormalized = Assert.Single(acrobatWindowsPackage.NormalizedVersions.ToArray());
Assert.Equal(NormalizedVersionSchemes.SemVer, windowsNormalized.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThan, windowsNormalized.Type);
Assert.Equal("25.1.20680", windowsNormalized.Max);
Assert.False(windowsNormalized.MaxInclusive);
Assert.Equal("adobe:Acrobat DC:Windows", windowsNormalized.Notes);
var acrobatMacPackage = Assert.Single(
acrobatAdvisory.AffectedPackages,
pkg => string.Equals(pkg.Identifier, "Acrobat DC", StringComparison.Ordinal)
&& string.Equals(pkg.Platform, "macOS", StringComparison.Ordinal));
var acrobatMacRange = Assert.Single(acrobatMacPackage.VersionRanges);
Assert.Equal("vendor", acrobatMacRange.RangeKind);
Assert.Equal("25.001.20678", acrobatMacRange.FixedVersion);
Assert.Equal("25.001.20668", acrobatMacRange.LastAffectedVersion);
Assert.NotNull(acrobatMacRange.Primitives);
var macExtensions = acrobatMacRange.Primitives!.VendorExtensions;
Assert.NotNull(macExtensions);
Assert.True(macExtensions!.TryGetValue("adobe.affected.raw", out var rawAffectedMac));
Assert.Equal("25.001.20668 and earlier", rawAffectedMac);
Assert.True(macExtensions.TryGetValue("adobe.updated.raw", out var rawUpdatedMac));
Assert.Equal("25.001.20678", rawUpdatedMac);
Assert.Contains(
AffectedPackageStatusCatalog.Fixed,
acrobatMacPackage.Statuses.Select(static status => status.Status));
var macNormalized = Assert.Single(acrobatMacPackage.NormalizedVersions.ToArray());
Assert.Equal(NormalizedVersionSchemes.SemVer, macNormalized.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThan, macNormalized.Type);
Assert.Equal("25.1.20678", macNormalized.Max);
Assert.False(macNormalized.MaxInclusive);
Assert.Equal("adobe:Acrobat DC:macOS", macNormalized.Notes);
var premiereAdvisory = advisories.Single(a => a.AdvisoryKey == "APSB25-87");
Assert.Contains("APSB25-87", premiereAdvisory.Aliases);
Assert.Equal(
premiereAdvisory.References.Select(static r => r.Url).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
premiereAdvisory.References.Length);
var premiereWindowsPackage = Assert.Single(
premiereAdvisory.AffectedPackages,
pkg => string.Equals(pkg.Identifier, "Premiere Pro", StringComparison.Ordinal)
&& string.Equals(pkg.Platform, "Windows", StringComparison.Ordinal));
var premiereWindowsRange = Assert.Single(premiereWindowsPackage.VersionRanges);
Assert.Equal("24.6", premiereWindowsRange.FixedVersion);
Assert.Equal("24.5", premiereWindowsRange.LastAffectedVersion);
Assert.NotNull(premiereWindowsRange.Primitives);
var premiereWindowsExtensions = premiereWindowsRange.Primitives!.VendorExtensions;
Assert.NotNull(premiereWindowsExtensions);
Assert.True(premiereWindowsExtensions!.TryGetValue("adobe.priority", out var premierePriorityWin));
Assert.Equal("Priority 3", premierePriorityWin);
Assert.Contains(
AffectedPackageStatusCatalog.Fixed,
premiereWindowsPackage.Statuses.Select(static status => status.Status));
var premiereWinNormalized = Assert.Single(premiereWindowsPackage.NormalizedVersions.ToArray());
Assert.Equal(NormalizedVersionSchemes.SemVer, premiereWinNormalized.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThan, premiereWinNormalized.Type);
Assert.Equal("24.6", premiereWinNormalized.Max);
Assert.False(premiereWinNormalized.MaxInclusive);
Assert.Equal("adobe:Premiere Pro:Windows", premiereWinNormalized.Notes);
var premiereMacPackage = Assert.Single(
premiereAdvisory.AffectedPackages,
pkg => string.Equals(pkg.Identifier, "Premiere Pro", StringComparison.Ordinal)
&& string.Equals(pkg.Platform, "macOS", StringComparison.Ordinal));
var premiereMacRange = Assert.Single(premiereMacPackage.VersionRanges);
Assert.Equal("24.6", premiereMacRange.FixedVersion);
Assert.Equal("24.5", premiereMacRange.LastAffectedVersion);
Assert.NotNull(premiereMacRange.Primitives);
var premiereMacExtensions = premiereMacRange.Primitives!.VendorExtensions;
Assert.NotNull(premiereMacExtensions);
Assert.True(premiereMacExtensions!.TryGetValue("adobe.priority", out var premierePriorityMac));
Assert.Equal("Priority 3", premierePriorityMac);
Assert.Contains(
AffectedPackageStatusCatalog.Fixed,
premiereMacPackage.Statuses.Select(static status => status.Status));
var premiereMacNormalized = Assert.Single(premiereMacPackage.NormalizedVersions.ToArray());
Assert.Equal(NormalizedVersionSchemes.SemVer, premiereMacNormalized.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThan, premiereMacNormalized.Type);
Assert.Equal("24.6", premiereMacNormalized.Max);
Assert.False(premiereMacNormalized.MaxInclusive);
Assert.Equal("adobe:Premiere Pro:macOS", premiereMacNormalized.Notes);
var ordered = advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray();
var snapshot = SnapshotSerializer.ToSnapshot(ordered);
var expected = ReadFixture("adobe-advisories.snapshot.json");
var normalizedSnapshot = NormalizeLineEndings(snapshot);
var normalizedExpected = NormalizeLineEndings(expected);
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Adobe", "Fixtures", "adobe-advisories.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(normalizedExpected, normalizedSnapshot);
public sealed class AdobeConnectorFetchTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
public AdobeConnectorFetchTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 9, 10, 0, 0, 0, TimeSpan.Zero));
}
[Fact]
public async Task Fetch_WindowsIndexAndPersistsCursor()
{
var handler = new CannedHttpMessageHandler();
await using var provider = await BuildServiceProviderAsync(handler);
SeedIndex(handler);
SeedDetail(handler);
var connector = provider.GetRequiredService<AdobeConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrAdobeConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = state!.Cursor;
var pendingDocuments = ExtractGuidList(cursor, "pendingDocuments");
Assert.Equal(2, pendingDocuments.Count);
// Re-seed responses to simulate unchanged fetch
SeedIndex(handler);
SeedDetail(handler);
await connector.FetchAsync(provider, CancellationToken.None);
state = await stateRepository.TryGetAsync(VndrAdobeConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
cursor = state!.Cursor;
var afterPending = ExtractGuidList(cursor, "pendingDocuments");
Assert.Equal(pendingDocuments.OrderBy(static id => id), afterPending.OrderBy(static id => id));
var fetchCache = cursor.TryGetValue("fetchCache", out var fetchCacheValue) && fetchCacheValue is DocumentObject cacheDoc
? cacheDoc.Elements.Select(static e => e.Name).ToArray()
: Array.Empty<string>();
Assert.Contains("https://helpx.adobe.com/security/products/acrobat/apsb25-85.html", fetchCache);
Assert.Contains("https://helpx.adobe.com/security/products/premiere_pro/apsb25-87.html", fetchCache);
}
[Fact]
public async Task Parse_ProducesDtoAndClearsPendingDocuments()
{
var handler = new CannedHttpMessageHandler();
await using var provider = await BuildServiceProviderAsync(handler);
SeedIndex(handler);
SeedDetail(handler);
var connector = provider.GetRequiredService<AdobeConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var dtoStore = provider.GetRequiredService<IDtoStore>();
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var psirtStore = provider.GetRequiredService<IPsirtFlagStore>();
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var document = await documentStore.FindBySourceAndUriAsync(
VndrAdobeConnectorPlugin.SourceName,
"https://helpx.adobe.com/security/products/acrobat/apsb25-85.html",
CancellationToken.None);
Assert.NotNull(document);
var dtoRecord = await dtoStore.FindByDocumentIdAsync(document!.Id, CancellationToken.None);
Assert.NotNull(dtoRecord);
Assert.Equal("adobe.bulletin.v1", dtoRecord!.SchemaVersion);
var payload = dtoRecord.Payload;
Assert.Equal("APSB25-85", payload.GetValue("advisoryId").AsString);
Assert.Equal("https://helpx.adobe.com/security/products/acrobat/apsb25-85.html", payload.GetValue("detailUrl").AsString);
var products = payload.GetValue("products").AsDocumentArray
.Select(static value => value.AsDocumentObject)
.ToArray();
Assert.NotEmpty(products);
var acrobatWindowsProduct = Assert.Single(
products,
static doc => string.Equals(doc.GetValue("product").AsString, "Acrobat DC", StringComparison.Ordinal)
&& string.Equals(doc.GetValue("platform").AsString, "Windows", StringComparison.Ordinal));
Assert.Equal("25.001.20672 and earlier", acrobatWindowsProduct.GetValue("affectedVersion").AsString);
Assert.Equal("25.001.20680", acrobatWindowsProduct.GetValue("updatedVersion").AsString);
var acrobatMacProduct = Assert.Single(
products,
static doc => string.Equals(doc.GetValue("product").AsString, "Acrobat DC", StringComparison.Ordinal)
&& string.Equals(doc.GetValue("platform").AsString, "macOS", StringComparison.Ordinal));
Assert.Equal("25.001.20668 and earlier", acrobatMacProduct.GetValue("affectedVersion").AsString);
Assert.Equal("25.001.20678", acrobatMacProduct.GetValue("updatedVersion").AsString);
var state = await stateRepository.TryGetAsync(VndrAdobeConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = state!.Cursor;
Assert.True(!cursor.TryGetValue("pendingDocuments", out _)
|| cursor.GetValue("pendingDocuments").AsDocumentArray.Count == 0);
Assert.True(!cursor.TryGetValue("pendingMappings", out _)
|| cursor.GetValue("pendingMappings").AsDocumentArray.Count == 0);
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
Assert.Equal(2, advisories.Count);
var acrobatAdvisory = advisories.Single(a => a.AdvisoryKey == "APSB25-85");
Assert.Contains("APSB25-85", acrobatAdvisory.Aliases);
Assert.Equal(
acrobatAdvisory.References.Select(static r => r.Url).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
acrobatAdvisory.References.Length);
var acrobatWindowsPackage = Assert.Single(
acrobatAdvisory.AffectedPackages,
pkg => string.Equals(pkg.Identifier, "Acrobat DC", StringComparison.Ordinal)
&& string.Equals(pkg.Platform, "Windows", StringComparison.Ordinal));
var acrobatWindowsRange = Assert.Single(acrobatWindowsPackage.VersionRanges);
Assert.Equal("vendor", acrobatWindowsRange.RangeKind);
Assert.Equal("25.001.20680", acrobatWindowsRange.FixedVersion);
Assert.Equal("25.001.20672", acrobatWindowsRange.LastAffectedVersion);
Assert.NotNull(acrobatWindowsRange.Primitives);
var windowsExtensions = acrobatWindowsRange.Primitives!.VendorExtensions;
Assert.NotNull(windowsExtensions);
Assert.True(windowsExtensions!.TryGetValue("adobe.affected.raw", out var rawAffectedWin));
Assert.Equal("25.001.20672 and earlier", rawAffectedWin);
Assert.True(windowsExtensions.TryGetValue("adobe.updated.raw", out var rawUpdatedWin));
Assert.Equal("25.001.20680", rawUpdatedWin);
Assert.Contains(
AffectedPackageStatusCatalog.Fixed,
acrobatWindowsPackage.Statuses.Select(static status => status.Status));
var windowsNormalized = Assert.Single(acrobatWindowsPackage.NormalizedVersions.ToArray());
Assert.Equal(NormalizedVersionSchemes.SemVer, windowsNormalized.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThan, windowsNormalized.Type);
Assert.Equal("25.1.20680", windowsNormalized.Max);
Assert.False(windowsNormalized.MaxInclusive);
Assert.Equal("adobe:Acrobat DC:Windows", windowsNormalized.Notes);
var acrobatMacPackage = Assert.Single(
acrobatAdvisory.AffectedPackages,
pkg => string.Equals(pkg.Identifier, "Acrobat DC", StringComparison.Ordinal)
&& string.Equals(pkg.Platform, "macOS", StringComparison.Ordinal));
var acrobatMacRange = Assert.Single(acrobatMacPackage.VersionRanges);
Assert.Equal("vendor", acrobatMacRange.RangeKind);
Assert.Equal("25.001.20678", acrobatMacRange.FixedVersion);
Assert.Equal("25.001.20668", acrobatMacRange.LastAffectedVersion);
Assert.NotNull(acrobatMacRange.Primitives);
var macExtensions = acrobatMacRange.Primitives!.VendorExtensions;
Assert.NotNull(macExtensions);
Assert.True(macExtensions!.TryGetValue("adobe.affected.raw", out var rawAffectedMac));
Assert.Equal("25.001.20668 and earlier", rawAffectedMac);
Assert.True(macExtensions.TryGetValue("adobe.updated.raw", out var rawUpdatedMac));
Assert.Equal("25.001.20678", rawUpdatedMac);
Assert.Contains(
AffectedPackageStatusCatalog.Fixed,
acrobatMacPackage.Statuses.Select(static status => status.Status));
var macNormalized = Assert.Single(acrobatMacPackage.NormalizedVersions.ToArray());
Assert.Equal(NormalizedVersionSchemes.SemVer, macNormalized.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThan, macNormalized.Type);
Assert.Equal("25.1.20678", macNormalized.Max);
Assert.False(macNormalized.MaxInclusive);
Assert.Equal("adobe:Acrobat DC:macOS", macNormalized.Notes);
var premiereAdvisory = advisories.Single(a => a.AdvisoryKey == "APSB25-87");
Assert.Contains("APSB25-87", premiereAdvisory.Aliases);
Assert.Equal(
premiereAdvisory.References.Select(static r => r.Url).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
premiereAdvisory.References.Length);
var premiereWindowsPackage = Assert.Single(
premiereAdvisory.AffectedPackages,
pkg => string.Equals(pkg.Identifier, "Premiere Pro", StringComparison.Ordinal)
&& string.Equals(pkg.Platform, "Windows", StringComparison.Ordinal));
var premiereWindowsRange = Assert.Single(premiereWindowsPackage.VersionRanges);
Assert.Equal("24.6", premiereWindowsRange.FixedVersion);
Assert.Equal("24.5", premiereWindowsRange.LastAffectedVersion);
Assert.NotNull(premiereWindowsRange.Primitives);
var premiereWindowsExtensions = premiereWindowsRange.Primitives!.VendorExtensions;
Assert.NotNull(premiereWindowsExtensions);
Assert.True(premiereWindowsExtensions!.TryGetValue("adobe.priority", out var premierePriorityWin));
Assert.Equal("Priority 3", premierePriorityWin);
Assert.Contains(
AffectedPackageStatusCatalog.Fixed,
premiereWindowsPackage.Statuses.Select(static status => status.Status));
var premiereWinNormalized = Assert.Single(premiereWindowsPackage.NormalizedVersions.ToArray());
Assert.Equal(NormalizedVersionSchemes.SemVer, premiereWinNormalized.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThan, premiereWinNormalized.Type);
Assert.Equal("24.6", premiereWinNormalized.Max);
Assert.False(premiereWinNormalized.MaxInclusive);
Assert.Equal("adobe:Premiere Pro:Windows", premiereWinNormalized.Notes);
var premiereMacPackage = Assert.Single(
premiereAdvisory.AffectedPackages,
pkg => string.Equals(pkg.Identifier, "Premiere Pro", StringComparison.Ordinal)
&& string.Equals(pkg.Platform, "macOS", StringComparison.Ordinal));
var premiereMacRange = Assert.Single(premiereMacPackage.VersionRanges);
Assert.Equal("24.6", premiereMacRange.FixedVersion);
Assert.Equal("24.5", premiereMacRange.LastAffectedVersion);
Assert.NotNull(premiereMacRange.Primitives);
var premiereMacExtensions = premiereMacRange.Primitives!.VendorExtensions;
Assert.NotNull(premiereMacExtensions);
Assert.True(premiereMacExtensions!.TryGetValue("adobe.priority", out var premierePriorityMac));
Assert.Equal("Priority 3", premierePriorityMac);
Assert.Contains(
AffectedPackageStatusCatalog.Fixed,
premiereMacPackage.Statuses.Select(static status => status.Status));
var premiereMacNormalized = Assert.Single(premiereMacPackage.NormalizedVersions.ToArray());
Assert.Equal(NormalizedVersionSchemes.SemVer, premiereMacNormalized.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThan, premiereMacNormalized.Type);
Assert.Equal("24.6", premiereMacNormalized.Max);
Assert.False(premiereMacNormalized.MaxInclusive);
Assert.Equal("adobe:Premiere Pro:macOS", premiereMacNormalized.Notes);
var ordered = advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray();
var snapshot = SnapshotSerializer.ToSnapshot(ordered);
var expected = ReadFixture("adobe-advisories.snapshot.json");
var normalizedSnapshot = NormalizeLineEndings(snapshot);
var normalizedExpected = NormalizeLineEndings(expected);
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Adobe", "Fixtures", "adobe-advisories.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(normalizedExpected, normalizedSnapshot);
var psirtStore = provider.GetRequiredService<IPsirtFlagStore>();
var flagRecord = await psirtStore.FindAsync("APSB25-87", CancellationToken.None);
Assert.NotNull(flagRecord);
Assert.Equal("Adobe", flagRecord!.Vendor);
}
[Fact]
public async Task Fetch_WithNotModifiedResponses_KeepsDocumentsMapped()
{
var handler = new CannedHttpMessageHandler();
await using var provider = await BuildServiceProviderAsync(handler);
SeedIndex(handler);
SeedDetail(handler);
var connector = provider.GetRequiredService<AdobeConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var acrobatDoc = await documentStore.FindBySourceAndUriAsync(
VndrAdobeConnectorPlugin.SourceName,
"https://helpx.adobe.com/security/products/acrobat/apsb25-85.html",
CancellationToken.None);
Assert.NotNull(acrobatDoc);
Assert.Equal(DocumentStatuses.Mapped, acrobatDoc!.Status);
var premiereDoc = await documentStore.FindBySourceAndUriAsync(
VndrAdobeConnectorPlugin.SourceName,
"https://helpx.adobe.com/security/products/premiere_pro/apsb25-87.html",
CancellationToken.None);
Assert.NotNull(premiereDoc);
Assert.Equal(DocumentStatuses.Mapped, premiereDoc!.Status);
SeedIndex(handler);
SeedDetailNotModified(handler);
await connector.FetchAsync(provider, CancellationToken.None);
acrobatDoc = await documentStore.FindBySourceAndUriAsync(
VndrAdobeConnectorPlugin.SourceName,
"https://helpx.adobe.com/security/products/acrobat/apsb25-85.html",
CancellationToken.None);
Assert.NotNull(acrobatDoc);
Assert.Equal(DocumentStatuses.Mapped, acrobatDoc!.Status);
premiereDoc = await documentStore.FindBySourceAndUriAsync(
VndrAdobeConnectorPlugin.SourceName,
"https://helpx.adobe.com/security/products/premiere_pro/apsb25-87.html",
CancellationToken.None);
Assert.NotNull(premiereDoc);
Assert.Equal(DocumentStatuses.Mapped, premiereDoc!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrAdobeConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsBsonArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMap) && pendingMap.AsBsonArray.Count == 0);
}
}
[Fact]
public async Task Fetch_WithNotModifiedResponses_KeepsDocumentsMapped()
{
var handler = new CannedHttpMessageHandler();
await using var provider = await BuildServiceProviderAsync(handler);
SeedIndex(handler);
SeedDetail(handler);
var connector = provider.GetRequiredService<AdobeConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var acrobatDoc = await documentStore.FindBySourceAndUriAsync(
VndrAdobeConnectorPlugin.SourceName,
"https://helpx.adobe.com/security/products/acrobat/apsb25-85.html",
CancellationToken.None);
Assert.NotNull(acrobatDoc);
Assert.Equal(DocumentStatuses.Mapped, acrobatDoc!.Status);
var premiereDoc = await documentStore.FindBySourceAndUriAsync(
VndrAdobeConnectorPlugin.SourceName,
"https://helpx.adobe.com/security/products/premiere_pro/apsb25-87.html",
CancellationToken.None);
Assert.NotNull(premiereDoc);
Assert.Equal(DocumentStatuses.Mapped, premiereDoc!.Status);
SeedIndex(handler);
SeedDetailNotModified(handler);
await connector.FetchAsync(provider, CancellationToken.None);
acrobatDoc = await documentStore.FindBySourceAndUriAsync(
VndrAdobeConnectorPlugin.SourceName,
"https://helpx.adobe.com/security/products/acrobat/apsb25-85.html",
CancellationToken.None);
Assert.NotNull(acrobatDoc);
Assert.Equal(DocumentStatuses.Mapped, acrobatDoc!.Status);
premiereDoc = await documentStore.FindBySourceAndUriAsync(
VndrAdobeConnectorPlugin.SourceName,
"https://helpx.adobe.com/security/products/premiere_pro/apsb25-87.html",
CancellationToken.None);
Assert.NotNull(premiereDoc);
Assert.Equal(DocumentStatuses.Mapped, premiereDoc!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrAdobeConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.True(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) && pendingDocs.AsDocumentArray.Count == 0);
Assert.True(state.Cursor.TryGetValue("pendingMappings", out var pendingMap) && pendingMap.AsDocumentArray.Count == 0);
}
private async Task<ServiceProvider> BuildServiceProviderAsync(CannedHttpMessageHandler handler)
{
await _fixture.TruncateAllTablesAsync();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(handler);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(handler);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddAdobeConnector(opts =>
{
opts.IndexUri = new Uri("https://helpx.adobe.com/security/security-bulletin.html");
opts.InitialBackfill = TimeSpan.FromDays(30);
opts.WindowOverlap = TimeSpan.FromDays(2);
});
services.Configure<HttpClientFactoryOptions>(AdobeOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = handler;
});
});
services.AddSourceCommon();
services.AddAdobeConnector(opts =>
{
opts.IndexUri = new Uri("https://helpx.adobe.com/security/security-bulletin.html");
opts.InitialBackfill = TimeSpan.FromDays(30);
opts.WindowOverlap = TimeSpan.FromDays(2);
});
services.Configure<HttpClientFactoryOptions>(AdobeOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = handler;
});
});
return services.BuildServiceProvider();
}
private static void SeedIndex(CannedHttpMessageHandler handler)
{
var indexUri = new Uri("https://helpx.adobe.com/security/security-bulletin.html");
var indexHtml = ReadFixture("adobe-index.html");
handler.AddTextResponse(indexUri, indexHtml, "text/html");
}
private static void SeedDetail(CannedHttpMessageHandler handler)
{
AddDetailResponse(
handler,
new Uri("https://helpx.adobe.com/security/products/acrobat/apsb25-85.html"),
"adobe-detail-apsb25-85.html",
"\"apsb25-85\"");
AddDetailResponse(
handler,
new Uri("https://helpx.adobe.com/security/products/premiere_pro/apsb25-87.html"),
"adobe-detail-apsb25-87.html",
"\"apsb25-87\"");
}
private static void SeedDetailNotModified(CannedHttpMessageHandler handler)
{
AddNotModifiedResponse(
handler,
new Uri("https://helpx.adobe.com/security/products/acrobat/apsb25-85.html"),
"\"apsb25-85\"");
AddNotModifiedResponse(
handler,
new Uri("https://helpx.adobe.com/security/products/premiere_pro/apsb25-87.html"),
"\"apsb25-87\"");
}
private static void AddDetailResponse(CannedHttpMessageHandler handler, Uri uri, string fixture, string? etag)
{
handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/html"),
};
if (!string.IsNullOrEmpty(etag))
{
response.Headers.ETag = new EntityTagHeaderValue(etag);
}
return response;
});
}
private static void AddNotModifiedResponse(CannedHttpMessageHandler handler, Uri uri, string? etag)
{
handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
if (!string.IsNullOrEmpty(etag))
{
response.Headers.ETag = new EntityTagHeaderValue(etag);
}
return response;
});
}
private static List<Guid> ExtractGuidList(BsonDocument cursor, string field)
{
if (!cursor.TryGetValue(field, out var value) || value is not BsonArray array)
{
return new List<Guid>();
}
var list = new List<Guid>(array.Count);
foreach (var element in array)
{
if (Guid.TryParse(element.AsString, out var guid))
{
list.Add(guid);
}
}
return list;
}
private static string ReadFixture(string name)
{
var candidate = Path.Combine(AppContext.BaseDirectory, "Adobe", "Fixtures", name);
if (!File.Exists(candidate))
{
candidate = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Adobe", "Fixtures", name);
}
return File.ReadAllText(candidate);
}
private static string NormalizeLineEndings(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
}
private static void SeedIndex(CannedHttpMessageHandler handler)
{
var indexUri = new Uri("https://helpx.adobe.com/security/security-bulletin.html");
var indexHtml = ReadFixture("adobe-index.html");
handler.AddTextResponse(indexUri, indexHtml, "text/html");
}
private static void SeedDetail(CannedHttpMessageHandler handler)
{
AddDetailResponse(
handler,
new Uri("https://helpx.adobe.com/security/products/acrobat/apsb25-85.html"),
"adobe-detail-apsb25-85.html",
"\"apsb25-85\"");
AddDetailResponse(
handler,
new Uri("https://helpx.adobe.com/security/products/premiere_pro/apsb25-87.html"),
"adobe-detail-apsb25-87.html",
"\"apsb25-87\"");
}
private static void SeedDetailNotModified(CannedHttpMessageHandler handler)
{
AddNotModifiedResponse(
handler,
new Uri("https://helpx.adobe.com/security/products/acrobat/apsb25-85.html"),
"\"apsb25-85\"");
AddNotModifiedResponse(
handler,
new Uri("https://helpx.adobe.com/security/products/premiere_pro/apsb25-87.html"),
"\"apsb25-87\"");
}
private static void AddDetailResponse(CannedHttpMessageHandler handler, Uri uri, string fixture, string? etag)
{
handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/html"),
};
if (!string.IsNullOrEmpty(etag))
{
response.Headers.ETag = new EntityTagHeaderValue(etag);
}
return response;
});
}
private static void AddNotModifiedResponse(CannedHttpMessageHandler handler, Uri uri, string? etag)
{
handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.NotModified);
if (!string.IsNullOrEmpty(etag))
{
response.Headers.ETag = new EntityTagHeaderValue(etag);
}
return response;
});
}
private static List<Guid> ExtractGuidList(DocumentObject cursor, string field)
{
if (!cursor.TryGetValue(field, out var value) || value is not DocumentArray array)
{
return new List<Guid>();
}
var list = new List<Guid>(array.Count);
foreach (var element in array)
{
if (Guid.TryParse(element.AsString, out var guid))
{
list.Add(guid);
}
}
return list;
}
private static string ReadFixture(string name)
{
var candidate = Path.Combine(AppContext.BaseDirectory, "Adobe", "Fixtures", name);
if (!File.Exists(candidate))
{
candidate = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Adobe", "Fixtures", name);
}
return File.ReadAllText(candidate);
}
private static string NormalizeLineEndings(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
}

View File

@@ -1,257 +1,257 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Common.Packages;
using StellaOps.Concelier.Connector.Vndr.Apple;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Common.Packages;
using StellaOps.Concelier.Connector.Vndr.Apple;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage.PsirtFlags;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Testing;
using Xunit;
namespace StellaOps.Concelier.Connector.Vndr.Apple.Tests;
namespace StellaOps.Concelier.Connector.Vndr.Apple.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class AppleConnectorTests : IAsyncLifetime
{
private static readonly Uri IndexUri = new("https://support.example.com/index.json");
private static readonly Uri DetailBaseUri = new("https://support.example.com/en-us/");
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
public AppleConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero));
}
[Fact]
public async Task FetchParseMap_EndToEnd_ProducesCanonicalAdvisories()
{
var handler = new CannedHttpMessageHandler();
SeedIndex(handler);
SeedDetail(handler);
await using var provider = await BuildServiceProviderAsync(handler);
var connector = provider.GetRequiredService<AppleConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(5, advisories.Count);
var advisoriesByKey = advisories.ToDictionary(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal);
var iosBaseline = advisoriesByKey["125326"];
Assert.Contains("CVE-2025-43400", iosBaseline.Aliases, StringComparer.OrdinalIgnoreCase);
Assert.Equal(3, iosBaseline.AffectedPackages.Count());
AssertPackageDetails(iosBaseline, "iPhone 16 Pro", "26.0.1", "24A341", "iOS");
AssertPackageDetails(iosBaseline, "iPhone 16 Pro", "26.0.1 (a)", "24A341a", "iOS");
AssertPackageDetails(iosBaseline, "iPad Pro (M4)", "26", "24B120", "iPadOS");
var macBaseline = advisoriesByKey["125328"];
Assert.Contains("CVE-2025-43400", macBaseline.Aliases, StringComparer.OrdinalIgnoreCase);
Assert.Equal(2, macBaseline.AffectedPackages.Count());
AssertPackageDetails(macBaseline, "MacBook Pro (M4)", "26.0.1", "26A123", "macOS");
AssertPackageDetails(macBaseline, "Mac Studio", "26", "26A120b", "macOS");
var venturaRsr = advisoriesByKey["106355"];
Assert.Contains("CVE-2023-37450", venturaRsr.Aliases, StringComparer.OrdinalIgnoreCase);
Assert.Equal(2, venturaRsr.AffectedPackages.Count());
AssertPackageDetails(venturaRsr, "macOS Ventura", string.Empty, "22F400", "macOS Ventura");
AssertPackageDetails(venturaRsr, "macOS Ventura (Intel)", string.Empty, "22F400a", "macOS Ventura");
var visionOs = advisoriesByKey["HT214108"];
Assert.Contains("CVE-2024-27800", visionOs.Aliases, StringComparer.OrdinalIgnoreCase);
Assert.False(visionOs.AffectedPackages.Any());
var rsrAdvisory = advisoriesByKey["HT215500"];
Assert.Contains("CVE-2025-2468", rsrAdvisory.Aliases, StringComparer.OrdinalIgnoreCase);
var rsrPackage = AssertPackageDetails(rsrAdvisory, "iPhone 15 Pro", "18.0.1 (c)", "22A123c", "iOS");
Assert.True(rsrPackage.NormalizedVersions.IsDefaultOrEmpty || rsrPackage.NormalizedVersions.Length == 0);
var flagStore = provider.GetRequiredService<IPsirtFlagStore>();
var rsrFlag = await flagStore.FindAsync("HT215500", CancellationToken.None);
Assert.NotNull(rsrFlag);
}
private static AffectedPackage AssertPackageDetails(
Advisory advisory,
string identifier,
string expectedRangeExpression,
string? expectedBuild,
string expectedPlatform)
{
var candidates = advisory.AffectedPackages
.Where(package => string.Equals(package.Identifier, identifier, StringComparison.Ordinal))
.ToList();
Assert.NotEmpty(candidates);
var package = Assert.Single(candidates, candidate =>
{
var rangeCandidate = candidate.VersionRanges.SingleOrDefault();
return rangeCandidate is not null
&& string.Equals(rangeCandidate.RangeExpression ?? string.Empty, expectedRangeExpression, StringComparison.Ordinal);
});
Assert.Equal(expectedPlatform, package.Platform);
var range = Assert.Single(package.VersionRanges);
Assert.Equal(expectedRangeExpression, range.RangeExpression ?? string.Empty);
Assert.Equal("vendor", range.RangeKind);
Assert.NotNull(range.Primitives);
Assert.NotNull(range.Primitives!.VendorExtensions);
var vendorExtensions = range.Primitives.VendorExtensions;
if (!string.IsNullOrWhiteSpace(expectedRangeExpression))
{
if (!vendorExtensions.TryGetValue("apple.version.raw", out var rawVersion))
{
throw new Xunit.Sdk.XunitException($"Missing apple.version.raw for {identifier}; available keys: {string.Join(", ", vendorExtensions.Keys)}");
}
Assert.Equal(expectedRangeExpression, rawVersion);
}
else
{
Assert.False(vendorExtensions.ContainsKey("apple.version.raw"));
}
if (!string.IsNullOrWhiteSpace(expectedPlatform))
{
if (vendorExtensions.TryGetValue("apple.platform", out var platformExtension))
{
Assert.Equal(expectedPlatform, platformExtension);
}
else
{
throw new Xunit.Sdk.XunitException($"Missing apple.platform extension for {identifier}; available keys: {string.Join(", ", vendorExtensions.Keys)}");
}
}
if (!string.IsNullOrWhiteSpace(expectedBuild))
{
Assert.True(vendorExtensions.TryGetValue("apple.build", out var buildExtension));
Assert.Equal(expectedBuild, buildExtension);
}
else
{
Assert.False(vendorExtensions.ContainsKey("apple.build"));
}
if (PackageCoordinateHelper.TryParseSemVer(expectedRangeExpression, out _, out var normalized))
{
var normalizedRule = Assert.Single(package.NormalizedVersions);
Assert.Equal(NormalizedVersionSchemes.SemVer, normalizedRule.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThanOrEqual, normalizedRule.Type);
Assert.Equal(normalized, normalizedRule.Max);
Assert.Equal($"apple:{expectedPlatform}:{identifier}", normalizedRule.Notes);
}
else
{
Assert.True(package.NormalizedVersions.IsDefaultOrEmpty || package.NormalizedVersions.Length == 0);
}
return package;
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
public sealed class AppleConnectorTests : IAsyncLifetime
{
private static readonly Uri IndexUri = new("https://support.example.com/index.json");
private static readonly Uri DetailBaseUri = new("https://support.example.com/en-us/");
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
public AppleConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero));
}
[Fact]
public async Task FetchParseMap_EndToEnd_ProducesCanonicalAdvisories()
{
var handler = new CannedHttpMessageHandler();
SeedIndex(handler);
SeedDetail(handler);
await using var provider = await BuildServiceProviderAsync(handler);
var connector = provider.GetRequiredService<AppleConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(5, advisories.Count);
var advisoriesByKey = advisories.ToDictionary(static advisory => advisory.AdvisoryKey, StringComparer.Ordinal);
var iosBaseline = advisoriesByKey["125326"];
Assert.Contains("CVE-2025-43400", iosBaseline.Aliases, StringComparer.OrdinalIgnoreCase);
Assert.Equal(3, iosBaseline.AffectedPackages.Count());
AssertPackageDetails(iosBaseline, "iPhone 16 Pro", "26.0.1", "24A341", "iOS");
AssertPackageDetails(iosBaseline, "iPhone 16 Pro", "26.0.1 (a)", "24A341a", "iOS");
AssertPackageDetails(iosBaseline, "iPad Pro (M4)", "26", "24B120", "iPadOS");
var macBaseline = advisoriesByKey["125328"];
Assert.Contains("CVE-2025-43400", macBaseline.Aliases, StringComparer.OrdinalIgnoreCase);
Assert.Equal(2, macBaseline.AffectedPackages.Count());
AssertPackageDetails(macBaseline, "MacBook Pro (M4)", "26.0.1", "26A123", "macOS");
AssertPackageDetails(macBaseline, "Mac Studio", "26", "26A120b", "macOS");
var venturaRsr = advisoriesByKey["106355"];
Assert.Contains("CVE-2023-37450", venturaRsr.Aliases, StringComparer.OrdinalIgnoreCase);
Assert.Equal(2, venturaRsr.AffectedPackages.Count());
AssertPackageDetails(venturaRsr, "macOS Ventura", string.Empty, "22F400", "macOS Ventura");
AssertPackageDetails(venturaRsr, "macOS Ventura (Intel)", string.Empty, "22F400a", "macOS Ventura");
var visionOs = advisoriesByKey["HT214108"];
Assert.Contains("CVE-2024-27800", visionOs.Aliases, StringComparer.OrdinalIgnoreCase);
Assert.False(visionOs.AffectedPackages.Any());
var rsrAdvisory = advisoriesByKey["HT215500"];
Assert.Contains("CVE-2025-2468", rsrAdvisory.Aliases, StringComparer.OrdinalIgnoreCase);
var rsrPackage = AssertPackageDetails(rsrAdvisory, "iPhone 15 Pro", "18.0.1 (c)", "22A123c", "iOS");
Assert.True(rsrPackage.NormalizedVersions.IsDefaultOrEmpty || rsrPackage.NormalizedVersions.Length == 0);
var flagStore = provider.GetRequiredService<IPsirtFlagStore>();
var rsrFlag = await flagStore.FindAsync("HT215500", CancellationToken.None);
Assert.NotNull(rsrFlag);
}
private static AffectedPackage AssertPackageDetails(
Advisory advisory,
string identifier,
string expectedRangeExpression,
string? expectedBuild,
string expectedPlatform)
{
var candidates = advisory.AffectedPackages
.Where(package => string.Equals(package.Identifier, identifier, StringComparison.Ordinal))
.ToList();
Assert.NotEmpty(candidates);
var package = Assert.Single(candidates, candidate =>
{
var rangeCandidate = candidate.VersionRanges.SingleOrDefault();
return rangeCandidate is not null
&& string.Equals(rangeCandidate.RangeExpression ?? string.Empty, expectedRangeExpression, StringComparison.Ordinal);
});
Assert.Equal(expectedPlatform, package.Platform);
var range = Assert.Single(package.VersionRanges);
Assert.Equal(expectedRangeExpression, range.RangeExpression ?? string.Empty);
Assert.Equal("vendor", range.RangeKind);
Assert.NotNull(range.Primitives);
Assert.NotNull(range.Primitives!.VendorExtensions);
var vendorExtensions = range.Primitives.VendorExtensions;
if (!string.IsNullOrWhiteSpace(expectedRangeExpression))
{
if (!vendorExtensions.TryGetValue("apple.version.raw", out var rawVersion))
{
throw new Xunit.Sdk.XunitException($"Missing apple.version.raw for {identifier}; available keys: {string.Join(", ", vendorExtensions.Keys)}");
}
Assert.Equal(expectedRangeExpression, rawVersion);
}
else
{
Assert.False(vendorExtensions.ContainsKey("apple.version.raw"));
}
if (!string.IsNullOrWhiteSpace(expectedPlatform))
{
if (vendorExtensions.TryGetValue("apple.platform", out var platformExtension))
{
Assert.Equal(expectedPlatform, platformExtension);
}
else
{
throw new Xunit.Sdk.XunitException($"Missing apple.platform extension for {identifier}; available keys: {string.Join(", ", vendorExtensions.Keys)}");
}
}
if (!string.IsNullOrWhiteSpace(expectedBuild))
{
Assert.True(vendorExtensions.TryGetValue("apple.build", out var buildExtension));
Assert.Equal(expectedBuild, buildExtension);
}
else
{
Assert.False(vendorExtensions.ContainsKey("apple.build"));
}
if (PackageCoordinateHelper.TryParseSemVer(expectedRangeExpression, out _, out var normalized))
{
var normalizedRule = Assert.Single(package.NormalizedVersions);
Assert.Equal(NormalizedVersionSchemes.SemVer, normalizedRule.Scheme);
Assert.Equal(NormalizedVersionRuleTypes.LessThanOrEqual, normalizedRule.Type);
Assert.Equal(normalized, normalizedRule.Max);
Assert.Equal($"apple:{expectedPlatform}:{identifier}", normalizedRule.Notes);
}
else
{
Assert.True(package.NormalizedVersions.IsDefaultOrEmpty || package.NormalizedVersions.Length == 0);
}
return package;
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
private async Task<ServiceProvider> BuildServiceProviderAsync(CannedHttpMessageHandler handler)
{
await _fixture.TruncateAllTablesAsync();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(handler);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(handler);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddAppleConnector(opts =>
{
opts.SoftwareLookupUri = IndexUri;
opts.AdvisoryBaseUri = DetailBaseUri;
opts.LocaleSegment = "en-us";
opts.InitialBackfill = TimeSpan.FromDays(120);
opts.ModifiedTolerance = TimeSpan.FromHours(2);
opts.MaxAdvisoriesPerFetch = 10;
});
services.Configure<HttpClientFactoryOptions>(AppleOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = handler;
});
});
services.AddSourceCommon();
services.AddAppleConnector(opts =>
{
opts.SoftwareLookupUri = IndexUri;
opts.AdvisoryBaseUri = DetailBaseUri;
opts.LocaleSegment = "en-us";
opts.InitialBackfill = TimeSpan.FromDays(120);
opts.ModifiedTolerance = TimeSpan.FromHours(2);
opts.MaxAdvisoriesPerFetch = 10;
});
services.Configure<HttpClientFactoryOptions>(AppleOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = handler;
});
});
return services.BuildServiceProvider();
}
private static void SeedIndex(CannedHttpMessageHandler handler)
{
handler.AddJsonResponse(IndexUri, ReadFixture("index.json"));
}
private static void SeedDetail(CannedHttpMessageHandler handler)
{
AddHtmlResponse(handler, new Uri(DetailBaseUri, "125326"), "125326.html");
AddHtmlResponse(handler, new Uri(DetailBaseUri, "125328"), "125328.html");
AddHtmlResponse(handler, new Uri(DetailBaseUri, "106355"), "106355.html");
AddHtmlResponse(handler, new Uri(DetailBaseUri, "HT214108"), "ht214108.html");
AddHtmlResponse(handler, new Uri(DetailBaseUri, "HT215500"), "ht215500.html");
}
private static void AddHtmlResponse(CannedHttpMessageHandler handler, Uri uri, string fixture)
{
handler.AddResponse(uri, () =>
{
var content = ReadFixture(fixture);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(content, Encoding.UTF8, "text/html"),
};
});
}
private static string ReadFixture(string name)
{
var path = Path.Combine(
AppContext.BaseDirectory,
"Source",
"Vndr",
"Apple",
"Fixtures",
name);
return File.ReadAllText(path);
}
}
private static void SeedIndex(CannedHttpMessageHandler handler)
{
handler.AddJsonResponse(IndexUri, ReadFixture("index.json"));
}
private static void SeedDetail(CannedHttpMessageHandler handler)
{
AddHtmlResponse(handler, new Uri(DetailBaseUri, "125326"), "125326.html");
AddHtmlResponse(handler, new Uri(DetailBaseUri, "125328"), "125328.html");
AddHtmlResponse(handler, new Uri(DetailBaseUri, "106355"), "106355.html");
AddHtmlResponse(handler, new Uri(DetailBaseUri, "HT214108"), "ht214108.html");
AddHtmlResponse(handler, new Uri(DetailBaseUri, "HT215500"), "ht215500.html");
}
private static void AddHtmlResponse(CannedHttpMessageHandler handler, Uri uri, string fixture)
{
handler.AddResponse(uri, () =>
{
var content = ReadFixture(fixture);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(content, Encoding.UTF8, "text/html"),
};
});
}
private static string ReadFixture(string name)
{
var path = Path.Combine(
AppContext.BaseDirectory,
"Source",
"Vndr",
"Apple",
"Fixtures",
name);
return File.ReadAllText(path);
}
}

View File

@@ -1,342 +1,342 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Concelier.Connector.Vndr.Apple.Internal;
namespace StellaOps.Concelier.Connector.Vndr.Apple.Tests.Apple;
internal static class AppleFixtureManager
{
private const string UpdateEnvVar = "UPDATE_APPLE_FIXTURES";
private const string UpdateSentinelFileName = ".update-apple-fixtures";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private static readonly Uri ExampleDetailBaseUri = new("https://support.example.com/en-us/");
private static readonly AppleFixtureDefinition[] Definitions =
{
new(
"125326",
new Uri("https://support.apple.com/en-us/125326"),
Products: new[]
{
new AppleFixtureProduct("iOS", "iPhone 16 Pro", "26.0.1", "24A341"),
new AppleFixtureProduct("iOS", "iPhone 16 Pro", "26.0.1 (a)", "24A341a"),
new AppleFixtureProduct("iPadOS", "iPad Pro (M4)", "26", "24B120"),
}),
new(
"125328",
new Uri("https://support.apple.com/en-us/125328"),
Products: new[]
{
new AppleFixtureProduct("macOS", "MacBook Pro (M4)", "26.0.1", "26A123"),
new AppleFixtureProduct("macOS", "Mac Studio", "26", "26A120b"),
}),
new(
"106355",
new Uri("https://support.apple.com/en-us/106355"),
ForceRapidSecurityResponse: true,
Products: new[]
{
new AppleFixtureProduct("macOS Ventura", "macOS Ventura", string.Empty, "22F400"),
new AppleFixtureProduct("macOS Ventura", "macOS Ventura (Intel)", string.Empty, "22F400a"),
}),
new(
"HT214108",
new Uri("https://support.apple.com/en-us/HT214108")),
new(
"HT215500",
new Uri("https://support.apple.com/en-us/HT215500"),
ForceRapidSecurityResponse: true),
};
private static readonly Lazy<Task> UpdateTask = new(
() => UpdateFixturesAsync(CancellationToken.None),
LazyThreadSafetyMode.ExecutionAndPublication);
public static IReadOnlyList<AppleFixtureDefinition> Fixtures => Definitions;
public static Task EnsureUpdatedAsync(CancellationToken cancellationToken = default)
{
if (!ShouldUpdateFixtures())
{
return Task.CompletedTask;
}
Console.WriteLine("[AppleFixtures] UPDATE flag detected; refreshing fixtures");
return UpdateTask.Value;
}
public static string ReadFixture(string name)
{
var path = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Apple", "Fixtures", name);
return File.ReadAllText(path);
}
public static AppleDetailDto ReadExpectedDto(string articleId)
{
var json = ReadFixture($"{articleId}.expected.json");
return JsonSerializer.Deserialize<AppleDetailDto>(json, SerializerOptions)
?? throw new InvalidOperationException($"Unable to deserialize expected DTO for {articleId}.");
}
private static bool ShouldUpdateFixtures()
{
var value = Environment.GetEnvironmentVariable(UpdateEnvVar)?.Trim();
if (string.IsNullOrEmpty(value))
{
var sentinelPath = Path.Combine(ResolveFixtureRoot(), UpdateSentinelFileName);
return File.Exists(sentinelPath);
}
if (string.Equals(value, "0", StringComparison.Ordinal)
|| string.Equals(value, "false", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
}
private static async Task UpdateFixturesAsync(CancellationToken cancellationToken)
{
var handler = new HttpClientHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.All,
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var httpClient = new HttpClient(handler);
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOpsFixtureUpdater", "1.0"));
httpClient.Timeout = TimeSpan.FromSeconds(30);
var indexEntries = new List<object>(Definitions.Length);
try
{
foreach (var definition in Definitions)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var html = await httpClient.GetStringAsync(definition.DetailUri, cancellationToken).ConfigureAwait(false);
var entryProducts = definition.Products?
.Select(product => new AppleIndexProduct(
product.Platform,
product.Name,
product.Version,
product.Build))
.ToArray() ?? Array.Empty<AppleIndexProduct>();
var entry = new AppleIndexEntry(
UpdateId: definition.ArticleId,
ArticleId: definition.ArticleId,
Title: definition.ArticleId,
PostingDate: DateTimeOffset.UtcNow,
DetailUri: definition.DetailUri,
Products: entryProducts,
IsRapidSecurityResponse: definition.ForceRapidSecurityResponse ?? false);
var dto = AppleDetailParser.Parse(html, entry);
var sanitizedHtml = BuildSanitizedHtml(dto);
WriteFixture(definition.HtmlFixtureName, sanitizedHtml);
WriteFixture(definition.ExpectedFixtureName, JsonSerializer.Serialize(dto, SerializerOptions));
var exampleDetailUri = new Uri(ExampleDetailBaseUri, definition.ArticleId);
indexEntries.Add(new
{
id = definition.ArticleId,
articleId = definition.ArticleId,
title = dto.Title,
postingDate = dto.Published.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
detailUrl = exampleDetailUri.ToString(),
rapidSecurityResponse = definition.ForceRapidSecurityResponse
?? dto.Title.Contains("Rapid Security Response", StringComparison.OrdinalIgnoreCase),
products = dto.Affected
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.Select(p => new
{
platform = p.Platform,
name = p.Name,
version = p.Version,
build = p.Build,
})
.ToArray(),
});
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
{
Console.WriteLine($"[AppleFixtures] Skipped {definition.ArticleId}: {ex.Message}");
}
}
}
finally
{
var sentinelPath = Path.Combine(ResolveFixtureRoot(), UpdateSentinelFileName);
if (File.Exists(sentinelPath))
{
try
{
File.Delete(sentinelPath);
}
catch (IOException)
{
// best effort
}
}
}
var indexDocument = new { updates = indexEntries };
WriteFixture("index.json", JsonSerializer.Serialize(indexDocument, SerializerOptions));
}
private static string BuildSanitizedHtml(AppleDetailDto dto)
{
var builder = new StringBuilder();
builder.AppendLine("<!DOCTYPE html>");
builder.AppendLine("<html lang=\"en\">");
builder.AppendLine("<body>");
builder.AppendLine("<article>");
builder.AppendLine($" <h1 data-testid=\"update-title\">{Escape(dto.Title)}</h1>");
if (!string.IsNullOrWhiteSpace(dto.Summary))
{
builder.AppendLine($" <p data-testid=\"update-summary\">{Escape(dto.Summary)}</p>");
}
var publishedDisplay = dto.Published.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture);
builder.AppendLine($" <time data-testid=\"published\" datetime=\"{dto.Published:O}\">{publishedDisplay}</time>");
if (dto.Updated.HasValue)
{
var updatedDisplay = dto.Updated.Value.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture);
builder.AppendLine($" <time data-testid=\"updated\" datetime=\"{dto.Updated:O}\">{updatedDisplay}</time>");
}
if (dto.CveIds.Count > 0)
{
builder.AppendLine(" <section data-component=\"security-update\">");
builder.AppendLine(" <h2>Security Issues</h2>");
builder.AppendLine(" <ul data-testid=\"cve-list\">");
foreach (var cve in dto.CveIds.OrderBy(id => id, StringComparer.OrdinalIgnoreCase))
{
builder.AppendLine($" <li>{Escape(cve)}</li>");
}
builder.AppendLine(" </ul>");
builder.AppendLine(" </section>");
}
if (dto.Affected.Count > 0)
{
builder.AppendLine(" <table>");
builder.AppendLine(" <tbody>");
foreach (var product in dto.Affected
.OrderBy(p => p.Platform ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Build ?? string.Empty, StringComparer.OrdinalIgnoreCase))
{
var platform = product.Platform is null ? string.Empty : Escape(product.Platform);
var name = Escape(product.Name);
var version = product.Version is null ? string.Empty : Escape(product.Version);
var build = product.Build is null ? string.Empty : Escape(product.Build);
builder.Append(" <tr data-testid=\"product-row\"");
builder.Append($" data-platform=\"{platform}\"");
builder.Append($" data-product=\"{name}\"");
builder.Append($" data-version=\"{version}\"");
builder.Append($" data-build=\"{build}\">");
builder.AppendLine();
builder.AppendLine($" <td>{name}</td>");
builder.AppendLine($" <td>{version}</td>");
builder.AppendLine($" <td>{build}</td>");
builder.AppendLine(" </tr>");
}
builder.AppendLine(" </tbody>");
builder.AppendLine(" </table>");
}
if (dto.References.Count > 0)
{
builder.AppendLine(" <section>");
builder.AppendLine(" <h2>References</h2>");
foreach (var reference in dto.References
.OrderBy(r => r.Url, StringComparer.OrdinalIgnoreCase))
{
var title = reference.Title ?? string.Empty;
builder.AppendLine($" <a href=\"{Escape(reference.Url)}\">{Escape(title)}</a>");
}
builder.AppendLine(" </section>");
}
builder.AppendLine("</article>");
builder.AppendLine("</body>");
builder.AppendLine("</html>");
return builder.ToString();
}
private static void WriteFixture(string name, string contents)
{
var root = ResolveFixtureRoot();
Directory.CreateDirectory(root);
var normalized = NormalizeLineEndings(contents);
var sourcePath = Path.Combine(root, name);
File.WriteAllText(sourcePath, normalized);
var outputPath = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Apple", "Fixtures", name);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
File.WriteAllText(outputPath, normalized);
Console.WriteLine($"[AppleFixtures] Wrote {name}");
}
private static string ResolveFixtureRoot()
{
var baseDir = AppContext.BaseDirectory;
// bin/Debug/net10.0/ -> project -> src -> repo root
var root = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", ".."));
return Path.Combine(root, "src", "StellaOps.Concelier.Connector.Vndr.Apple.Tests", "Apple", "Fixtures");
}
private static string NormalizeLineEndings(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
private static string Escape(string value)
=> System.Net.WebUtility.HtmlEncode(value);
}
internal sealed record AppleFixtureDefinition(
string ArticleId,
Uri DetailUri,
bool? ForceRapidSecurityResponse = null,
IReadOnlyList<AppleFixtureProduct>? Products = null)
{
public string HtmlFixtureName => $"{ArticleId}.html";
public string ExpectedFixtureName => $"{ArticleId}.expected.json";
}
internal sealed record AppleFixtureProduct(
string Platform,
string Name,
string Version,
string Build);
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Concelier.Connector.Vndr.Apple.Internal;
namespace StellaOps.Concelier.Connector.Vndr.Apple.Tests.Apple;
internal static class AppleFixtureManager
{
private const string UpdateEnvVar = "UPDATE_APPLE_FIXTURES";
private const string UpdateSentinelFileName = ".update-apple-fixtures";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private static readonly Uri ExampleDetailBaseUri = new("https://support.example.com/en-us/");
private static readonly AppleFixtureDefinition[] Definitions =
{
new(
"125326",
new Uri("https://support.apple.com/en-us/125326"),
Products: new[]
{
new AppleFixtureProduct("iOS", "iPhone 16 Pro", "26.0.1", "24A341"),
new AppleFixtureProduct("iOS", "iPhone 16 Pro", "26.0.1 (a)", "24A341a"),
new AppleFixtureProduct("iPadOS", "iPad Pro (M4)", "26", "24B120"),
}),
new(
"125328",
new Uri("https://support.apple.com/en-us/125328"),
Products: new[]
{
new AppleFixtureProduct("macOS", "MacBook Pro (M4)", "26.0.1", "26A123"),
new AppleFixtureProduct("macOS", "Mac Studio", "26", "26A120b"),
}),
new(
"106355",
new Uri("https://support.apple.com/en-us/106355"),
ForceRapidSecurityResponse: true,
Products: new[]
{
new AppleFixtureProduct("macOS Ventura", "macOS Ventura", string.Empty, "22F400"),
new AppleFixtureProduct("macOS Ventura", "macOS Ventura (Intel)", string.Empty, "22F400a"),
}),
new(
"HT214108",
new Uri("https://support.apple.com/en-us/HT214108")),
new(
"HT215500",
new Uri("https://support.apple.com/en-us/HT215500"),
ForceRapidSecurityResponse: true),
};
private static readonly Lazy<Task> UpdateTask = new(
() => UpdateFixturesAsync(CancellationToken.None),
LazyThreadSafetyMode.ExecutionAndPublication);
public static IReadOnlyList<AppleFixtureDefinition> Fixtures => Definitions;
public static Task EnsureUpdatedAsync(CancellationToken cancellationToken = default)
{
if (!ShouldUpdateFixtures())
{
return Task.CompletedTask;
}
Console.WriteLine("[AppleFixtures] UPDATE flag detected; refreshing fixtures");
return UpdateTask.Value;
}
public static string ReadFixture(string name)
{
var path = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Apple", "Fixtures", name);
return File.ReadAllText(path);
}
public static AppleDetailDto ReadExpectedDto(string articleId)
{
var json = ReadFixture($"{articleId}.expected.json");
return JsonSerializer.Deserialize<AppleDetailDto>(json, SerializerOptions)
?? throw new InvalidOperationException($"Unable to deserialize expected DTO for {articleId}.");
}
private static bool ShouldUpdateFixtures()
{
var value = Environment.GetEnvironmentVariable(UpdateEnvVar)?.Trim();
if (string.IsNullOrEmpty(value))
{
var sentinelPath = Path.Combine(ResolveFixtureRoot(), UpdateSentinelFileName);
return File.Exists(sentinelPath);
}
if (string.Equals(value, "0", StringComparison.Ordinal)
|| string.Equals(value, "false", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
}
private static async Task UpdateFixturesAsync(CancellationToken cancellationToken)
{
var handler = new HttpClientHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.All,
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var httpClient = new HttpClient(handler);
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOpsFixtureUpdater", "1.0"));
httpClient.Timeout = TimeSpan.FromSeconds(30);
var indexEntries = new List<object>(Definitions.Length);
try
{
foreach (var definition in Definitions)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var html = await httpClient.GetStringAsync(definition.DetailUri, cancellationToken).ConfigureAwait(false);
var entryProducts = definition.Products?
.Select(product => new AppleIndexProduct(
product.Platform,
product.Name,
product.Version,
product.Build))
.ToArray() ?? Array.Empty<AppleIndexProduct>();
var entry = new AppleIndexEntry(
UpdateId: definition.ArticleId,
ArticleId: definition.ArticleId,
Title: definition.ArticleId,
PostingDate: DateTimeOffset.UtcNow,
DetailUri: definition.DetailUri,
Products: entryProducts,
IsRapidSecurityResponse: definition.ForceRapidSecurityResponse ?? false);
var dto = AppleDetailParser.Parse(html, entry);
var sanitizedHtml = BuildSanitizedHtml(dto);
WriteFixture(definition.HtmlFixtureName, sanitizedHtml);
WriteFixture(definition.ExpectedFixtureName, JsonSerializer.Serialize(dto, SerializerOptions));
var exampleDetailUri = new Uri(ExampleDetailBaseUri, definition.ArticleId);
indexEntries.Add(new
{
id = definition.ArticleId,
articleId = definition.ArticleId,
title = dto.Title,
postingDate = dto.Published.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
detailUrl = exampleDetailUri.ToString(),
rapidSecurityResponse = definition.ForceRapidSecurityResponse
?? dto.Title.Contains("Rapid Security Response", StringComparison.OrdinalIgnoreCase),
products = dto.Affected
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.Select(p => new
{
platform = p.Platform,
name = p.Name,
version = p.Version,
build = p.Build,
})
.ToArray(),
});
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
{
Console.WriteLine($"[AppleFixtures] Skipped {definition.ArticleId}: {ex.Message}");
}
}
}
finally
{
var sentinelPath = Path.Combine(ResolveFixtureRoot(), UpdateSentinelFileName);
if (File.Exists(sentinelPath))
{
try
{
File.Delete(sentinelPath);
}
catch (IOException)
{
// best effort
}
}
}
var indexDocument = new { updates = indexEntries };
WriteFixture("index.json", JsonSerializer.Serialize(indexDocument, SerializerOptions));
}
private static string BuildSanitizedHtml(AppleDetailDto dto)
{
var builder = new StringBuilder();
builder.AppendLine("<!DOCTYPE html>");
builder.AppendLine("<html lang=\"en\">");
builder.AppendLine("<body>");
builder.AppendLine("<article>");
builder.AppendLine($" <h1 data-testid=\"update-title\">{Escape(dto.Title)}</h1>");
if (!string.IsNullOrWhiteSpace(dto.Summary))
{
builder.AppendLine($" <p data-testid=\"update-summary\">{Escape(dto.Summary)}</p>");
}
var publishedDisplay = dto.Published.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture);
builder.AppendLine($" <time data-testid=\"published\" datetime=\"{dto.Published:O}\">{publishedDisplay}</time>");
if (dto.Updated.HasValue)
{
var updatedDisplay = dto.Updated.Value.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture);
builder.AppendLine($" <time data-testid=\"updated\" datetime=\"{dto.Updated:O}\">{updatedDisplay}</time>");
}
if (dto.CveIds.Count > 0)
{
builder.AppendLine(" <section data-component=\"security-update\">");
builder.AppendLine(" <h2>Security Issues</h2>");
builder.AppendLine(" <ul data-testid=\"cve-list\">");
foreach (var cve in dto.CveIds.OrderBy(id => id, StringComparer.OrdinalIgnoreCase))
{
builder.AppendLine($" <li>{Escape(cve)}</li>");
}
builder.AppendLine(" </ul>");
builder.AppendLine(" </section>");
}
if (dto.Affected.Count > 0)
{
builder.AppendLine(" <table>");
builder.AppendLine(" <tbody>");
foreach (var product in dto.Affected
.OrderBy(p => p.Platform ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(p => p.Build ?? string.Empty, StringComparer.OrdinalIgnoreCase))
{
var platform = product.Platform is null ? string.Empty : Escape(product.Platform);
var name = Escape(product.Name);
var version = product.Version is null ? string.Empty : Escape(product.Version);
var build = product.Build is null ? string.Empty : Escape(product.Build);
builder.Append(" <tr data-testid=\"product-row\"");
builder.Append($" data-platform=\"{platform}\"");
builder.Append($" data-product=\"{name}\"");
builder.Append($" data-version=\"{version}\"");
builder.Append($" data-build=\"{build}\">");
builder.AppendLine();
builder.AppendLine($" <td>{name}</td>");
builder.AppendLine($" <td>{version}</td>");
builder.AppendLine($" <td>{build}</td>");
builder.AppendLine(" </tr>");
}
builder.AppendLine(" </tbody>");
builder.AppendLine(" </table>");
}
if (dto.References.Count > 0)
{
builder.AppendLine(" <section>");
builder.AppendLine(" <h2>References</h2>");
foreach (var reference in dto.References
.OrderBy(r => r.Url, StringComparer.OrdinalIgnoreCase))
{
var title = reference.Title ?? string.Empty;
builder.AppendLine($" <a href=\"{Escape(reference.Url)}\">{Escape(title)}</a>");
}
builder.AppendLine(" </section>");
}
builder.AppendLine("</article>");
builder.AppendLine("</body>");
builder.AppendLine("</html>");
return builder.ToString();
}
private static void WriteFixture(string name, string contents)
{
var root = ResolveFixtureRoot();
Directory.CreateDirectory(root);
var normalized = NormalizeLineEndings(contents);
var sourcePath = Path.Combine(root, name);
File.WriteAllText(sourcePath, normalized);
var outputPath = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Apple", "Fixtures", name);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
File.WriteAllText(outputPath, normalized);
Console.WriteLine($"[AppleFixtures] Wrote {name}");
}
private static string ResolveFixtureRoot()
{
var baseDir = AppContext.BaseDirectory;
// bin/Debug/net10.0/ -> project -> src -> repo root
var root = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", ".."));
return Path.Combine(root, "src", "StellaOps.Concelier.Connector.Vndr.Apple.Tests", "Apple", "Fixtures");
}
private static string NormalizeLineEndings(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
private static string Escape(string value)
=> System.Net.WebUtility.HtmlEncode(value);
}
internal sealed record AppleFixtureDefinition(
string ArticleId,
Uri DetailUri,
bool? ForceRapidSecurityResponse = null,
IReadOnlyList<AppleFixtureProduct>? Products = null)
{
public string HtmlFixtureName => $"{ArticleId}.html";
public string ExpectedFixtureName => $"{ArticleId}.expected.json";
}
internal sealed record AppleFixtureProduct(
string Platform,
string Name,
string Version,
string Build);

View File

@@ -1,60 +1,60 @@
using System;
using System.Collections.Generic;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using StellaOps.Concelier.Connector.Vndr.Apple.Internal;
using StellaOps.Concelier.Connector.Vndr.Apple.Tests.Apple;
using Xunit;
namespace StellaOps.Concelier.Connector.Vndr.Apple.Tests;
public sealed class AppleLiveRegressionTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
public static IEnumerable<object[]> FixtureCases
{
get
{
foreach (var definition in AppleFixtureManager.Fixtures)
{
yield return new object[] { definition.ArticleId };
}
}
}
[Theory]
[MemberData(nameof(FixtureCases))]
public async Task Parser_SanitizedFixture_MatchesExpectedDto(string articleId)
{
var updateFlag = Environment.GetEnvironmentVariable("UPDATE_APPLE_FIXTURES");
if (!string.IsNullOrEmpty(updateFlag))
{
Console.WriteLine($"[AppleFixtures] UPDATE_APPLE_FIXTURES={updateFlag}");
}
await AppleFixtureManager.EnsureUpdatedAsync();
var expected = AppleFixtureManager.ReadExpectedDto(articleId);
var html = AppleFixtureManager.ReadFixture($"{articleId}.html");
var entry = new AppleIndexEntry(
UpdateId: articleId,
ArticleId: articleId,
Title: expected.Title,
PostingDate: expected.Published,
DetailUri: new Uri($"https://support.apple.com/en-us/{articleId}"),
Products: Array.Empty<AppleIndexProduct>(),
IsRapidSecurityResponse: expected.RapidSecurityResponse);
var dto = AppleDetailParser.Parse(html, entry);
var actualJson = JsonSerializer.Serialize(dto, SerializerOptions);
var expectedJson = JsonSerializer.Serialize(expected, SerializerOptions);
Assert.Equal(expectedJson, actualJson);
}
}
using System;
using System.Collections.Generic;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using StellaOps.Concelier.Connector.Vndr.Apple.Internal;
using StellaOps.Concelier.Connector.Vndr.Apple.Tests.Apple;
using Xunit;
namespace StellaOps.Concelier.Connector.Vndr.Apple.Tests;
public sealed class AppleLiveRegressionTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
public static IEnumerable<object[]> FixtureCases
{
get
{
foreach (var definition in AppleFixtureManager.Fixtures)
{
yield return new object[] { definition.ArticleId };
}
}
}
[Theory]
[MemberData(nameof(FixtureCases))]
public async Task Parser_SanitizedFixture_MatchesExpectedDto(string articleId)
{
var updateFlag = Environment.GetEnvironmentVariable("UPDATE_APPLE_FIXTURES");
if (!string.IsNullOrEmpty(updateFlag))
{
Console.WriteLine($"[AppleFixtures] UPDATE_APPLE_FIXTURES={updateFlag}");
}
await AppleFixtureManager.EnsureUpdatedAsync();
var expected = AppleFixtureManager.ReadExpectedDto(articleId);
var html = AppleFixtureManager.ReadFixture($"{articleId}.html");
var entry = new AppleIndexEntry(
UpdateId: articleId,
ArticleId: articleId,
Title: expected.Title,
PostingDate: expected.Published,
DetailUri: new Uri($"https://support.apple.com/en-us/{articleId}"),
Products: Array.Empty<AppleIndexProduct>(),
IsRapidSecurityResponse: expected.RapidSecurityResponse);
var dto = AppleDetailParser.Parse(html, entry);
var actualJson = JsonSerializer.Serialize(dto, SerializerOptions);
var expectedJson = JsonSerializer.Serialize(expected, SerializerOptions);
Assert.Equal(expectedJson, actualJson);
}
}

View File

@@ -1,268 +1,268 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Json;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Vndr.Chromium;
using StellaOps.Concelier.Connector.Vndr.Chromium.Configuration;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.PsirtFlags;
using StellaOps.Concelier.Testing;
namespace StellaOps.Concelier.Connector.Vndr.Chromium.Tests;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Vndr.Chromium;
using StellaOps.Concelier.Connector.Vndr.Chromium.Configuration;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.PsirtFlags;
using StellaOps.Concelier.Testing;
namespace StellaOps.Concelier.Connector.Vndr.Chromium.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class ChromiumConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly List<string> _allocatedDatabases = new();
public ChromiumConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 9, 10, 18, 0, 0, TimeSpan.Zero));
}
[Fact]
public async Task FetchParseMap_ProducesSnapshot()
{
var databaseName = AllocateDatabaseName();
await DropDatabaseAsync(databaseName);
try
{
var handler = new CannedHttpMessageHandler();
await using var provider = await BuildServiceProviderAsync(handler, databaseName);
SeedHttpFixtures(handler);
var connector = provider.GetRequiredService<ChromiumConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
try
{
await connector.ParseAsync(provider, CancellationToken.None);
}
catch (StellaOps.Concelier.Connector.Common.Json.JsonSchemaValidationException)
{
// Parsing should flag document as failed even when schema validation rejects payloads.
}
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
var advisory = Assert.Single(advisories);
Assert.Equal("chromium/post/stable-channel-update-for-desktop", advisory.AdvisoryKey);
Assert.Contains("CHROMIUM-POST:stable-channel-update-for-desktop", advisory.Aliases);
Assert.Contains("CVE-2024-12345", advisory.Aliases);
Assert.Contains("CVE-2024-22222", advisory.Aliases);
Assert.Contains(advisory.AffectedPackages, package => package.Platform == "android" && package.VersionRanges.Any(range => range.FixedVersion == "128.0.6613.89"));
Assert.Contains(advisory.AffectedPackages, package => package.Platform == "linux" && package.VersionRanges.Any(range => range.FixedVersion == "128.0.6613.137"));
Assert.Contains(advisory.AffectedPackages, package => package.Identifier == "google:chrome" && package.Platform == "windows-mac" && package.VersionRanges.Any(range => range.FixedVersion == "128.0.6613.138"));
Assert.Contains(advisory.AffectedPackages, package => package.Identifier == "google:chrome:extended-stable" && package.Platform == "windows-mac" && package.VersionRanges.Any(range => range.FixedVersion == "128.0.6613.138"));
Assert.Contains(advisory.References, reference => reference.Url.Contains("chromium.googlesource.com", StringComparison.OrdinalIgnoreCase));
Assert.Contains(advisory.References, reference => reference.Url.Contains("issues.chromium.org", StringComparison.OrdinalIgnoreCase));
var psirtStore = provider.GetRequiredService<IPsirtFlagStore>();
var psirtFlag = await psirtStore.FindAsync(advisory.AdvisoryKey, CancellationToken.None);
Assert.NotNull(psirtFlag);
Assert.Equal("Google", psirtFlag!.Vendor);
var canonicalJson = CanonicalJsonSerializer.Serialize(advisory).Trim();
var snapshotPath = ResolveFixturePath("chromium-advisory.snapshot.json");
var expected = File.ReadAllText(snapshotPath).Trim();
if (!string.Equals(expected, canonicalJson, StringComparison.Ordinal))
{
var actualPath = ResolveFixturePath("chromium-advisory.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, canonicalJson);
}
Assert.Equal(expected, canonicalJson);
}
finally
{
await DropDatabaseAsync(databaseName);
}
}
[Fact]
public async Task ParseFailure_MarksDocumentFailed()
{
var databaseName = AllocateDatabaseName();
await DropDatabaseAsync(databaseName);
try
{
var handler = new CannedHttpMessageHandler();
var feedUri = new Uri("https://chromereleases.googleblog.com/atom.xml?max-results=50&start-index=1&redirect=false");
var detailUri = new Uri("https://chromereleases.googleblog.com/2024/09/stable-channel-update-for-desktop.html");
handler.AddTextResponse(feedUri, ReadFixture("chromium-feed.xml"), "application/atom+xml");
handler.AddTextResponse(detailUri, "<html><body><div>missing post body</div></body></html>", "text/html");
await using var provider = await BuildServiceProviderAsync(handler, databaseName);
var connector = provider.GetRequiredService<ChromiumConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
try
{
await connector.ParseAsync(provider, CancellationToken.None);
}
catch (JsonSchemaValidationException)
{
// Expected for malformed posts; connector should still flag the document as failed.
}
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(VndrChromiumConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Failed, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrChromiumConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue.AsBsonArray
: new BsonArray();
Assert.Empty(pendingDocuments);
}
finally
{
await DropDatabaseAsync(databaseName);
}
}
[Fact]
public async Task Resume_CompletesPendingDocumentsAfterRestart()
{
var databaseName = AllocateDatabaseName();
await DropDatabaseAsync(databaseName);
try
{
var fetchHandler = new CannedHttpMessageHandler();
Guid[] pendingDocumentIds;
await using (var fetchProvider = await BuildServiceProviderAsync(fetchHandler, databaseName))
{
SeedHttpFixtures(fetchHandler);
var connector = fetchProvider.GetRequiredService<ChromiumConnector>();
await connector.FetchAsync(fetchProvider, CancellationToken.None);
var stateRepository = fetchProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrChromiumConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue.AsBsonArray
: new BsonArray();
Assert.NotEmpty(pendingDocuments);
pendingDocumentIds = pendingDocuments.Select(value => Guid.Parse(value.AsString)).ToArray();
}
var resumeHandler = new CannedHttpMessageHandler();
SeedHttpFixtures(resumeHandler);
await using var resumeProvider = await BuildServiceProviderAsync(resumeHandler, databaseName);
var stateRepositoryBefore = resumeProvider.GetRequiredService<ISourceStateRepository>();
var resumeState = await stateRepositoryBefore.TryGetAsync(VndrChromiumConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(resumeState);
var resumePendingDocs = resumeState!.Cursor.TryGetValue("pendingDocuments", out var resumePendingValue)
? resumePendingValue.AsBsonArray
: new BsonArray();
Assert.Equal(pendingDocumentIds.Length, resumePendingDocs.Count);
var resumeIds = resumePendingDocs.Select(value => Guid.Parse(value.AsString)).OrderBy(id => id).ToArray();
Assert.Equal(pendingDocumentIds.OrderBy(id => id).ToArray(), resumeIds);
var resumeConnector = resumeProvider.GetRequiredService<ChromiumConnector>();
await resumeConnector.ParseAsync(resumeProvider, CancellationToken.None);
await resumeConnector.MapAsync(resumeProvider, CancellationToken.None);
var documentStore = resumeProvider.GetRequiredService<IDocumentStore>();
foreach (var documentId in pendingDocumentIds)
{
var document = await documentStore.FindAsync(documentId, CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
}
var stateRepositoryAfter = resumeProvider.GetRequiredService<ISourceStateRepository>();
var finalState = await stateRepositoryAfter.TryGetAsync(VndrChromiumConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(finalState);
var finalPending = finalState!.Cursor.TryGetValue("pendingDocuments", out var finalPendingDocs)
? finalPendingDocs.AsBsonArray
: new BsonArray();
Assert.Empty(finalPending);
var finalPendingMappings = finalState.Cursor.TryGetValue("pendingMappings", out var finalPendingMappingsValue)
? finalPendingMappingsValue.AsBsonArray
: new BsonArray();
Assert.Empty(finalPendingMappings);
}
finally
{
await DropDatabaseAsync(databaseName);
}
}
[Fact]
public async Task Fetch_SkipsUnchangedDocuments()
{
var databaseName = AllocateDatabaseName();
await DropDatabaseAsync(databaseName);
try
{
var handler = new CannedHttpMessageHandler();
await using var provider = await BuildServiceProviderAsync(handler, databaseName);
SeedHttpFixtures(handler);
var connector = provider.GetRequiredService<ChromiumConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Single(advisories);
// Re-seed responses and fetch again with unchanged content.
SeedHttpFixtures(handler);
await connector.FetchAsync(provider, CancellationToken.None);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrChromiumConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = state!.Cursor;
var pendingDocuments = cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue.AsBsonArray
: new BsonArray();
Assert.Empty(pendingDocuments);
var pendingMappings = cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue.AsBsonArray
: new BsonArray();
Assert.Empty(pendingMappings);
}
finally
{
await DropDatabaseAsync(databaseName);
}
}
public sealed class ChromiumConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly List<string> _allocatedDatabases = new();
public ChromiumConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 9, 10, 18, 0, 0, TimeSpan.Zero));
}
[Fact]
public async Task FetchParseMap_ProducesSnapshot()
{
var databaseName = AllocateDatabaseName();
await DropDatabaseAsync(databaseName);
try
{
var handler = new CannedHttpMessageHandler();
await using var provider = await BuildServiceProviderAsync(handler, databaseName);
SeedHttpFixtures(handler);
var connector = provider.GetRequiredService<ChromiumConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
try
{
await connector.ParseAsync(provider, CancellationToken.None);
}
catch (StellaOps.Concelier.Connector.Common.Json.JsonSchemaValidationException)
{
// Parsing should flag document as failed even when schema validation rejects payloads.
}
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
var advisory = Assert.Single(advisories);
Assert.Equal("chromium/post/stable-channel-update-for-desktop", advisory.AdvisoryKey);
Assert.Contains("CHROMIUM-POST:stable-channel-update-for-desktop", advisory.Aliases);
Assert.Contains("CVE-2024-12345", advisory.Aliases);
Assert.Contains("CVE-2024-22222", advisory.Aliases);
Assert.Contains(advisory.AffectedPackages, package => package.Platform == "android" && package.VersionRanges.Any(range => range.FixedVersion == "128.0.6613.89"));
Assert.Contains(advisory.AffectedPackages, package => package.Platform == "linux" && package.VersionRanges.Any(range => range.FixedVersion == "128.0.6613.137"));
Assert.Contains(advisory.AffectedPackages, package => package.Identifier == "google:chrome" && package.Platform == "windows-mac" && package.VersionRanges.Any(range => range.FixedVersion == "128.0.6613.138"));
Assert.Contains(advisory.AffectedPackages, package => package.Identifier == "google:chrome:extended-stable" && package.Platform == "windows-mac" && package.VersionRanges.Any(range => range.FixedVersion == "128.0.6613.138"));
Assert.Contains(advisory.References, reference => reference.Url.Contains("chromium.googlesource.com", StringComparison.OrdinalIgnoreCase));
Assert.Contains(advisory.References, reference => reference.Url.Contains("issues.chromium.org", StringComparison.OrdinalIgnoreCase));
var psirtStore = provider.GetRequiredService<IPsirtFlagStore>();
var psirtFlag = await psirtStore.FindAsync(advisory.AdvisoryKey, CancellationToken.None);
Assert.NotNull(psirtFlag);
Assert.Equal("Google", psirtFlag!.Vendor);
var canonicalJson = CanonicalJsonSerializer.Serialize(advisory).Trim();
var snapshotPath = ResolveFixturePath("chromium-advisory.snapshot.json");
var expected = File.ReadAllText(snapshotPath).Trim();
if (!string.Equals(expected, canonicalJson, StringComparison.Ordinal))
{
var actualPath = ResolveFixturePath("chromium-advisory.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, canonicalJson);
}
Assert.Equal(expected, canonicalJson);
}
finally
{
await DropDatabaseAsync(databaseName);
}
}
[Fact]
public async Task ParseFailure_MarksDocumentFailed()
{
var databaseName = AllocateDatabaseName();
await DropDatabaseAsync(databaseName);
try
{
var handler = new CannedHttpMessageHandler();
var feedUri = new Uri("https://chromereleases.googleblog.com/atom.xml?max-results=50&start-index=1&redirect=false");
var detailUri = new Uri("https://chromereleases.googleblog.com/2024/09/stable-channel-update-for-desktop.html");
handler.AddTextResponse(feedUri, ReadFixture("chromium-feed.xml"), "application/atom+xml");
handler.AddTextResponse(detailUri, "<html><body><div>missing post body</div></body></html>", "text/html");
await using var provider = await BuildServiceProviderAsync(handler, databaseName);
var connector = provider.GetRequiredService<ChromiumConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
try
{
await connector.ParseAsync(provider, CancellationToken.None);
}
catch (JsonSchemaValidationException)
{
// Expected for malformed posts; connector should still flag the document as failed.
}
var documentStore = provider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(VndrChromiumConnectorPlugin.SourceName, detailUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Failed, document!.Status);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrChromiumConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue.AsDocumentArray
: new DocumentArray();
Assert.Empty(pendingDocuments);
}
finally
{
await DropDatabaseAsync(databaseName);
}
}
[Fact]
public async Task Resume_CompletesPendingDocumentsAfterRestart()
{
var databaseName = AllocateDatabaseName();
await DropDatabaseAsync(databaseName);
try
{
var fetchHandler = new CannedHttpMessageHandler();
Guid[] pendingDocumentIds;
await using (var fetchProvider = await BuildServiceProviderAsync(fetchHandler, databaseName))
{
SeedHttpFixtures(fetchHandler);
var connector = fetchProvider.GetRequiredService<ChromiumConnector>();
await connector.FetchAsync(fetchProvider, CancellationToken.None);
var stateRepository = fetchProvider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrChromiumConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var pendingDocuments = state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue.AsDocumentArray
: new DocumentArray();
Assert.NotEmpty(pendingDocuments);
pendingDocumentIds = pendingDocuments.Select(value => Guid.Parse(value.AsString)).ToArray();
}
var resumeHandler = new CannedHttpMessageHandler();
SeedHttpFixtures(resumeHandler);
await using var resumeProvider = await BuildServiceProviderAsync(resumeHandler, databaseName);
var stateRepositoryBefore = resumeProvider.GetRequiredService<ISourceStateRepository>();
var resumeState = await stateRepositoryBefore.TryGetAsync(VndrChromiumConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(resumeState);
var resumePendingDocs = resumeState!.Cursor.TryGetValue("pendingDocuments", out var resumePendingValue)
? resumePendingValue.AsDocumentArray
: new DocumentArray();
Assert.Equal(pendingDocumentIds.Length, resumePendingDocs.Count);
var resumeIds = resumePendingDocs.Select(value => Guid.Parse(value.AsString)).OrderBy(id => id).ToArray();
Assert.Equal(pendingDocumentIds.OrderBy(id => id).ToArray(), resumeIds);
var resumeConnector = resumeProvider.GetRequiredService<ChromiumConnector>();
await resumeConnector.ParseAsync(resumeProvider, CancellationToken.None);
await resumeConnector.MapAsync(resumeProvider, CancellationToken.None);
var documentStore = resumeProvider.GetRequiredService<IDocumentStore>();
foreach (var documentId in pendingDocumentIds)
{
var document = await documentStore.FindAsync(documentId, CancellationToken.None);
Assert.NotNull(document);
Assert.Equal(DocumentStatuses.Mapped, document!.Status);
}
var stateRepositoryAfter = resumeProvider.GetRequiredService<ISourceStateRepository>();
var finalState = await stateRepositoryAfter.TryGetAsync(VndrChromiumConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(finalState);
var finalPending = finalState!.Cursor.TryGetValue("pendingDocuments", out var finalPendingDocs)
? finalPendingDocs.AsDocumentArray
: new DocumentArray();
Assert.Empty(finalPending);
var finalPendingMappings = finalState.Cursor.TryGetValue("pendingMappings", out var finalPendingMappingsValue)
? finalPendingMappingsValue.AsDocumentArray
: new DocumentArray();
Assert.Empty(finalPendingMappings);
}
finally
{
await DropDatabaseAsync(databaseName);
}
}
[Fact]
public async Task Fetch_SkipsUnchangedDocuments()
{
var databaseName = AllocateDatabaseName();
await DropDatabaseAsync(databaseName);
try
{
var handler = new CannedHttpMessageHandler();
await using var provider = await BuildServiceProviderAsync(handler, databaseName);
SeedHttpFixtures(handler);
var connector = provider.GetRequiredService<ChromiumConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Single(advisories);
// Re-seed responses and fetch again with unchanged content.
SeedHttpFixtures(handler);
await connector.FetchAsync(provider, CancellationToken.None);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrChromiumConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = state!.Cursor;
var pendingDocuments = cursor.TryGetValue("pendingDocuments", out var pendingDocsValue)
? pendingDocsValue.AsDocumentArray
: new DocumentArray();
Assert.Empty(pendingDocuments);
var pendingMappings = cursor.TryGetValue("pendingMappings", out var pendingMappingsValue)
? pendingMappingsValue.AsDocumentArray
: new DocumentArray();
Assert.Empty(pendingMappings);
}
finally
{
await DropDatabaseAsync(databaseName);
}
}
private async Task<ServiceProvider> BuildServiceProviderAsync(CannedHttpMessageHandler handler, string databaseName)
{
var services = new ServiceCollection();
@@ -276,74 +276,74 @@ public sealed class ChromiumConnectorTests : IAsyncLifetime
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddChromiumConnector(opts =>
{
opts.FeedUri = new Uri("https://chromereleases.googleblog.com/atom.xml");
opts.InitialBackfill = TimeSpan.FromDays(30);
opts.WindowOverlap = TimeSpan.FromDays(1);
opts.MaxFeedPages = 1;
opts.MaxEntriesPerPage = 50;
});
services.Configure<HttpClientFactoryOptions>(ChromiumOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = handler;
});
});
services.AddSourceCommon();
services.AddChromiumConnector(opts =>
{
opts.FeedUri = new Uri("https://chromereleases.googleblog.com/atom.xml");
opts.InitialBackfill = TimeSpan.FromDays(30);
opts.WindowOverlap = TimeSpan.FromDays(1);
opts.MaxFeedPages = 1;
opts.MaxEntriesPerPage = 50;
});
services.Configure<HttpClientFactoryOptions>(ChromiumOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = handler;
});
});
return services.BuildServiceProvider();
}
private string AllocateDatabaseName()
{
var name = $"chromium-tests-{Guid.NewGuid():N}";
_allocatedDatabases.Add(name);
return name;
}
private string AllocateDatabaseName()
{
var name = $"chromium-tests-{Guid.NewGuid():N}";
_allocatedDatabases.Add(name);
return name;
}
private async Task DropDatabaseAsync(string databaseName)
{
await _fixture.TruncateAllTablesAsync();
}
private static void SeedHttpFixtures(CannedHttpMessageHandler handler)
{
var feedUri = new Uri("https://chromereleases.googleblog.com/atom.xml?max-results=50&start-index=1&redirect=false");
var detailUri = new Uri("https://chromereleases.googleblog.com/2024/09/stable-channel-update-for-desktop.html");
handler.AddTextResponse(feedUri, ReadFixture("chromium-feed.xml"), "application/atom+xml");
handler.AddTextResponse(detailUri, ReadFixture("chromium-detail.html"), "text/html");
}
private static string ReadFixture(string filename)
{
var path = ResolveFixturePath(filename);
return File.ReadAllText(path);
}
private static string ResolveFixturePath(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "Vndr", "Chromium", "Fixtures", filename);
if (File.Exists(primary))
{
return primary;
}
return Path.Combine(baseDirectory, "Chromium", "Fixtures", filename);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
foreach (var name in _allocatedDatabases.Distinct(StringComparer.Ordinal))
{
await DropDatabaseAsync(name);
}
}
}
private static void SeedHttpFixtures(CannedHttpMessageHandler handler)
{
var feedUri = new Uri("https://chromereleases.googleblog.com/atom.xml?max-results=50&start-index=1&redirect=false");
var detailUri = new Uri("https://chromereleases.googleblog.com/2024/09/stable-channel-update-for-desktop.html");
handler.AddTextResponse(feedUri, ReadFixture("chromium-feed.xml"), "application/atom+xml");
handler.AddTextResponse(detailUri, ReadFixture("chromium-detail.html"), "text/html");
}
private static string ReadFixture(string filename)
{
var path = ResolveFixturePath(filename);
return File.ReadAllText(path);
}
private static string ResolveFixturePath(string filename)
{
var baseDirectory = AppContext.BaseDirectory;
var primary = Path.Combine(baseDirectory, "Source", "Vndr", "Chromium", "Fixtures", filename);
if (File.Exists(primary))
{
return primary;
}
return Path.Combine(baseDirectory, "Chromium", "Fixtures", filename);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
foreach (var name in _allocatedDatabases.Distinct(StringComparer.Ordinal))
{
await DropDatabaseAsync(name);
}
}
}

View File

@@ -1,47 +1,47 @@
using System;
using System.Linq;
using StellaOps.Concelier.Connector.Vndr.Chromium;
using StellaOps.Concelier.Connector.Vndr.Chromium.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Vndr.Chromium.Tests;
public sealed class ChromiumMapperTests
{
[Fact]
public void Map_DeduplicatesReferencesAndOrdersDeterministically()
{
var published = new DateTimeOffset(2024, 9, 12, 14, 0, 0, TimeSpan.Zero);
var metadata = new ChromiumDocumentMetadata(
"post-123",
"Stable Channel Update",
new Uri("https://chromium.example/stable-update.html"),
published,
null,
"Security fixes");
var dto = ChromiumDto.From(
metadata,
new[] { "CVE-2024-0001" },
new[] { "windows" },
new[] { new ChromiumVersionInfo("windows", "stable", "128.0.6613.88") },
new[]
{
new ChromiumReference("https://chromium.example/ref1", "advisory", "Ref 1"),
new ChromiumReference("https://chromium.example/ref1", "advisory", "Ref 1 duplicate"),
new ChromiumReference("https://chromium.example/ref2", "patch", "Ref 2"),
});
var (advisory, _) = ChromiumMapper.Map(dto, VndrChromiumConnectorPlugin.SourceName, published);
var referenceUrls = advisory.References.Select(r => r.Url).ToArray();
Assert.Equal(
new[]
{
"https://chromium.example/ref1",
"https://chromium.example/ref2",
"https://chromium.example/stable-update.html",
},
referenceUrls);
}
}
using System;
using System.Linq;
using StellaOps.Concelier.Connector.Vndr.Chromium;
using StellaOps.Concelier.Connector.Vndr.Chromium.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Vndr.Chromium.Tests;
public sealed class ChromiumMapperTests
{
[Fact]
public void Map_DeduplicatesReferencesAndOrdersDeterministically()
{
var published = new DateTimeOffset(2024, 9, 12, 14, 0, 0, TimeSpan.Zero);
var metadata = new ChromiumDocumentMetadata(
"post-123",
"Stable Channel Update",
new Uri("https://chromium.example/stable-update.html"),
published,
null,
"Security fixes");
var dto = ChromiumDto.From(
metadata,
new[] { "CVE-2024-0001" },
new[] { "windows" },
new[] { new ChromiumVersionInfo("windows", "stable", "128.0.6613.88") },
new[]
{
new ChromiumReference("https://chromium.example/ref1", "advisory", "Ref 1"),
new ChromiumReference("https://chromium.example/ref1", "advisory", "Ref 1 duplicate"),
new ChromiumReference("https://chromium.example/ref2", "patch", "Ref 2"),
});
var (advisory, _) = ChromiumMapper.Map(dto, VndrChromiumConnectorPlugin.SourceName, published);
var referenceUrls = advisory.References.Select(r => r.Url).ToArray();
Assert.Equal(
new[]
{
"https://chromium.example/ref1",
"https://chromium.example/ref2",
"https://chromium.example/stable-update.html",
},
referenceUrls);
}
}

View File

@@ -1,73 +1,73 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Concelier.Connector.Vndr.Cisco.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Vndr.Cisco.Tests;
public sealed class CiscoDtoFactoryTests
{
[Fact]
public async Task CreateAsync_MergesRawAndCsafProducts()
{
const string CsafPayload = @"
{
""product_tree"": {
""full_product_names"": [
{ ""product_id"": ""PID-1"", ""name"": ""Cisco Widget"" }
]
},
""vulnerabilities"": [
{
""product_status"": {
""known_affected"": [""PID-1""]
}
}
]
}";
var csafClient = new StubCsafClient(CsafPayload);
var factory = new CiscoDtoFactory(csafClient, NullLogger<CiscoDtoFactory>.Instance);
var raw = new CiscoRawAdvisory
{
AdvisoryId = "CISCO-SA-TEST",
AdvisoryTitle = "Test Advisory",
Summary = "Summary",
Sir = "High",
FirstPublished = "2025-10-01T00:00:00Z",
LastUpdated = "2025-10-02T00:00:00Z",
PublicationUrl = "https://example.com/advisory",
CsafUrl = "https://sec.cloudapps.cisco.com/csaf/test.json",
Cves = new List<string> { "CVE-2024-0001" },
BugIds = new List<string> { "BUG123" },
ProductNames = new List<string> { "Cisco Widget" },
Version = "1.2.3",
CvssBaseScore = "9.8"
};
var dto = await factory.CreateAsync(raw, CancellationToken.None);
dto.Should().NotBeNull();
dto.Severity.Should().Be("high");
dto.CvssBaseScore.Should().Be(9.8);
dto.Products.Should().HaveCount(1);
var product = dto.Products[0];
product.Name.Should().Be("Cisco Widget");
product.ProductId.Should().Be("PID-1");
product.Statuses.Should().Contain("known_affected");
}
private sealed class StubCsafClient : ICiscoCsafClient
{
private readonly string? _payload;
public StubCsafClient(string? payload) => _payload = payload;
public Task<string?> TryFetchAsync(string? url, CancellationToken cancellationToken)
=> Task.FromResult(_payload);
}
}
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Concelier.Connector.Vndr.Cisco.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Vndr.Cisco.Tests;
public sealed class CiscoDtoFactoryTests
{
[Fact]
public async Task CreateAsync_MergesRawAndCsafProducts()
{
const string CsafPayload = @"
{
""product_tree"": {
""full_product_names"": [
{ ""product_id"": ""PID-1"", ""name"": ""Cisco Widget"" }
]
},
""vulnerabilities"": [
{
""product_status"": {
""known_affected"": [""PID-1""]
}
}
]
}";
var csafClient = new StubCsafClient(CsafPayload);
var factory = new CiscoDtoFactory(csafClient, NullLogger<CiscoDtoFactory>.Instance);
var raw = new CiscoRawAdvisory
{
AdvisoryId = "CISCO-SA-TEST",
AdvisoryTitle = "Test Advisory",
Summary = "Summary",
Sir = "High",
FirstPublished = "2025-10-01T00:00:00Z",
LastUpdated = "2025-10-02T00:00:00Z",
PublicationUrl = "https://example.com/advisory",
CsafUrl = "https://sec.cloudapps.cisco.com/csaf/test.json",
Cves = new List<string> { "CVE-2024-0001" },
BugIds = new List<string> { "BUG123" },
ProductNames = new List<string> { "Cisco Widget" },
Version = "1.2.3",
CvssBaseScore = "9.8"
};
var dto = await factory.CreateAsync(raw, CancellationToken.None);
dto.Should().NotBeNull();
dto.Severity.Should().Be("high");
dto.CvssBaseScore.Should().Be(9.8);
dto.Products.Should().HaveCount(1);
var product = dto.Products[0];
product.Name.Should().Be("Cisco Widget");
product.ProductId.Should().Be("PID-1");
product.Statuses.Should().Contain("known_affected");
}
private sealed class StubCsafClient : ICiscoCsafClient
{
private readonly string? _payload;
public StubCsafClient(string? payload) => _payload = payload;
public Task<string?> TryFetchAsync(string? url, CancellationToken cancellationToken)
=> Task.FromResult(_payload);
}
}

View File

@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Vndr.Cisco;
@@ -54,7 +54,7 @@ public sealed class CiscoMapperTests
LastModified: updated,
PayloadId: null);
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, VndrCiscoConnectorPlugin.SourceName, "cisco.dto.test", new BsonDocument(), updated);
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, VndrCiscoConnectorPlugin.SourceName, "cisco.dto.test", new DocumentObject(), updated);
var advisory = CiscoMapper.Map(dto, document, dtoRecord);

View File

@@ -1,21 +1,21 @@
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Vndr.Msrc.Configuration;
using StellaOps.Concelier.Connector.Vndr.Msrc.Internal;
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Vndr.Msrc.Configuration;
using StellaOps.Concelier.Connector.Vndr.Msrc.Internal;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
@@ -24,175 +24,175 @@ using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Testing;
using Xunit;
using StellaOps.Concelier.Connector.Common.Http;
namespace StellaOps.Concelier.Connector.Vndr.Msrc.Tests;
namespace StellaOps.Concelier.Connector.Vndr.Msrc.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class MsrcConnectorTests : IAsyncLifetime
{
private static readonly Uri TokenUri = new("https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111/oauth2/v2.0/token");
private static readonly Uri SummaryUri = new("https://api.msrc.microsoft.com/sug/v2.0/vulnerabilities");
private static readonly Uri DetailUri = new("https://api.msrc.microsoft.com/sug/v2.0/vulnerability/ADV123456");
private readonly ConcelierPostgresFixture _fixture;
private readonly CannedHttpMessageHandler _handler;
public MsrcConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesCanonicalAdvisory()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses();
var connector = provider.GetRequiredService<MsrcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
advisories.Should().HaveCount(1);
var advisory = advisories[0];
advisory.AdvisoryKey.Should().Be("ADV123456");
advisory.Severity.Should().Be("critical");
advisory.Aliases.Should().Contain("CVE-2025-0001");
advisory.Aliases.Should().Contain("KB5031234");
advisory.References.Should().Contain(reference => reference.Url == "https://msrc.microsoft.com/update-guide/vulnerability/ADV123456");
advisory.References.Should().Contain(reference => reference.Url == "https://download.microsoft.com/msrc/2025/ADV123456.cvrf.zip");
advisory.AffectedPackages.Should().HaveCount(1);
advisory.AffectedPackages[0].NormalizedVersions.Should().Contain(rule => rule.Scheme == "msrc.build" && rule.Value == "22631.3520");
advisory.CvssMetrics.Should().Contain(metric => metric.BaseScore == 8.1);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(MsrcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
pendingDocs!.AsBsonArray.Should().BeEmpty();
var documentStore = provider.GetRequiredService<IDocumentStore>();
var cvrfDocument = await documentStore.FindBySourceAndUriAsync(MsrcConnectorPlugin.SourceName, "https://download.microsoft.com/msrc/2025/ADV123456.cvrf.zip", CancellationToken.None);
cvrfDocument.Should().NotBeNull();
cvrfDocument!.Status.Should().Be(DocumentStatuses.Mapped);
}
public sealed class MsrcConnectorTests : IAsyncLifetime
{
private static readonly Uri TokenUri = new("https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111/oauth2/v2.0/token");
private static readonly Uri SummaryUri = new("https://api.msrc.microsoft.com/sug/v2.0/vulnerabilities");
private static readonly Uri DetailUri = new("https://api.msrc.microsoft.com/sug/v2.0/vulnerability/ADV123456");
private readonly ConcelierPostgresFixture _fixture;
private readonly CannedHttpMessageHandler _handler;
public MsrcConnectorTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
_handler = new CannedHttpMessageHandler();
}
[Fact]
public async Task FetchParseMap_ProducesCanonicalAdvisory()
{
await using var provider = await BuildServiceProviderAsync();
SeedResponses();
var connector = provider.GetRequiredService<MsrcConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(5, CancellationToken.None);
advisories.Should().HaveCount(1);
var advisory = advisories[0];
advisory.AdvisoryKey.Should().Be("ADV123456");
advisory.Severity.Should().Be("critical");
advisory.Aliases.Should().Contain("CVE-2025-0001");
advisory.Aliases.Should().Contain("KB5031234");
advisory.References.Should().Contain(reference => reference.Url == "https://msrc.microsoft.com/update-guide/vulnerability/ADV123456");
advisory.References.Should().Contain(reference => reference.Url == "https://download.microsoft.com/msrc/2025/ADV123456.cvrf.zip");
advisory.AffectedPackages.Should().HaveCount(1);
advisory.AffectedPackages[0].NormalizedVersions.Should().Contain(rule => rule.Scheme == "msrc.build" && rule.Value == "22631.3520");
advisory.CvssMetrics.Should().Contain(metric => metric.BaseScore == 8.1);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(MsrcConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
pendingDocs!.AsDocumentArray.Should().BeEmpty();
var documentStore = provider.GetRequiredService<IDocumentStore>();
var cvrfDocument = await documentStore.FindBySourceAndUriAsync(MsrcConnectorPlugin.SourceName, "https://download.microsoft.com/msrc/2025/ADV123456.cvrf.zip", CancellationToken.None);
cvrfDocument.Should().NotBeNull();
cvrfDocument!.Status.Should().Be(DocumentStatuses.Mapped);
}
private async Task<ServiceProvider> BuildServiceProviderAsync()
{
await _fixture.TruncateAllTablesAsync();
_handler.Clear();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton(_handler);
services.AddSingleton(TimeProvider.System);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton(_handler);
services.AddSingleton(TimeProvider.System);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddMsrcConnector(options =>
{
options.TenantId = "11111111-1111-1111-1111-111111111111";
options.ClientId = "client-id";
options.ClientSecret = "secret";
options.InitialLastModified = new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero);
options.RequestDelay = TimeSpan.Zero;
options.MaxAdvisoriesPerFetch = 10;
options.CursorOverlap = TimeSpan.FromMinutes(1);
options.DownloadCvrf = true;
});
services.Configure<HttpClientFactoryOptions>(MsrcOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
services.Configure<HttpClientFactoryOptions>(MsrcOptions.TokenClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
services.AddSourceCommon();
services.AddMsrcConnector(options =>
{
options.TenantId = "11111111-1111-1111-1111-111111111111";
options.ClientId = "client-id";
options.ClientSecret = "secret";
options.InitialLastModified = new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero);
options.RequestDelay = TimeSpan.Zero;
options.MaxAdvisoriesPerFetch = 10;
options.CursorOverlap = TimeSpan.FromMinutes(1);
options.DownloadCvrf = true;
});
services.Configure<HttpClientFactoryOptions>(MsrcOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
services.Configure<HttpClientFactoryOptions>(MsrcOptions.TokenClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
return services.BuildServiceProvider();
}
private void SeedResponses()
{
var summaryJson = ReadFixture("msrc-summary.json");
var detailJson = ReadFixture("msrc-detail.json");
var tokenJson = """{"token_type":"Bearer","expires_in":3600,"access_token":"fake-token"}""";
var cvrfBytes = Encoding.UTF8.GetBytes("PK\x03\x04FAKECVRF");
_handler.SetFallback(request =>
{
if (request.RequestUri is null)
{
return new HttpResponseMessage(HttpStatusCode.BadRequest);
}
if (request.RequestUri.Host.Contains("login.microsoftonline.com", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(tokenJson, Encoding.UTF8, "application/json"),
};
}
if (request.RequestUri.AbsolutePath.EndsWith("/vulnerabilities", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(summaryJson, Encoding.UTF8, "application/json"),
};
}
if (request.RequestUri.AbsolutePath.Contains("/vulnerability/ADV123456", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(detailJson, Encoding.UTF8, "application/json"),
};
}
if (request.RequestUri.Host.Contains("download.microsoft.com", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(cvrfBytes)
{
Headers =
{
ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip"),
},
},
};
}
return new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No canned response for {request.RequestUri}", Encoding.UTF8),
};
});
}
private static string ReadFixture(string fileName)
=> System.IO.File.ReadAllText(System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName));
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
}
private void SeedResponses()
{
var summaryJson = ReadFixture("msrc-summary.json");
var detailJson = ReadFixture("msrc-detail.json");
var tokenJson = """{"token_type":"Bearer","expires_in":3600,"access_token":"fake-token"}""";
var cvrfBytes = Encoding.UTF8.GetBytes("PK\x03\x04FAKECVRF");
_handler.SetFallback(request =>
{
if (request.RequestUri is null)
{
return new HttpResponseMessage(HttpStatusCode.BadRequest);
}
if (request.RequestUri.Host.Contains("login.microsoftonline.com", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(tokenJson, Encoding.UTF8, "application/json"),
};
}
if (request.RequestUri.AbsolutePath.EndsWith("/vulnerabilities", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(summaryJson, Encoding.UTF8, "application/json"),
};
}
if (request.RequestUri.AbsolutePath.Contains("/vulnerability/ADV123456", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(detailJson, Encoding.UTF8, "application/json"),
};
}
if (request.RequestUri.Host.Contains("download.microsoft.com", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(cvrfBytes)
{
Headers =
{
ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip"),
},
},
};
}
return new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No canned response for {request.RequestUri}", Encoding.UTF8),
};
});
}
private static string ReadFixture(string fileName)
=> System.IO.File.ReadAllText(System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", fileName));
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
}

View File

@@ -1,27 +1,27 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Vndr.Oracle;
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
using StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
using StellaOps.Concelier.Connector.Vndr.Oracle;
using StellaOps.Concelier.Connector.Vndr.Oracle.Configuration;
using StellaOps.Concelier.Connector.Vndr.Oracle.Internal;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
@@ -30,82 +30,82 @@ using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Storage.PsirtFlags;
using StellaOps.Concelier.Testing;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Tests;
namespace StellaOps.Concelier.Connector.Vndr.Oracle.Tests;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class OracleConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private readonly ITestOutputHelper _output;
private static readonly Uri AdvisoryOne = new("https://www.oracle.com/security-alerts/cpuapr2024-01.html");
private static readonly Uri AdvisoryTwo = new("https://www.oracle.com/security-alerts/cpuapr2024-02.html");
private static readonly Uri CalendarUri = new("https://www.oracle.com/security-alerts/cpuapr2024.html");
public OracleConnectorTests(ConcelierPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 4, 18, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
_output = output;
}
[Fact]
public async Task FetchParseMap_EmitsOraclePsirtSnapshot()
{
await using var provider = await BuildServiceProviderAsync();
SeedDetails();
var calendarFetcher = provider.GetRequiredService<OracleCalendarFetcher>();
var discovered = await calendarFetcher.GetAdvisoryUrisAsync(CancellationToken.None);
_output.WriteLine("Calendar URIs: " + string.Join(", ", discovered.Select(static uri => uri.AbsoluteUri)));
Assert.Equal(2, discovered.Count);
// Re-seed fixtures because calendar fetch consumes canned responses.
SeedDetails();
var connector = provider.GetRequiredService<OracleConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
_output.WriteLine("Advisories fetched: " + string.Join(", ", advisories.Select(static a => a.AdvisoryKey)));
_output.WriteLine($"Advisory count: {advisories.Count}");
Assert.Equal(2, advisories.Count);
var first = advisories.Single(advisory => advisory.AdvisoryKey == "oracle/cpuapr2024-01-html");
var second = advisories.Single(advisory => advisory.AdvisoryKey == "oracle/cpuapr2024-02-html");
Assert.Equal(new DateTimeOffset(2024, 4, 18, 12, 30, 0, TimeSpan.Zero), first.Published);
Assert.Equal(new DateTimeOffset(2024, 4, 19, 8, 15, 0, TimeSpan.Zero), second.Published);
Assert.All(advisories, advisory =>
{
Assert.True(advisory.Aliases.Any(alias => alias.StartsWith("CVE-", StringComparison.Ordinal)), $"Expected CVE alias for {advisory.AdvisoryKey}");
Assert.NotEmpty(advisory.AffectedPackages);
});
var snapshot = SnapshotSerializer.ToSnapshot(advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray());
var expected = ReadFixture("oracle-advisories.snapshot.json");
var normalizedSnapshot = Normalize(snapshot);
var normalizedExpected = Normalize(expected);
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Oracle", "Fixtures", "oracle-advisories.actual.json");
var actualDirectory = Path.GetDirectoryName(actualPath);
if (!string.IsNullOrEmpty(actualDirectory))
{
Directory.CreateDirectory(actualDirectory);
}
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(normalizedExpected, normalizedSnapshot);
public sealed class OracleConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private readonly ITestOutputHelper _output;
private static readonly Uri AdvisoryOne = new("https://www.oracle.com/security-alerts/cpuapr2024-01.html");
private static readonly Uri AdvisoryTwo = new("https://www.oracle.com/security-alerts/cpuapr2024-02.html");
private static readonly Uri CalendarUri = new("https://www.oracle.com/security-alerts/cpuapr2024.html");
public OracleConnectorTests(ConcelierPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 4, 18, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
_output = output;
}
[Fact]
public async Task FetchParseMap_EmitsOraclePsirtSnapshot()
{
await using var provider = await BuildServiceProviderAsync();
SeedDetails();
var calendarFetcher = provider.GetRequiredService<OracleCalendarFetcher>();
var discovered = await calendarFetcher.GetAdvisoryUrisAsync(CancellationToken.None);
_output.WriteLine("Calendar URIs: " + string.Join(", ", discovered.Select(static uri => uri.AbsoluteUri)));
Assert.Equal(2, discovered.Count);
// Re-seed fixtures because calendar fetch consumes canned responses.
SeedDetails();
var connector = provider.GetRequiredService<OracleConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
_output.WriteLine("Advisories fetched: " + string.Join(", ", advisories.Select(static a => a.AdvisoryKey)));
_output.WriteLine($"Advisory count: {advisories.Count}");
Assert.Equal(2, advisories.Count);
var first = advisories.Single(advisory => advisory.AdvisoryKey == "oracle/cpuapr2024-01-html");
var second = advisories.Single(advisory => advisory.AdvisoryKey == "oracle/cpuapr2024-02-html");
Assert.Equal(new DateTimeOffset(2024, 4, 18, 12, 30, 0, TimeSpan.Zero), first.Published);
Assert.Equal(new DateTimeOffset(2024, 4, 19, 8, 15, 0, TimeSpan.Zero), second.Published);
Assert.All(advisories, advisory =>
{
Assert.True(advisory.Aliases.Any(alias => alias.StartsWith("CVE-", StringComparison.Ordinal)), $"Expected CVE alias for {advisory.AdvisoryKey}");
Assert.NotEmpty(advisory.AffectedPackages);
});
var snapshot = SnapshotSerializer.ToSnapshot(advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray());
var expected = ReadFixture("oracle-advisories.snapshot.json");
var normalizedSnapshot = Normalize(snapshot);
var normalizedExpected = Normalize(expected);
if (!string.Equals(normalizedExpected, normalizedSnapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Oracle", "Fixtures", "oracle-advisories.actual.json");
var actualDirectory = Path.GetDirectoryName(actualPath);
if (!string.IsNullOrEmpty(actualDirectory))
{
Directory.CreateDirectory(actualDirectory);
}
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(normalizedExpected, normalizedSnapshot);
var psirtStore = provider.GetRequiredService<IPsirtFlagStore>();
var flags = new List<PsirtFlagRecord>();
foreach (var advisory in advisories)
@@ -119,124 +119,124 @@ public sealed class OracleConnectorTests : IAsyncLifetime
Assert.Equal(2, flags.Count);
Assert.All(flags, flag => Assert.Equal("Oracle", flag.Vendor));
}
[Fact]
public async Task FetchAsync_IdempotentForUnchangedAdvisories()
{
await using var provider = await BuildServiceProviderAsync();
SeedDetails();
var connector = provider.GetRequiredService<OracleConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.MapAsync(provider, CancellationToken.None);
// Second run with unchanged documents should rely on fetch cache.
SeedDetails();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrOracleConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = OracleCursor.FromBson(state!.Cursor);
Assert.Empty(cursor.PendingDocuments);
Assert.Empty(cursor.PendingMappings);
Assert.Equal(2, cursor.FetchCache.Count);
Assert.All(cursor.FetchCache.Values, entry => Assert.False(string.IsNullOrWhiteSpace(entry.Sha256)));
var documentStore = provider.GetRequiredService<IDocumentStore>();
var first = await documentStore.FindBySourceAndUriAsync(VndrOracleConnectorPlugin.SourceName, AdvisoryOne.ToString(), CancellationToken.None);
Assert.NotNull(first);
Assert.Equal(DocumentStatuses.Mapped, first!.Status);
var second = await documentStore.FindBySourceAndUriAsync(VndrOracleConnectorPlugin.SourceName, AdvisoryTwo.ToString(), CancellationToken.None);
Assert.NotNull(second);
Assert.Equal(DocumentStatuses.Mapped, second!.Status);
}
[Fact]
public async Task FetchAsync_IdempotentForUnchangedAdvisories()
{
await using var provider = await BuildServiceProviderAsync();
SeedDetails();
var connector = provider.GetRequiredService<OracleConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.MapAsync(provider, CancellationToken.None);
// Second run with unchanged documents should rely on fetch cache.
SeedDetails();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrOracleConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = OracleCursor.FromBson(state!.Cursor);
Assert.Empty(cursor.PendingDocuments);
Assert.Empty(cursor.PendingMappings);
Assert.Equal(2, cursor.FetchCache.Count);
Assert.All(cursor.FetchCache.Values, entry => Assert.False(string.IsNullOrWhiteSpace(entry.Sha256)));
var documentStore = provider.GetRequiredService<IDocumentStore>();
var first = await documentStore.FindBySourceAndUriAsync(VndrOracleConnectorPlugin.SourceName, AdvisoryOne.ToString(), CancellationToken.None);
Assert.NotNull(first);
Assert.Equal(DocumentStatuses.Mapped, first!.Status);
var second = await documentStore.FindBySourceAndUriAsync(VndrOracleConnectorPlugin.SourceName, AdvisoryTwo.ToString(), CancellationToken.None);
Assert.NotNull(second);
Assert.Equal(DocumentStatuses.Mapped, second!.Status);
var dtoStore = provider.GetRequiredService<IDtoStore>();
var dto1 = await dtoStore.FindByDocumentIdAsync(first!.Id, CancellationToken.None);
Assert.NotNull(dto1);
var dto2 = await dtoStore.FindByDocumentIdAsync(second!.Id, CancellationToken.None);
Assert.NotNull(dto2);
}
[Fact]
public async Task FetchAsync_ResumeProcessesNewCalendarEntries()
{
await using var provider = await BuildServiceProviderAsync();
AddCalendarResponse(CalendarUri, "oracle-calendar-cpuapr2024-single.html");
AddDetailResponse(AdvisoryOne, "oracle-detail-cpuapr2024-01.html", "\"oracle-001\"");
var connector = provider.GetRequiredService<OracleConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Single(advisories);
Assert.Equal("oracle/cpuapr2024-01-html", advisories[0].AdvisoryKey);
_handler.Clear();
AddCalendarResponse(CalendarUri, "oracle-calendar-cpuapr2024.html");
AddDetailResponse(AdvisoryOne, "oracle-detail-cpuapr2024-01.html", "\"oracle-001\"");
AddDetailResponse(AdvisoryTwo, "oracle-detail-cpuapr2024-02.html", "\"oracle-002\"");
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.MapAsync(provider, CancellationToken.None);
advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
Assert.Contains(advisories, advisory => advisory.AdvisoryKey == "oracle/cpuapr2024-02-html");
}
[Fact]
public async Task ParseAsync_InvalidDocumentIsQuarantined()
{
await using var provider = await BuildServiceProviderAsync();
AddCalendarResponse(CalendarUri, "oracle-calendar-cpuapr2024.html");
AddDetailResponse(AdvisoryOne, "oracle-detail-invalid.html", "\"oracle-001\"");
AddDetailResponse(AdvisoryTwo, "oracle-detail-cpuapr2024-02.html", "\"oracle-002\"");
var connector = provider.GetRequiredService<OracleConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var invalidDocument = await documentStore.FindBySourceAndUriAsync(VndrOracleConnectorPlugin.SourceName, AdvisoryOne.ToString(), CancellationToken.None);
Assert.NotNull(invalidDocument);
_output.WriteLine($"Invalid document status: {invalidDocument!.Status}");
}
[Fact]
public async Task FetchAsync_ResumeProcessesNewCalendarEntries()
{
await using var provider = await BuildServiceProviderAsync();
AddCalendarResponse(CalendarUri, "oracle-calendar-cpuapr2024-single.html");
AddDetailResponse(AdvisoryOne, "oracle-detail-cpuapr2024-01.html", "\"oracle-001\"");
var connector = provider.GetRequiredService<OracleConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Single(advisories);
Assert.Equal("oracle/cpuapr2024-01-html", advisories[0].AdvisoryKey);
_handler.Clear();
AddCalendarResponse(CalendarUri, "oracle-calendar-cpuapr2024.html");
AddDetailResponse(AdvisoryOne, "oracle-detail-cpuapr2024-01.html", "\"oracle-001\"");
AddDetailResponse(AdvisoryTwo, "oracle-detail-cpuapr2024-02.html", "\"oracle-002\"");
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.MapAsync(provider, CancellationToken.None);
advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(2, advisories.Count);
Assert.Contains(advisories, advisory => advisory.AdvisoryKey == "oracle/cpuapr2024-02-html");
}
[Fact]
public async Task ParseAsync_InvalidDocumentIsQuarantined()
{
await using var provider = await BuildServiceProviderAsync();
AddCalendarResponse(CalendarUri, "oracle-calendar-cpuapr2024.html");
AddDetailResponse(AdvisoryOne, "oracle-detail-invalid.html", "\"oracle-001\"");
AddDetailResponse(AdvisoryTwo, "oracle-detail-cpuapr2024-02.html", "\"oracle-002\"");
var connector = provider.GetRequiredService<OracleConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.ParseAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var invalidDocument = await documentStore.FindBySourceAndUriAsync(VndrOracleConnectorPlugin.SourceName, AdvisoryOne.ToString(), CancellationToken.None);
Assert.NotNull(invalidDocument);
_output.WriteLine($"Invalid document status: {invalidDocument!.Status}");
var dtoStore = provider.GetRequiredService<IDtoStore>();
var invalidDto = await dtoStore.FindByDocumentIdAsync(invalidDocument.Id, CancellationToken.None);
if (invalidDto is not null)
{
_output.WriteLine("Validation unexpectedly succeeded. DTO: " + invalidDto.Payload.ToJson());
}
Assert.Equal(DocumentStatuses.Failed, invalidDocument.Status);
Assert.Null(invalidDto);
var validDocument = await documentStore.FindBySourceAndUriAsync(VndrOracleConnectorPlugin.SourceName, AdvisoryTwo.ToString(), CancellationToken.None);
Assert.NotNull(validDocument);
Assert.Equal(DocumentStatuses.PendingMap, validDocument!.Status);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.MapAsync(provider, CancellationToken.None);
var advisories = await provider.GetRequiredService<IAdvisoryStore>().GetRecentAsync(10, CancellationToken.None);
_output.WriteLine("Validation unexpectedly succeeded. DTO: " + invalidDto.Payload.ToJson());
}
Assert.Equal(DocumentStatuses.Failed, invalidDocument.Status);
Assert.Null(invalidDto);
var validDocument = await documentStore.FindBySourceAndUriAsync(VndrOracleConnectorPlugin.SourceName, AdvisoryTwo.ToString(), CancellationToken.None);
Assert.NotNull(validDocument);
Assert.Equal(DocumentStatuses.PendingMap, validDocument!.Status);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await connector.MapAsync(provider, CancellationToken.None);
var advisories = await provider.GetRequiredService<IAdvisoryStore>().GetRecentAsync(10, CancellationToken.None);
Assert.Single(advisories);
Assert.Equal("oracle/cpuapr2024-02-html", advisories[0].AdvisoryKey);
@@ -246,109 +246,109 @@ public sealed class OracleConnectorTests : IAsyncLifetime
var missingFlag = await psirtStore.FindAsync("oracle/cpuapr2024-01-html", CancellationToken.None);
Assert.Null(missingFlag);
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrOracleConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = OracleCursor.FromBson(state!.Cursor);
Assert.Empty(cursor.PendingDocuments);
Assert.Empty(cursor.PendingMappings);
}
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VndrOracleConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
var cursor = OracleCursor.FromBson(state!.Cursor);
Assert.Empty(cursor.PendingDocuments);
Assert.Empty(cursor.PendingMappings);
}
private async Task<ServiceProvider> BuildServiceProviderAsync()
{
await _fixture.TruncateAllTablesAsync();
_handler.Clear();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddOracleConnector(opts =>
{
opts.CalendarUris = new List<Uri> { CalendarUri };
opts.RequestDelay = TimeSpan.Zero;
});
services.Configure<HttpClientFactoryOptions>(OracleOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
services.AddSourceCommon();
services.AddOracleConnector(opts =>
{
opts.CalendarUris = new List<Uri> { CalendarUri };
opts.RequestDelay = TimeSpan.Zero;
});
services.Configure<HttpClientFactoryOptions>(OracleOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = _handler;
});
});
return services.BuildServiceProvider();
}
private void SeedDetails()
{
AddCalendarResponse(CalendarUri, "oracle-calendar-cpuapr2024.html");
AddDetailResponse(AdvisoryOne, "oracle-detail-cpuapr2024-01.html", "\"oracle-001\"");
AddDetailResponse(AdvisoryTwo, "oracle-detail-cpuapr2024-02.html", "\"oracle-002\"");
}
private void AddCalendarResponse(Uri uri, string fixture)
{
_handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/html"),
};
return response;
});
}
private void AddDetailResponse(Uri uri, string fixture, string? etag)
{
_handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/html"),
};
if (!string.IsNullOrEmpty(etag))
{
response.Headers.ETag = new EntityTagHeaderValue(etag);
}
return response;
});
}
private static string ReadFixture(string filename)
{
var primary = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Oracle", "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var fallback = Path.Combine(AppContext.BaseDirectory, "Oracle", "Fixtures", filename);
if (File.Exists(fallback))
{
return File.ReadAllText(fallback);
}
throw new FileNotFoundException($"Fixture '{filename}' not found in test output.", filename);
}
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
}
private void SeedDetails()
{
AddCalendarResponse(CalendarUri, "oracle-calendar-cpuapr2024.html");
AddDetailResponse(AdvisoryOne, "oracle-detail-cpuapr2024-01.html", "\"oracle-001\"");
AddDetailResponse(AdvisoryTwo, "oracle-detail-cpuapr2024-02.html", "\"oracle-002\"");
}
private void AddCalendarResponse(Uri uri, string fixture)
{
_handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/html"),
};
return response;
});
}
private void AddDetailResponse(Uri uri, string fixture, string? etag)
{
_handler.AddResponse(uri, () =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ReadFixture(fixture), Encoding.UTF8, "text/html"),
};
if (!string.IsNullOrEmpty(etag))
{
response.Headers.ETag = new EntityTagHeaderValue(etag);
}
return response;
});
}
private static string ReadFixture(string filename)
{
var primary = Path.Combine(AppContext.BaseDirectory, "Source", "Vndr", "Oracle", "Fixtures", filename);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var fallback = Path.Combine(AppContext.BaseDirectory, "Oracle", "Fixtures", filename);
if (File.Exists(fallback))
{
return File.ReadAllText(fallback);
}
throw new FileNotFoundException($"Fixture '{filename}' not found in test output.", filename);
}
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal);
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
}

View File

@@ -1,26 +1,26 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Vndr.Vmware;
using StellaOps.Concelier.Connector.Vndr.Vmware.Configuration;
using StellaOps.Concelier.Connector.Vndr.Vmware.Internal;
using StellaOps.Concelier.Connector.Vndr.Vmware;
using StellaOps.Concelier.Connector.Vndr.Vmware.Configuration;
using StellaOps.Concelier.Connector.Vndr.Vmware.Internal;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage;
@@ -29,59 +29,59 @@ using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Storage.PsirtFlags;
using StellaOps.Concelier.Testing;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Connector.Vndr.Vmware.Tests.Vmware;
namespace StellaOps.Concelier.Connector.Vndr.Vmware.Tests.Vmware;
[Collection(ConcelierFixtureCollection.Name)]
public sealed class VmwareConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private readonly ITestOutputHelper _output;
private static readonly Uri IndexUri = new("https://vmware.example/api/vmsa/index.json");
private static readonly Uri DetailOne = new("https://vmware.example/api/vmsa/VMSA-2024-0001.json");
private static readonly Uri DetailTwo = new("https://vmware.example/api/vmsa/VMSA-2024-0002.json");
private static readonly Uri DetailThree = new("https://vmware.example/api/vmsa/VMSA-2024-0003.json");
public VmwareConnectorTests(ConcelierPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 4, 5, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
_output = output;
}
[Fact]
public async Task FetchParseMap_ProducesSnapshotAndCoversResume()
{
await using var provider = await BuildServiceProviderAsync();
SeedInitialResponses();
using var metrics = new VmwareMetricCollector();
var connector = provider.GetRequiredService<VmwareConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
var ordered = advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray();
var snapshot = Normalize(SnapshotSerializer.ToSnapshot(ordered));
var expected = Normalize(ReadFixture("vmware-advisories.snapshot.json"));
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Vmware", "Fixtures", "vmware-advisories.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(expected, snapshot);
public sealed class VmwareConnectorTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly FakeTimeProvider _timeProvider;
private readonly CannedHttpMessageHandler _handler;
private readonly ITestOutputHelper _output;
private static readonly Uri IndexUri = new("https://vmware.example/api/vmsa/index.json");
private static readonly Uri DetailOne = new("https://vmware.example/api/vmsa/VMSA-2024-0001.json");
private static readonly Uri DetailTwo = new("https://vmware.example/api/vmsa/VMSA-2024-0002.json");
private static readonly Uri DetailThree = new("https://vmware.example/api/vmsa/VMSA-2024-0003.json");
public VmwareConnectorTests(ConcelierPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 4, 5, 0, 0, 0, TimeSpan.Zero));
_handler = new CannedHttpMessageHandler();
_output = output;
}
[Fact]
public async Task FetchParseMap_ProducesSnapshotAndCoversResume()
{
await using var provider = await BuildServiceProviderAsync();
SeedInitialResponses();
using var metrics = new VmwareMetricCollector();
var connector = provider.GetRequiredService<VmwareConnector>();
await connector.FetchAsync(provider, CancellationToken.None);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
var advisoryStore = provider.GetRequiredService<IAdvisoryStore>();
var advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
var ordered = advisories.OrderBy(static a => a.AdvisoryKey, StringComparer.Ordinal).ToArray();
var snapshot = Normalize(SnapshotSerializer.ToSnapshot(ordered));
var expected = Normalize(ReadFixture("vmware-advisories.snapshot.json"));
if (!string.Equals(expected, snapshot, StringComparison.Ordinal))
{
var actualPath = Path.Combine(AppContext.BaseDirectory, "Vmware", "Fixtures", "vmware-advisories.actual.json");
Directory.CreateDirectory(Path.GetDirectoryName(actualPath)!);
File.WriteAllText(actualPath, snapshot);
}
Assert.Equal(expected, snapshot);
var psirtStore = provider.GetRequiredService<IPsirtFlagStore>();
var psirtFlags = new List<PsirtFlagRecord>();
foreach (var advisory in ordered)
@@ -95,178 +95,178 @@ public sealed class VmwareConnectorTests : IAsyncLifetime
Assert.Equal(2, psirtFlags.Count);
Assert.All(psirtFlags, flag => Assert.Equal("VMware", flag.Vendor));
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VmwareConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.Empty(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) ? pendingDocs.AsBsonArray : new BsonArray());
Assert.Empty(state.Cursor.TryGetValue("pendingMappings", out var pendingMaps) ? pendingMaps.AsBsonArray : new BsonArray());
var cursorSnapshot = VmwareCursor.FromBson(state.Cursor);
_output.WriteLine($"Initial fetch cache entries: {cursorSnapshot.FetchCache.Count}");
foreach (var entry in cursorSnapshot.FetchCache)
{
_output.WriteLine($"Cache seed: {entry.Key} -> {entry.Value.Sha256}");
}
// Second run with unchanged advisories and one new advisory.
SeedUpdateResponses();
_timeProvider.Advance(TimeSpan.FromHours(1));
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var resumeDocOne = await documentStore.FindBySourceAndUriAsync(VmwareConnectorPlugin.SourceName, DetailOne.ToString(), CancellationToken.None);
var resumeDocTwo = await documentStore.FindBySourceAndUriAsync(VmwareConnectorPlugin.SourceName, DetailTwo.ToString(), CancellationToken.None);
_output.WriteLine($"After resume fetch status: {resumeDocOne?.Status} ({resumeDocOne?.Sha256}), {resumeDocTwo?.Status} ({resumeDocTwo?.Sha256})");
Assert.Equal(DocumentStatuses.Mapped, resumeDocOne?.Status);
Assert.Equal(DocumentStatuses.Mapped, resumeDocTwo?.Status);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(3, advisories.Count);
Assert.Contains(advisories, advisory => advisory.AdvisoryKey == "VMSA-2024-0003");
psirtFlags = await psirtCollection.Find(Builders<BsonDocument>.Filter.Empty).ToListAsync();
_output.WriteLine("PSIRT flags after resume: " + string.Join(", ", psirtFlags.Select(flag => flag.GetValue("_id", BsonValue.Create("<missing>")).ToString())));
Assert.Equal(3, psirtFlags.Count);
Assert.Contains(psirtFlags, doc => doc["_id"] == "VMSA-2024-0003");
var measurements = metrics.Measurements;
_output.WriteLine("Captured metrics:");
foreach (var measurement in measurements)
{
_output.WriteLine($"{measurement.Name} -> {measurement.Value}");
}
Assert.Equal(0, Sum(measurements, "vmware.fetch.failures"));
Assert.Equal(0, Sum(measurements, "vmware.parse.fail"));
Assert.Equal(3, Sum(measurements, "vmware.fetch.items")); // two initial, one new
var affectedCounts = measurements
.Where(m => m.Name == "vmware.map.affected_count")
.Select(m => (int)m.Value)
.OrderBy(v => v)
.ToArray();
Assert.Equal(new[] { 1, 1, 2 }, affectedCounts);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_handler.Clear();
return Task.CompletedTask;
}
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(VmwareConnectorPlugin.SourceName, CancellationToken.None);
Assert.NotNull(state);
Assert.Empty(state!.Cursor.TryGetValue("pendingDocuments", out var pendingDocs) ? pendingDocs.AsDocumentArray : new DocumentArray());
Assert.Empty(state.Cursor.TryGetValue("pendingMappings", out var pendingMaps) ? pendingMaps.AsDocumentArray : new DocumentArray());
var cursorSnapshot = VmwareCursor.FromBson(state.Cursor);
_output.WriteLine($"Initial fetch cache entries: {cursorSnapshot.FetchCache.Count}");
foreach (var entry in cursorSnapshot.FetchCache)
{
_output.WriteLine($"Cache seed: {entry.Key} -> {entry.Value.Sha256}");
}
// Second run with unchanged advisories and one new advisory.
SeedUpdateResponses();
_timeProvider.Advance(TimeSpan.FromHours(1));
await connector.FetchAsync(provider, CancellationToken.None);
var documentStore = provider.GetRequiredService<IDocumentStore>();
var resumeDocOne = await documentStore.FindBySourceAndUriAsync(VmwareConnectorPlugin.SourceName, DetailOne.ToString(), CancellationToken.None);
var resumeDocTwo = await documentStore.FindBySourceAndUriAsync(VmwareConnectorPlugin.SourceName, DetailTwo.ToString(), CancellationToken.None);
_output.WriteLine($"After resume fetch status: {resumeDocOne?.Status} ({resumeDocOne?.Sha256}), {resumeDocTwo?.Status} ({resumeDocTwo?.Sha256})");
Assert.Equal(DocumentStatuses.Mapped, resumeDocOne?.Status);
Assert.Equal(DocumentStatuses.Mapped, resumeDocTwo?.Status);
await connector.ParseAsync(provider, CancellationToken.None);
await connector.MapAsync(provider, CancellationToken.None);
advisories = await advisoryStore.GetRecentAsync(10, CancellationToken.None);
Assert.Equal(3, advisories.Count);
Assert.Contains(advisories, advisory => advisory.AdvisoryKey == "VMSA-2024-0003");
psirtFlags = await psirtCollection.Find(Builders<DocumentObject>.Filter.Empty).ToListAsync();
_output.WriteLine("PSIRT flags after resume: " + string.Join(", ", psirtFlags.Select(flag => flag.GetValue("_id", DocumentValue.Create("<missing>")).ToString())));
Assert.Equal(3, psirtFlags.Count);
Assert.Contains(psirtFlags, doc => doc["_id"] == "VMSA-2024-0003");
var measurements = metrics.Measurements;
_output.WriteLine("Captured metrics:");
foreach (var measurement in measurements)
{
_output.WriteLine($"{measurement.Name} -> {measurement.Value}");
}
Assert.Equal(0, Sum(measurements, "vmware.fetch.failures"));
Assert.Equal(0, Sum(measurements, "vmware.parse.fail"));
Assert.Equal(3, Sum(measurements, "vmware.fetch.items")); // two initial, one new
var affectedCounts = measurements
.Where(m => m.Name == "vmware.map.affected_count")
.Select(m => (int)m.Value)
.OrderBy(v => v)
.ToArray();
Assert.Equal(new[] { 1, 1, 2 }, affectedCounts);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_handler.Clear();
return Task.CompletedTask;
}
private async Task<ServiceProvider> BuildServiceProviderAsync()
{
await _fixture.TruncateAllTablesAsync();
_handler.Clear();
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(_timeProvider);
services.AddSingleton(_handler);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
services.AddVmwareConnector(opts =>
{
opts.IndexUri = IndexUri;
opts.InitialBackfill = TimeSpan.FromDays(30);
opts.ModifiedTolerance = TimeSpan.FromMinutes(5);
opts.MaxAdvisoriesPerFetch = 10;
opts.RequestDelay = TimeSpan.Zero;
});
services.Configure<HttpClientFactoryOptions>(VmwareOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
});
services.AddSourceCommon();
services.AddVmwareConnector(opts =>
{
opts.IndexUri = IndexUri;
opts.InitialBackfill = TimeSpan.FromDays(30);
opts.ModifiedTolerance = TimeSpan.FromMinutes(5);
opts.MaxAdvisoriesPerFetch = 10;
opts.RequestDelay = TimeSpan.Zero;
});
services.Configure<HttpClientFactoryOptions>(VmwareOptions.HttpClientName, builderOptions =>
{
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
});
return services.BuildServiceProvider();
}
private void SeedInitialResponses()
{
_handler.AddJsonResponse(IndexUri, ReadFixture("vmware-index-initial.json"));
_handler.AddJsonResponse(DetailOne, ReadFixture("vmware-detail-vmsa-2024-0001.json"));
_handler.AddJsonResponse(DetailTwo, ReadFixture("vmware-detail-vmsa-2024-0002.json"));
}
private void SeedUpdateResponses()
{
_handler.AddJsonResponse(IndexUri, ReadFixture("vmware-index-second.json"));
_handler.AddJsonResponse(DetailOne, ReadFixture("vmware-detail-vmsa-2024-0001.json"));
_handler.AddJsonResponse(DetailTwo, ReadFixture("vmware-detail-vmsa-2024-0002.json"));
_handler.AddJsonResponse(DetailThree, ReadFixture("vmware-detail-vmsa-2024-0003.json"));
}
private static string ReadFixture(string name)
{
var primary = Path.Combine(AppContext.BaseDirectory, "Vmware", "Fixtures", name);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var fallback = Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
if (File.Exists(fallback))
{
return File.ReadAllText(fallback);
}
throw new FileNotFoundException($"Fixture '{name}' not found.", name);
}
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
private static long Sum(IEnumerable<VmwareMetricCollector.MetricMeasurement> measurements, string name)
=> measurements.Where(m => m.Name == name).Sum(m => m.Value);
private sealed class VmwareMetricCollector : IDisposable
{
private readonly MeterListener _listener;
private readonly ConcurrentBag<MetricMeasurement> _measurements = new();
public VmwareMetricCollector()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == VmwareDiagnostics.MeterName)
{
listener.EnableMeasurementEvents(instrument);
}
}
};
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
var tagList = new List<KeyValuePair<string, object?>>(tags.Length);
foreach (var tag in tags)
{
tagList.Add(tag);
}
_measurements.Add(new MetricMeasurement(instrument.Name, measurement, tagList));
});
_listener.Start();
}
public IReadOnlyCollection<MetricMeasurement> Measurements => _measurements;
public void Dispose() => _listener.Dispose();
public sealed record MetricMeasurement(string Name, long Value, IReadOnlyList<KeyValuePair<string, object?>> Tags);
}
}
private void SeedInitialResponses()
{
_handler.AddJsonResponse(IndexUri, ReadFixture("vmware-index-initial.json"));
_handler.AddJsonResponse(DetailOne, ReadFixture("vmware-detail-vmsa-2024-0001.json"));
_handler.AddJsonResponse(DetailTwo, ReadFixture("vmware-detail-vmsa-2024-0002.json"));
}
private void SeedUpdateResponses()
{
_handler.AddJsonResponse(IndexUri, ReadFixture("vmware-index-second.json"));
_handler.AddJsonResponse(DetailOne, ReadFixture("vmware-detail-vmsa-2024-0001.json"));
_handler.AddJsonResponse(DetailTwo, ReadFixture("vmware-detail-vmsa-2024-0002.json"));
_handler.AddJsonResponse(DetailThree, ReadFixture("vmware-detail-vmsa-2024-0003.json"));
}
private static string ReadFixture(string name)
{
var primary = Path.Combine(AppContext.BaseDirectory, "Vmware", "Fixtures", name);
if (File.Exists(primary))
{
return File.ReadAllText(primary);
}
var fallback = Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
if (File.Exists(fallback))
{
return File.ReadAllText(fallback);
}
throw new FileNotFoundException($"Fixture '{name}' not found.", name);
}
private static string Normalize(string value)
=> value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
private static long Sum(IEnumerable<VmwareMetricCollector.MetricMeasurement> measurements, string name)
=> measurements.Where(m => m.Name == name).Sum(m => m.Value);
private sealed class VmwareMetricCollector : IDisposable
{
private readonly MeterListener _listener;
private readonly ConcurrentBag<MetricMeasurement> _measurements = new();
public VmwareMetricCollector()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == VmwareDiagnostics.MeterName)
{
listener.EnableMeasurementEvents(instrument);
}
}
};
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
var tagList = new List<KeyValuePair<string, object?>>(tags.Length);
foreach (var tag in tags)
{
tagList.Add(tag);
}
_measurements.Add(new MetricMeasurement(instrument.Name, measurement, tagList));
});
_listener.Start();
}
public IReadOnlyCollection<MetricMeasurement> Measurements => _measurements;
public void Dispose() => _listener.Dispose();
public sealed record MetricMeasurement(string Name, long Value, IReadOnlyList<KeyValuePair<string, object?>> Tags);
}
}

View File

@@ -1,86 +1,86 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Vndr.Vmware;
using StellaOps.Concelier.Connector.Vndr.Vmware.Internal;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using Xunit;
namespace StellaOps.Concelier.Connector.Vndr.Vmware.Tests;
public sealed class VmwareMapperTests
{
[Fact]
public void Map_CreatesCanonicalAdvisory()
{
var modified = DateTimeOffset.UtcNow;
var dto = new VmwareDetailDto
{
AdvisoryId = "VMSA-2025-0001",
Title = "Sample VMware Advisory",
Summary = "Summary text",
Published = modified.AddDays(-1),
Modified = modified,
CveIds = new[] { "CVE-2025-0001", "CVE-2025-0002" },
References = new[]
{
new VmwareReferenceDto { Url = "https://kb.vmware.com/some-kb", Type = "KB" },
new VmwareReferenceDto { Url = "https://vmsa.vmware.com/vmsa/KB", Type = "Advisory" },
},
Affected = new[]
{
new VmwareAffectedProductDto
{
Product = "VMware vCenter",
Version = "7.0",
FixedVersion = "7.0u3"
}
}
};
var document = new DocumentRecord(
Guid.NewGuid(),
VmwareConnectorPlugin.SourceName,
"https://vmsa.vmware.com/vmsa/VMSA-2025-0001",
DateTimeOffset.UtcNow,
"sha256",
DocumentStatuses.PendingParse,
"application/json",
null,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["vmware.id"] = dto.AdvisoryId,
},
null,
modified,
null,
null);
var payload = BsonDocument.Parse(JsonSerializer.Serialize(dto, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
}));
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, VmwareConnectorPlugin.SourceName, "vmware.v1", payload, DateTimeOffset.UtcNow);
var (advisory, flag) = VmwareMapper.Map(dto, document, dtoRecord);
Assert.Equal(dto.AdvisoryId, advisory.AdvisoryKey);
Assert.Contains("CVE-2025-0001", advisory.Aliases);
Assert.Contains("CVE-2025-0002", advisory.Aliases);
Assert.Single(advisory.AffectedPackages);
Assert.Equal("VMware vCenter", advisory.AffectedPackages[0].Identifier);
Assert.Single(advisory.AffectedPackages[0].VersionRanges);
Assert.Equal("7.0", advisory.AffectedPackages[0].VersionRanges[0].IntroducedVersion);
Assert.Equal("7.0u3", advisory.AffectedPackages[0].VersionRanges[0].FixedVersion);
Assert.Equal(2, advisory.References.Length);
Assert.Equal("https://kb.vmware.com/some-kb", advisory.References[0].Url);
Assert.Equal(dto.AdvisoryId, flag.AdvisoryKey);
Assert.Equal("VMware", flag.Vendor);
Assert.Equal(VmwareConnectorPlugin.SourceName, flag.SourceName);
}
}
using System;
using System.Collections.Generic;
using System.Text.Json;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Vndr.Vmware;
using StellaOps.Concelier.Connector.Vndr.Vmware.Internal;
using StellaOps.Concelier.Storage;
using StellaOps.Concelier.Storage;
using Xunit;
namespace StellaOps.Concelier.Connector.Vndr.Vmware.Tests;
public sealed class VmwareMapperTests
{
[Fact]
public void Map_CreatesCanonicalAdvisory()
{
var modified = DateTimeOffset.UtcNow;
var dto = new VmwareDetailDto
{
AdvisoryId = "VMSA-2025-0001",
Title = "Sample VMware Advisory",
Summary = "Summary text",
Published = modified.AddDays(-1),
Modified = modified,
CveIds = new[] { "CVE-2025-0001", "CVE-2025-0002" },
References = new[]
{
new VmwareReferenceDto { Url = "https://kb.vmware.com/some-kb", Type = "KB" },
new VmwareReferenceDto { Url = "https://vmsa.vmware.com/vmsa/KB", Type = "Advisory" },
},
Affected = new[]
{
new VmwareAffectedProductDto
{
Product = "VMware vCenter",
Version = "7.0",
FixedVersion = "7.0u3"
}
}
};
var document = new DocumentRecord(
Guid.NewGuid(),
VmwareConnectorPlugin.SourceName,
"https://vmsa.vmware.com/vmsa/VMSA-2025-0001",
DateTimeOffset.UtcNow,
"sha256",
DocumentStatuses.PendingParse,
"application/json",
null,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["vmware.id"] = dto.AdvisoryId,
},
null,
modified,
null,
null);
var payload = DocumentObject.Parse(JsonSerializer.Serialize(dto, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
}));
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, VmwareConnectorPlugin.SourceName, "vmware.v1", payload, DateTimeOffset.UtcNow);
var (advisory, flag) = VmwareMapper.Map(dto, document, dtoRecord);
Assert.Equal(dto.AdvisoryId, advisory.AdvisoryKey);
Assert.Contains("CVE-2025-0001", advisory.Aliases);
Assert.Contains("CVE-2025-0002", advisory.Aliases);
Assert.Single(advisory.AffectedPackages);
Assert.Equal("VMware vCenter", advisory.AffectedPackages[0].Identifier);
Assert.Single(advisory.AffectedPackages[0].VersionRanges);
Assert.Equal("7.0", advisory.AffectedPackages[0].VersionRanges[0].IntroducedVersion);
Assert.Equal("7.0u3", advisory.AffectedPackages[0].VersionRanges[0].FixedVersion);
Assert.Equal(2, advisory.References.Length);
Assert.Equal("https://kb.vmware.com/some-kb", advisory.References[0].Url);
Assert.Equal(dto.AdvisoryId, flag.AdvisoryKey);
Assert.Equal("VMware", flag.Vendor);
Assert.Equal(VmwareConnectorPlugin.SourceName, flag.SourceName);
}
}

View File

@@ -1,4 +1,4 @@
using System.Collections.Immutable;
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
@@ -15,30 +15,30 @@ public sealed class AdvisoryRawWriteGuardTests
string tenant = "tenant-a",
bool signaturePresent = false,
bool includeSignaturePayload = true)
{
using var rawDocument = JsonDocument.Parse("""{"id":"demo"}""");
var signature = signaturePresent
? new RawSignatureMetadata(
Present: true,
Format: "dsse",
KeyId: "key-1",
Signature: includeSignaturePayload ? "base64signature" : null)
: new RawSignatureMetadata(false);
{
using var rawDocument = JsonDocument.Parse("""{"id":"demo"}""");
var signature = signaturePresent
? new RawSignatureMetadata(
Present: true,
Format: "dsse",
KeyId: "key-1",
Signature: includeSignaturePayload ? "base64signature" : null)
: new RawSignatureMetadata(false);
return new AdvisoryRawDocument(
Tenant: tenant,
Source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: "GHSA-xxxx",
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:abc",
Signature: signature,
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "OSV",
SpecVersion: "1.0",
Raw: rawDocument.RootElement.Clone()),
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:abc",
Signature: signature,
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "OSV",
SpecVersion: "1.0",
Raw: rawDocument.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create("GHSA-xxxx"),
PrimaryId: "GHSA-xxxx"),
@@ -65,7 +65,7 @@ public sealed class AdvisoryRawWriteGuardTests
guard.EnsureValid(document);
}
[Fact]
public void EnsureValid_ThrowsWhenTenantMissing()
{
@@ -73,10 +73,10 @@ public sealed class AdvisoryRawWriteGuardTests
var document = CreateDocument(tenant: string.Empty);
var exception = Assert.Throws<ConcelierAocGuardException>(() => guard.EnsureValid(document));
Assert.Equal("ERR_AOC_004", exception.PrimaryErrorCode);
Assert.Contains(exception.Violations, violation => violation.ErrorCode == "ERR_AOC_004" && violation.Path == "/tenant");
}
Assert.Equal("ERR_AOC_004", exception.PrimaryErrorCode);
Assert.Contains(exception.Violations, violation => violation.ErrorCode == "ERR_AOC_004" && violation.Path == "/tenant");
}
[Fact]
public void EnsureValid_ThrowsWhenSignaturePayloadMissing()
{
@@ -84,7 +84,7 @@ public sealed class AdvisoryRawWriteGuardTests
var document = CreateDocument(signaturePresent: true, includeSignaturePayload: false);
var exception = Assert.Throws<ConcelierAocGuardException>(() => guard.EnsureValid(document));
Assert.Equal("ERR_AOC_005", exception.PrimaryErrorCode);
Assert.Contains(exception.Violations, violation => violation.ErrorCode == "ERR_AOC_005");
}
}
Assert.Equal("ERR_AOC_005", exception.PrimaryErrorCode);
Assert.Contains(exception.Violations, violation => violation.ErrorCode == "ERR_AOC_005");
}
}

View File

@@ -1,371 +1,371 @@
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Core.Tests;
public sealed class CanonicalMergerTests
{
private static readonly DateTimeOffset BaseTimestamp = new(2025, 10, 10, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void Merge_PrefersGhsaTitleAndSummaryByPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-aaaa-bbbb-cccc",
title: "GHSA Title",
summary: "GHSA Summary",
modified: BaseTimestamp.AddHours(1));
var nvd = CreateAdvisory(
source: "nvd",
advisoryKey: "CVE-2025-0001",
title: "NVD Title",
summary: "NVD Summary",
modified: BaseTimestamp);
var result = merger.Merge("CVE-2025-0001", ghsa, nvd, null);
Assert.Equal("GHSA Title", result.Advisory.Title);
Assert.Equal("GHSA Summary", result.Advisory.Summary);
Assert.Contains(result.Decisions, decision =>
decision.Field == "summary" &&
string.Equals(decision.SelectedSource, "ghsa", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Advisory.Provenance, provenance =>
string.Equals(provenance.Source, "ghsa", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Value, "summary", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_FreshnessOverrideUsesOsvSummaryWhenNewerByThreshold()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(10)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-xxxx-yyyy-zzzz",
title: "Container Escape Vulnerability",
summary: "Initial GHSA summary.",
modified: BaseTimestamp);
var osv = CreateAdvisory(
source: "osv",
advisoryKey: "GHSA-xxxx-yyyy-zzzz",
title: "Container Escape Vulnerability",
summary: "OSV summary with additional mitigation steps.",
modified: BaseTimestamp.AddHours(72));
var result = merger.Merge("CVE-2025-9000", ghsa, null, osv);
Assert.Equal("OSV summary with additional mitigation steps.", result.Advisory.Summary);
Assert.Contains(result.Decisions, decision =>
decision.Field == "summary" &&
string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Advisory.Provenance, provenance =>
string.Equals(provenance.Source, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Value, "summary", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_AffectedPackagesPreferOsvPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(4)));
var ghsaPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: null,
fixedVersion: "1.2.3",
lastAffectedVersion: null,
rangeExpression: "<1.2.3",
provenance: CreateProvenance("ghsa", ProvenanceFieldMasks.VersionRanges),
primitives: null)
},
statuses: new[]
{
new AffectedPackageStatus(
"affected",
CreateProvenance("ghsa", ProvenanceFieldMasks.PackageStatuses))
},
provenance: new[] { CreateProvenance("ghsa", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var nvdPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: null,
fixedVersion: "1.2.4",
lastAffectedVersion: null,
rangeExpression: "<1.2.4",
provenance: CreateProvenance("nvd", ProvenanceFieldMasks.VersionRanges),
primitives: null)
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { CreateProvenance("nvd", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var osvPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: "1.0.0",
fixedVersion: "1.2.5",
lastAffectedVersion: null,
rangeExpression: ">=1.0.0,<1.2.5",
provenance: CreateProvenance("osv", ProvenanceFieldMasks.VersionRanges),
primitives: null)
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { CreateProvenance("osv", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var ghsa = CreateAdvisory("ghsa", "GHSA-1234", "GHSA Title", modified: BaseTimestamp.AddHours(1), packages: new[] { ghsaPackage });
var nvd = CreateAdvisory("nvd", "CVE-2025-1111", "NVD Title", modified: BaseTimestamp.AddHours(2), packages: new[] { nvdPackage });
var osv = CreateAdvisory("osv", "OSV-2025-xyz", "OSV Title", modified: BaseTimestamp.AddHours(3), packages: new[] { osvPackage });
var result = merger.Merge("CVE-2025-1111", ghsa, nvd, osv);
var package = Assert.Single(result.Advisory.AffectedPackages);
Assert.Equal("pkg:npm/example@1", package.Identifier);
Assert.Contains(package.Provenance, provenance =>
string.Equals(provenance.Source, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Decisions, decision =>
decision.Field.StartsWith("affectedPackages", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_CvssMetricsOrderedByPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(5)));
var nvdMetric = new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 9.8, "critical", CreateProvenance("nvd", ProvenanceFieldMasks.CvssMetrics));
var ghsaMetric = new CvssMetric("3.0", "CVSS:3.0/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H", 7.5, "high", CreateProvenance("ghsa", ProvenanceFieldMasks.CvssMetrics));
var nvd = CreateAdvisory("nvd", "CVE-2025-2000", "NVD Title", severity: null, modified: BaseTimestamp, metrics: new[] { nvdMetric });
var ghsa = CreateAdvisory("ghsa", "GHSA-9999", "GHSA Title", severity: null, modified: BaseTimestamp.AddHours(1), metrics: new[] { ghsaMetric });
var result = merger.Merge("CVE-2025-2000", ghsa, nvd, null);
Assert.Equal(2, result.Advisory.CvssMetrics.Length);
Assert.Equal("nvd", result.Decisions.Single(decision => decision.Field == "cvssMetrics").SelectedSource);
Assert.Equal("critical", result.Advisory.Severity);
Assert.Contains(result.Advisory.CvssMetrics, metric => metric.Vector == "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H");
Assert.Contains(result.Advisory.CvssMetrics, metric => metric.Vector == "CVSS:3.0/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H");
Assert.Equal("3.1|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", result.Advisory.CanonicalMetricId);
}
[Fact]
public void Merge_ReferencesNormalizedAndFreshnessOverrides()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(80)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-ref",
title: "GHSA Title",
references: new[]
{
new AdvisoryReference(
"http://Example.COM/path/resource?b=2&a=1#section",
kind: "advisory",
sourceTag: null,
summary: null,
CreateProvenance("ghsa", ProvenanceFieldMasks.References))
},
modified: BaseTimestamp);
var osv = CreateAdvisory(
source: "osv",
advisoryKey: "OSV-ref",
title: "OSV Title",
references: new[]
{
new AdvisoryReference(
"https://example.com/path/resource?a=1&b=2",
kind: "advisory",
sourceTag: null,
summary: null,
CreateProvenance("osv", ProvenanceFieldMasks.References))
},
modified: BaseTimestamp.AddHours(80));
var result = merger.Merge("CVE-REF-2025-01", ghsa, null, osv);
var reference = Assert.Single(result.Advisory.References);
Assert.Equal("https://example.com/path/resource?a=1&b=2", reference.Url);
var unionDecision = Assert.Single(result.Decisions.Where(decision => decision.Field == "references"));
Assert.Null(unionDecision.SelectedSource);
Assert.Equal("union", unionDecision.DecisionReason);
var itemDecision = Assert.Single(result.Decisions.Where(decision => decision.Field.StartsWith("references[", StringComparison.OrdinalIgnoreCase)));
Assert.Equal("osv", itemDecision.SelectedSource);
Assert.Equal("freshness_override", itemDecision.DecisionReason);
Assert.Contains("https://example.com/path/resource?a=1&b=2", itemDecision.Field, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Merge_DescriptionFreshnessOverride()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(12)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-desc",
title: "GHSA Title",
summary: "Summary",
description: "Initial GHSA description",
modified: BaseTimestamp.AddHours(1));
var nvd = CreateAdvisory(
source: "nvd",
advisoryKey: "CVE-2025-5555",
title: "NVD Title",
summary: "Summary",
description: "NVD baseline description",
modified: BaseTimestamp.AddHours(2));
var osv = CreateAdvisory(
source: "osv",
advisoryKey: "OSV-2025-desc",
title: "OSV Title",
summary: "Summary",
description: "OSV fresher description",
modified: BaseTimestamp.AddHours(72));
var result = merger.Merge("CVE-2025-5555", ghsa, nvd, osv);
Assert.Equal("OSV fresher description", result.Advisory.Description);
Assert.Contains(result.Decisions, decision =>
decision.Field == "description" &&
string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_CwesPreferNvdPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));
var ghsaWeakness = CreateWeakness("ghsa", "CWE-79", "cross-site scripting", BaseTimestamp.AddHours(1));
var nvdWeakness = CreateWeakness("nvd", "CWE-79", "Cross-Site Scripting", BaseTimestamp.AddHours(2));
var osvWeakness = CreateWeakness("osv", "CWE-79", "XSS", BaseTimestamp.AddHours(3));
var ghsa = CreateAdvisory("ghsa", "GHSA-weakness", "GHSA Title", weaknesses: new[] { ghsaWeakness }, modified: BaseTimestamp.AddHours(1));
var nvd = CreateAdvisory("nvd", "CVE-2025-7777", "NVD Title", weaknesses: new[] { nvdWeakness }, modified: BaseTimestamp.AddHours(2));
var osv = CreateAdvisory("osv", "OSV-weakness", "OSV Title", weaknesses: new[] { osvWeakness }, modified: BaseTimestamp.AddHours(3));
var result = merger.Merge("CVE-2025-7777", ghsa, nvd, osv);
var weakness = Assert.Single(result.Advisory.Cwes);
Assert.Equal("CWE-79", weakness.Identifier);
Assert.Equal("Cross-Site Scripting", weakness.Name);
Assert.Contains(result.Decisions, decision =>
decision.Field == "cwes[cwe|CWE-79]" &&
string.Equals(decision.SelectedSource, "nvd", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
}
private static Advisory CreateAdvisory(
string source,
string advisoryKey,
string title,
string? summary = null,
string? description = null,
DateTimeOffset? modified = null,
string? severity = null,
IEnumerable<AffectedPackage>? packages = null,
IEnumerable<CvssMetric>? metrics = null,
IEnumerable<AdvisoryReference>? references = null,
IEnumerable<AdvisoryWeakness>? weaknesses = null,
string? canonicalMetricId = null)
{
var provenance = new AdvisoryProvenance(
source,
kind: "map",
value: advisoryKey,
recordedAt: modified ?? BaseTimestamp,
fieldMask: new[] { ProvenanceFieldMasks.Advisory });
return new Advisory(
advisoryKey,
title,
summary,
language: "en",
published: modified,
modified: modified,
severity: severity,
exploitKnown: false,
aliases: new[] { advisoryKey },
credits: Array.Empty<AdvisoryCredit>(),
references: references ?? Array.Empty<AdvisoryReference>(),
affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
cvssMetrics: metrics ?? Array.Empty<CvssMetric>(),
provenance: new[] { provenance },
description: description,
cwes: weaknesses ?? Array.Empty<AdvisoryWeakness>(),
canonicalMetricId: canonicalMetricId);
}
private static AdvisoryProvenance CreateProvenance(string source, string fieldMask)
=> new(
source,
kind: "map",
value: source,
recordedAt: BaseTimestamp,
fieldMask: new[] { fieldMask });
private static AdvisoryWeakness CreateWeakness(string source, string identifier, string? name, DateTimeOffset recordedAt)
{
var provenance = new AdvisoryProvenance(
source,
kind: "map",
value: identifier,
recordedAt: recordedAt,
fieldMask: new[] { ProvenanceFieldMasks.Weaknesses });
return new AdvisoryWeakness("cwe", identifier, name, uri: null, provenance: new[] { provenance });
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Core.Tests;
public sealed class CanonicalMergerTests
{
private static readonly DateTimeOffset BaseTimestamp = new(2025, 10, 10, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void Merge_PrefersGhsaTitleAndSummaryByPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-aaaa-bbbb-cccc",
title: "GHSA Title",
summary: "GHSA Summary",
modified: BaseTimestamp.AddHours(1));
var nvd = CreateAdvisory(
source: "nvd",
advisoryKey: "CVE-2025-0001",
title: "NVD Title",
summary: "NVD Summary",
modified: BaseTimestamp);
var result = merger.Merge("CVE-2025-0001", ghsa, nvd, null);
Assert.Equal("GHSA Title", result.Advisory.Title);
Assert.Equal("GHSA Summary", result.Advisory.Summary);
Assert.Contains(result.Decisions, decision =>
decision.Field == "summary" &&
string.Equals(decision.SelectedSource, "ghsa", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Advisory.Provenance, provenance =>
string.Equals(provenance.Source, "ghsa", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Value, "summary", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_FreshnessOverrideUsesOsvSummaryWhenNewerByThreshold()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(10)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-xxxx-yyyy-zzzz",
title: "Container Escape Vulnerability",
summary: "Initial GHSA summary.",
modified: BaseTimestamp);
var osv = CreateAdvisory(
source: "osv",
advisoryKey: "GHSA-xxxx-yyyy-zzzz",
title: "Container Escape Vulnerability",
summary: "OSV summary with additional mitigation steps.",
modified: BaseTimestamp.AddHours(72));
var result = merger.Merge("CVE-2025-9000", ghsa, null, osv);
Assert.Equal("OSV summary with additional mitigation steps.", result.Advisory.Summary);
Assert.Contains(result.Decisions, decision =>
decision.Field == "summary" &&
string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Advisory.Provenance, provenance =>
string.Equals(provenance.Source, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Value, "summary", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_AffectedPackagesPreferOsvPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(4)));
var ghsaPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: null,
fixedVersion: "1.2.3",
lastAffectedVersion: null,
rangeExpression: "<1.2.3",
provenance: CreateProvenance("ghsa", ProvenanceFieldMasks.VersionRanges),
primitives: null)
},
statuses: new[]
{
new AffectedPackageStatus(
"affected",
CreateProvenance("ghsa", ProvenanceFieldMasks.PackageStatuses))
},
provenance: new[] { CreateProvenance("ghsa", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var nvdPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: null,
fixedVersion: "1.2.4",
lastAffectedVersion: null,
rangeExpression: "<1.2.4",
provenance: CreateProvenance("nvd", ProvenanceFieldMasks.VersionRanges),
primitives: null)
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { CreateProvenance("nvd", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var osvPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/example@1",
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: "1.0.0",
fixedVersion: "1.2.5",
lastAffectedVersion: null,
rangeExpression: ">=1.0.0,<1.2.5",
provenance: CreateProvenance("osv", ProvenanceFieldMasks.VersionRanges),
primitives: null)
},
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { CreateProvenance("osv", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var ghsa = CreateAdvisory("ghsa", "GHSA-1234", "GHSA Title", modified: BaseTimestamp.AddHours(1), packages: new[] { ghsaPackage });
var nvd = CreateAdvisory("nvd", "CVE-2025-1111", "NVD Title", modified: BaseTimestamp.AddHours(2), packages: new[] { nvdPackage });
var osv = CreateAdvisory("osv", "OSV-2025-xyz", "OSV Title", modified: BaseTimestamp.AddHours(3), packages: new[] { osvPackage });
var result = merger.Merge("CVE-2025-1111", ghsa, nvd, osv);
var package = Assert.Single(result.Advisory.AffectedPackages);
Assert.Equal("pkg:npm/example@1", package.Identifier);
Assert.Contains(package.Provenance, provenance =>
string.Equals(provenance.Source, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
string.Equals(provenance.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result.Decisions, decision =>
decision.Field.StartsWith("affectedPackages", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_CvssMetricsOrderedByPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(5)));
var nvdMetric = new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 9.8, "critical", CreateProvenance("nvd", ProvenanceFieldMasks.CvssMetrics));
var ghsaMetric = new CvssMetric("3.0", "CVSS:3.0/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H", 7.5, "high", CreateProvenance("ghsa", ProvenanceFieldMasks.CvssMetrics));
var nvd = CreateAdvisory("nvd", "CVE-2025-2000", "NVD Title", severity: null, modified: BaseTimestamp, metrics: new[] { nvdMetric });
var ghsa = CreateAdvisory("ghsa", "GHSA-9999", "GHSA Title", severity: null, modified: BaseTimestamp.AddHours(1), metrics: new[] { ghsaMetric });
var result = merger.Merge("CVE-2025-2000", ghsa, nvd, null);
Assert.Equal(2, result.Advisory.CvssMetrics.Length);
Assert.Equal("nvd", result.Decisions.Single(decision => decision.Field == "cvssMetrics").SelectedSource);
Assert.Equal("critical", result.Advisory.Severity);
Assert.Contains(result.Advisory.CvssMetrics, metric => metric.Vector == "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H");
Assert.Contains(result.Advisory.CvssMetrics, metric => metric.Vector == "CVSS:3.0/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H");
Assert.Equal("3.1|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", result.Advisory.CanonicalMetricId);
}
[Fact]
public void Merge_ReferencesNormalizedAndFreshnessOverrides()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(80)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-ref",
title: "GHSA Title",
references: new[]
{
new AdvisoryReference(
"http://Example.COM/path/resource?b=2&a=1#section",
kind: "advisory",
sourceTag: null,
summary: null,
CreateProvenance("ghsa", ProvenanceFieldMasks.References))
},
modified: BaseTimestamp);
var osv = CreateAdvisory(
source: "osv",
advisoryKey: "OSV-ref",
title: "OSV Title",
references: new[]
{
new AdvisoryReference(
"https://example.com/path/resource?a=1&b=2",
kind: "advisory",
sourceTag: null,
summary: null,
CreateProvenance("osv", ProvenanceFieldMasks.References))
},
modified: BaseTimestamp.AddHours(80));
var result = merger.Merge("CVE-REF-2025-01", ghsa, null, osv);
var reference = Assert.Single(result.Advisory.References);
Assert.Equal("https://example.com/path/resource?a=1&b=2", reference.Url);
var unionDecision = Assert.Single(result.Decisions.Where(decision => decision.Field == "references"));
Assert.Null(unionDecision.SelectedSource);
Assert.Equal("union", unionDecision.DecisionReason);
var itemDecision = Assert.Single(result.Decisions.Where(decision => decision.Field.StartsWith("references[", StringComparison.OrdinalIgnoreCase)));
Assert.Equal("osv", itemDecision.SelectedSource);
Assert.Equal("freshness_override", itemDecision.DecisionReason);
Assert.Contains("https://example.com/path/resource?a=1&b=2", itemDecision.Field, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Merge_DescriptionFreshnessOverride()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(12)));
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-desc",
title: "GHSA Title",
summary: "Summary",
description: "Initial GHSA description",
modified: BaseTimestamp.AddHours(1));
var nvd = CreateAdvisory(
source: "nvd",
advisoryKey: "CVE-2025-5555",
title: "NVD Title",
summary: "Summary",
description: "NVD baseline description",
modified: BaseTimestamp.AddHours(2));
var osv = CreateAdvisory(
source: "osv",
advisoryKey: "OSV-2025-desc",
title: "OSV Title",
summary: "Summary",
description: "OSV fresher description",
modified: BaseTimestamp.AddHours(72));
var result = merger.Merge("CVE-2025-5555", ghsa, nvd, osv);
Assert.Equal("OSV fresher description", result.Advisory.Description);
Assert.Contains(result.Decisions, decision =>
decision.Field == "description" &&
string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Merge_CwesPreferNvdPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));
var ghsaWeakness = CreateWeakness("ghsa", "CWE-79", "cross-site scripting", BaseTimestamp.AddHours(1));
var nvdWeakness = CreateWeakness("nvd", "CWE-79", "Cross-Site Scripting", BaseTimestamp.AddHours(2));
var osvWeakness = CreateWeakness("osv", "CWE-79", "XSS", BaseTimestamp.AddHours(3));
var ghsa = CreateAdvisory("ghsa", "GHSA-weakness", "GHSA Title", weaknesses: new[] { ghsaWeakness }, modified: BaseTimestamp.AddHours(1));
var nvd = CreateAdvisory("nvd", "CVE-2025-7777", "NVD Title", weaknesses: new[] { nvdWeakness }, modified: BaseTimestamp.AddHours(2));
var osv = CreateAdvisory("osv", "OSV-weakness", "OSV Title", weaknesses: new[] { osvWeakness }, modified: BaseTimestamp.AddHours(3));
var result = merger.Merge("CVE-2025-7777", ghsa, nvd, osv);
var weakness = Assert.Single(result.Advisory.Cwes);
Assert.Equal("CWE-79", weakness.Identifier);
Assert.Equal("Cross-Site Scripting", weakness.Name);
Assert.Contains(result.Decisions, decision =>
decision.Field == "cwes[cwe|CWE-79]" &&
string.Equals(decision.SelectedSource, "nvd", StringComparison.OrdinalIgnoreCase) &&
string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
}
private static Advisory CreateAdvisory(
string source,
string advisoryKey,
string title,
string? summary = null,
string? description = null,
DateTimeOffset? modified = null,
string? severity = null,
IEnumerable<AffectedPackage>? packages = null,
IEnumerable<CvssMetric>? metrics = null,
IEnumerable<AdvisoryReference>? references = null,
IEnumerable<AdvisoryWeakness>? weaknesses = null,
string? canonicalMetricId = null)
{
var provenance = new AdvisoryProvenance(
source,
kind: "map",
value: advisoryKey,
recordedAt: modified ?? BaseTimestamp,
fieldMask: new[] { ProvenanceFieldMasks.Advisory });
return new Advisory(
advisoryKey,
title,
summary,
language: "en",
published: modified,
modified: modified,
severity: severity,
exploitKnown: false,
aliases: new[] { advisoryKey },
credits: Array.Empty<AdvisoryCredit>(),
references: references ?? Array.Empty<AdvisoryReference>(),
affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
cvssMetrics: metrics ?? Array.Empty<CvssMetric>(),
provenance: new[] { provenance },
description: description,
cwes: weaknesses ?? Array.Empty<AdvisoryWeakness>(),
canonicalMetricId: canonicalMetricId);
}
private static AdvisoryProvenance CreateProvenance(string source, string fieldMask)
=> new(
source,
kind: "map",
value: source,
recordedAt: BaseTimestamp,
fieldMask: new[] { fieldMask });
private static AdvisoryWeakness CreateWeakness(string source, string identifier, string? name, DateTimeOffset recordedAt)
{
var provenance = new AdvisoryProvenance(
source,
kind: "map",
value: identifier,
recordedAt: recordedAt,
fieldMask: new[] { ProvenanceFieldMasks.Weaknesses });
return new AdvisoryWeakness("cwe", identifier, name, uri: null, provenance: new[] { provenance });
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow;
public FixedTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}

View File

@@ -1,54 +1,54 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Models;
using StellaOps.Provenance.Mongo;
using StellaOps.Provenance;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Events;
public sealed class AdvisoryEventLogTests
{
[Fact]
namespace StellaOps.Concelier.Core.Tests.Events;
public sealed class AdvisoryEventLogTests
{
[Fact]
public async Task AppendAsync_PersistsCanonicalStatementEntries()
{
var repository = new FakeRepository();
var timeProvider = new FixedTimeProvider(DateTimeOffset.UtcNow);
var log = new AdvisoryEventLog(repository, timeProvider);
var advisory = new Advisory(
"adv-1",
"Test Advisory",
summary: "Summary",
language: "en",
published: DateTimeOffset.Parse("2025-10-01T00:00:00Z"),
modified: DateTimeOffset.Parse("2025-10-02T00:00:00Z"),
severity: "high",
exploitKnown: true,
aliases: new[] { "CVE-2025-0001" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: Array.Empty<AdvisoryProvenance>());
var statementInput = new AdvisoryStatementInput(
VulnerabilityKey: "CVE-2025-0001",
Advisory: advisory,
AsOf: DateTimeOffset.Parse("2025-10-03T00:00:00Z"),
InputDocumentIds: new[] { Guid.Parse("11111111-1111-1111-1111-111111111111") });
await log.AppendAsync(new AdvisoryEventAppendRequest(new[] { statementInput }), CancellationToken.None);
Assert.Single(repository.InsertedStatements);
var entry = repository.InsertedStatements.Single();
Assert.Equal("cve-2025-0001", entry.VulnerabilityKey);
Assert.Equal("adv-1", entry.AdvisoryKey);
Assert.Equal(DateTimeOffset.Parse("2025-10-03T00:00:00Z"), entry.AsOf);
var advisory = new Advisory(
"adv-1",
"Test Advisory",
summary: "Summary",
language: "en",
published: DateTimeOffset.Parse("2025-10-01T00:00:00Z"),
modified: DateTimeOffset.Parse("2025-10-02T00:00:00Z"),
severity: "high",
exploitKnown: true,
aliases: new[] { "CVE-2025-0001" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: Array.Empty<AdvisoryProvenance>());
var statementInput = new AdvisoryStatementInput(
VulnerabilityKey: "CVE-2025-0001",
Advisory: advisory,
AsOf: DateTimeOffset.Parse("2025-10-03T00:00:00Z"),
InputDocumentIds: new[] { Guid.Parse("11111111-1111-1111-1111-111111111111") });
await log.AppendAsync(new AdvisoryEventAppendRequest(new[] { statementInput }), CancellationToken.None);
Assert.Single(repository.InsertedStatements);
var entry = repository.InsertedStatements.Single();
Assert.Equal("cve-2025-0001", entry.VulnerabilityKey);
Assert.Equal("adv-1", entry.AdvisoryKey);
Assert.Equal(DateTimeOffset.Parse("2025-10-03T00:00:00Z"), entry.AsOf);
Assert.Contains("\"advisoryKey\":\"adv-1\"", entry.CanonicalJson);
Assert.NotEqual(ImmutableArray<byte>.Empty, entry.StatementHash);
}
@@ -97,28 +97,28 @@ public sealed class AdvisoryEventLogTests
Assert.True(entry.Trust!.Verified);
Assert.Equal("Authority@stella", entry.Trust.Verifier);
}
[Fact]
[Fact]
public async Task AppendAsync_PersistsConflictsWithCanonicalizedJson()
{
var repository = new FakeRepository();
var timeProvider = new FixedTimeProvider(DateTimeOffset.Parse("2025-10-19T12:00:00Z"));
var log = new AdvisoryEventLog(repository, timeProvider);
using var conflictJson = JsonDocument.Parse("{\"reason\":\"tie\",\"details\":{\"b\":2,\"a\":1}}");
var conflictInput = new AdvisoryConflictInput(
VulnerabilityKey: "CVE-2025-0001",
Details: conflictJson,
AsOf: DateTimeOffset.Parse("2025-10-04T00:00:00Z"),
StatementIds: new[] { Guid.Parse("22222222-2222-2222-2222-222222222222") });
await log.AppendAsync(new AdvisoryEventAppendRequest(Array.Empty<AdvisoryStatementInput>(), new[] { conflictInput }), CancellationToken.None);
Assert.Single(repository.InsertedConflicts);
var entry = repository.InsertedConflicts.Single();
Assert.Equal("cve-2025-0001", entry.VulnerabilityKey);
Assert.Equal("{\"details\":{\"a\":1,\"b\":2},\"reason\":\"tie\"}", entry.CanonicalJson);
Assert.NotEqual(ImmutableArray<byte>.Empty, entry.ConflictHash);
using var conflictJson = JsonDocument.Parse("{\"reason\":\"tie\",\"details\":{\"b\":2,\"a\":1}}");
var conflictInput = new AdvisoryConflictInput(
VulnerabilityKey: "CVE-2025-0001",
Details: conflictJson,
AsOf: DateTimeOffset.Parse("2025-10-04T00:00:00Z"),
StatementIds: new[] { Guid.Parse("22222222-2222-2222-2222-222222222222") });
await log.AppendAsync(new AdvisoryEventAppendRequest(Array.Empty<AdvisoryStatementInput>(), new[] { conflictInput }), CancellationToken.None);
Assert.Single(repository.InsertedConflicts);
var entry = repository.InsertedConflicts.Single();
Assert.Equal("cve-2025-0001", entry.VulnerabilityKey);
Assert.Equal("{\"details\":{\"a\":1,\"b\":2},\"reason\":\"tie\"}", entry.CanonicalJson);
Assert.NotEqual(ImmutableArray<byte>.Empty, entry.ConflictHash);
Assert.Equal(DateTimeOffset.Parse("2025-10-04T00:00:00Z"), entry.AsOf);
}
@@ -165,104 +165,104 @@ public sealed class AdvisoryEventLogTests
public async Task ReplayAsync_ReturnsSortedSnapshots()
{
var repository = new FakeRepository();
var timeProvider = new FixedTimeProvider(DateTimeOffset.Parse("2025-10-05T00:00:00Z"));
var log = new AdvisoryEventLog(repository, timeProvider);
repository.StoredStatements.AddRange(new[]
{
new AdvisoryStatementEntry(
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
"cve-2025-0001",
"adv-2",
CanonicalJsonSerializer.Serialize(new Advisory(
"adv-2",
"B title",
null,
null,
null,
DateTimeOffset.Parse("2025-10-02T00:00:00Z"),
null,
false,
Array.Empty<string>(),
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
Array.Empty<AdvisoryProvenance>())),
ImmutableArray.Create(new byte[] { 0x01, 0x02 }),
DateTimeOffset.Parse("2025-10-04T00:00:00Z"),
DateTimeOffset.Parse("2025-10-04T01:00:00Z"),
ImmutableArray<Guid>.Empty),
new AdvisoryStatementEntry(
Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
"cve-2025-0001",
"adv-1",
CanonicalJsonSerializer.Serialize(new Advisory(
"adv-1",
"A title",
null,
null,
null,
DateTimeOffset.Parse("2025-10-01T00:00:00Z"),
null,
false,
Array.Empty<string>(),
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
Array.Empty<AdvisoryProvenance>())),
ImmutableArray.Create(new byte[] { 0x03, 0x04 }),
DateTimeOffset.Parse("2025-10-03T00:00:00Z"),
DateTimeOffset.Parse("2025-10-04T02:00:00Z"),
ImmutableArray<Guid>.Empty),
});
repository.StoredConflicts.Add(new AdvisoryConflictEntry(
Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
"cve-2025-0001",
CanonicalJson: "{\"reason\":\"conflict\"}",
ConflictHash: ImmutableArray.Create(new byte[] { 0x10 }),
AsOf: DateTimeOffset.Parse("2025-10-04T00:00:00Z"),
RecordedAt: DateTimeOffset.Parse("2025-10-04T03:00:00Z"),
StatementIds: ImmutableArray<Guid>.Empty));
var replay = await log.ReplayAsync("CVE-2025-0001", asOf: null, CancellationToken.None);
Assert.Equal("cve-2025-0001", replay.VulnerabilityKey);
Assert.Collection(
replay.Statements,
first => Assert.Equal("adv-2", first.AdvisoryKey),
second => Assert.Equal("adv-1", second.AdvisoryKey));
Assert.Single(replay.Conflicts);
Assert.Equal("{\"reason\":\"conflict\"}", replay.Conflicts[0].CanonicalJson);
}
var timeProvider = new FixedTimeProvider(DateTimeOffset.Parse("2025-10-05T00:00:00Z"));
var log = new AdvisoryEventLog(repository, timeProvider);
repository.StoredStatements.AddRange(new[]
{
new AdvisoryStatementEntry(
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
"cve-2025-0001",
"adv-2",
CanonicalJsonSerializer.Serialize(new Advisory(
"adv-2",
"B title",
null,
null,
null,
DateTimeOffset.Parse("2025-10-02T00:00:00Z"),
null,
false,
Array.Empty<string>(),
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
Array.Empty<AdvisoryProvenance>())),
ImmutableArray.Create(new byte[] { 0x01, 0x02 }),
DateTimeOffset.Parse("2025-10-04T00:00:00Z"),
DateTimeOffset.Parse("2025-10-04T01:00:00Z"),
ImmutableArray<Guid>.Empty),
new AdvisoryStatementEntry(
Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
"cve-2025-0001",
"adv-1",
CanonicalJsonSerializer.Serialize(new Advisory(
"adv-1",
"A title",
null,
null,
null,
DateTimeOffset.Parse("2025-10-01T00:00:00Z"),
null,
false,
Array.Empty<string>(),
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
Array.Empty<AdvisoryProvenance>())),
ImmutableArray.Create(new byte[] { 0x03, 0x04 }),
DateTimeOffset.Parse("2025-10-03T00:00:00Z"),
DateTimeOffset.Parse("2025-10-04T02:00:00Z"),
ImmutableArray<Guid>.Empty),
});
repository.StoredConflicts.Add(new AdvisoryConflictEntry(
Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
"cve-2025-0001",
CanonicalJson: "{\"reason\":\"conflict\"}",
ConflictHash: ImmutableArray.Create(new byte[] { 0x10 }),
AsOf: DateTimeOffset.Parse("2025-10-04T00:00:00Z"),
RecordedAt: DateTimeOffset.Parse("2025-10-04T03:00:00Z"),
StatementIds: ImmutableArray<Guid>.Empty));
var replay = await log.ReplayAsync("CVE-2025-0001", asOf: null, CancellationToken.None);
Assert.Equal("cve-2025-0001", replay.VulnerabilityKey);
Assert.Collection(
replay.Statements,
first => Assert.Equal("adv-2", first.AdvisoryKey),
second => Assert.Equal("adv-1", second.AdvisoryKey));
Assert.Single(replay.Conflicts);
Assert.Equal("{\"reason\":\"conflict\"}", replay.Conflicts[0].CanonicalJson);
}
private sealed class FakeRepository : IAdvisoryEventRepository
{
public List<AdvisoryStatementEntry> InsertedStatements { get; } = new();
public List<AdvisoryConflictEntry> InsertedConflicts { get; } = new();
public List<AdvisoryStatementEntry> StoredStatements { get; } = new();
public List<AdvisoryConflictEntry> StoredConflicts { get; } = new();
public ValueTask InsertStatementsAsync(IReadOnlyCollection<AdvisoryStatementEntry> statements, CancellationToken cancellationToken)
{
InsertedStatements.AddRange(statements);
return ValueTask.CompletedTask;
}
public ValueTask InsertConflictsAsync(IReadOnlyCollection<AdvisoryConflictEntry> conflicts, CancellationToken cancellationToken)
{
InsertedConflicts.AddRange(conflicts);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<AdvisoryStatementEntry>> GetStatementsAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyList<AdvisoryStatementEntry>>(StoredStatements.Where(entry =>
string.Equals(entry.VulnerabilityKey, vulnerabilityKey, StringComparison.Ordinal) &&
(!asOf.HasValue || entry.AsOf <= asOf.Value)).ToList());
public List<AdvisoryStatementEntry> InsertedStatements { get; } = new();
public List<AdvisoryConflictEntry> InsertedConflicts { get; } = new();
public List<AdvisoryStatementEntry> StoredStatements { get; } = new();
public List<AdvisoryConflictEntry> StoredConflicts { get; } = new();
public ValueTask InsertStatementsAsync(IReadOnlyCollection<AdvisoryStatementEntry> statements, CancellationToken cancellationToken)
{
InsertedStatements.AddRange(statements);
return ValueTask.CompletedTask;
}
public ValueTask InsertConflictsAsync(IReadOnlyCollection<AdvisoryConflictEntry> conflicts, CancellationToken cancellationToken)
{
InsertedConflicts.AddRange(conflicts);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<AdvisoryStatementEntry>> GetStatementsAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyList<AdvisoryStatementEntry>>(StoredStatements.Where(entry =>
string.Equals(entry.VulnerabilityKey, vulnerabilityKey, StringComparison.Ordinal) &&
(!asOf.HasValue || entry.AsOf <= asOf.Value)).ToList());
public ValueTask<IReadOnlyList<AdvisoryConflictEntry>> GetConflictsAsync(string vulnerabilityKey, DateTimeOffset? asOf, CancellationToken cancellationToken)
=> ValueTask.FromResult<IReadOnlyList<AdvisoryConflictEntry>>(StoredConflicts.Where(entry =>
string.Equals(entry.VulnerabilityKey, vulnerabilityKey, StringComparison.Ordinal) &&
@@ -278,11 +278,11 @@ public sealed class AdvisoryEventLogTests
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now)
{
_now = now.ToUniversalTime();
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now)
{
_now = now.ToUniversalTime();
}
public override DateTimeOffset GetUtcNow() => _now;

View File

@@ -1,61 +1,61 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Concelier.Core.Tests;
public sealed class JobPluginRegistrationExtensionsTests
{
[Fact]
public void RegisterJobPluginRoutines_LoadsPluginsAndRegistersDefinitions()
{
var services = new ServiceCollection();
services.AddJobScheduler();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["plugin:test:timeoutSeconds"] = "45",
})
.Build();
var assemblyPath = typeof(JobPluginRegistrationExtensionsTests).Assembly.Location;
var pluginDirectory = Path.GetDirectoryName(assemblyPath)!;
var pluginFile = Path.GetFileName(assemblyPath);
var options = new PluginHostOptions
{
BaseDirectory = pluginDirectory,
PluginsDirectory = pluginDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
options.SearchPatterns.Add(pluginFile);
services.RegisterJobPluginRoutines(configuration, options);
Assert.Contains(
services,
descriptor => descriptor.ServiceType == typeof(PluginHostResult));
Assert.Contains(
services,
descriptor => descriptor.ServiceType.FullName == typeof(PluginRoutineExecuted).FullName);
using var provider = services.BuildServiceProvider();
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.True(schedulerOptions.Definitions.TryGetValue(PluginJob.JobKind, out var definition));
Assert.NotNull(definition);
Assert.Equal(PluginJob.JobKind, definition.Kind);
Assert.Equal("StellaOps.Concelier.Core.Tests.PluginJob", definition.JobType.FullName);
Assert.Equal(TimeSpan.FromSeconds(45), definition.Timeout);
Assert.Equal(TimeSpan.FromSeconds(5), definition.LeaseDuration);
Assert.Equal("*/10 * * * *", definition.CronExpression);
}
}
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Concelier.Core.Tests;
public sealed class JobPluginRegistrationExtensionsTests
{
[Fact]
public void RegisterJobPluginRoutines_LoadsPluginsAndRegistersDefinitions()
{
var services = new ServiceCollection();
services.AddJobScheduler();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["plugin:test:timeoutSeconds"] = "45",
})
.Build();
var assemblyPath = typeof(JobPluginRegistrationExtensionsTests).Assembly.Location;
var pluginDirectory = Path.GetDirectoryName(assemblyPath)!;
var pluginFile = Path.GetFileName(assemblyPath);
var options = new PluginHostOptions
{
BaseDirectory = pluginDirectory,
PluginsDirectory = pluginDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
options.SearchPatterns.Add(pluginFile);
services.RegisterJobPluginRoutines(configuration, options);
Assert.Contains(
services,
descriptor => descriptor.ServiceType == typeof(PluginHostResult));
Assert.Contains(
services,
descriptor => descriptor.ServiceType.FullName == typeof(PluginRoutineExecuted).FullName);
using var provider = services.BuildServiceProvider();
var schedulerOptions = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.True(schedulerOptions.Definitions.TryGetValue(PluginJob.JobKind, out var definition));
Assert.NotNull(definition);
Assert.Equal(PluginJob.JobKind, definition.Kind);
Assert.Equal("StellaOps.Concelier.Core.Tests.PluginJob", definition.JobType.FullName);
Assert.Equal(TimeSpan.FromSeconds(45), definition.Timeout);
Assert.Equal(TimeSpan.FromSeconds(5), definition.LeaseDuration);
Assert.Equal("*/10 * * * *", definition.CronExpression);
}
}

View File

@@ -1,70 +1,70 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.Core.Tests;
public sealed class JobSchedulerBuilderTests
{
[Fact]
public void AddJob_RegistersDefinitionWithExplicitMetadata()
{
var services = new ServiceCollection();
var builder = services.AddJobScheduler();
builder.AddJob<TestJob>(
kind: "jobs:test",
cronExpression: "*/5 * * * *",
timeout: TimeSpan.FromMinutes(42),
leaseDuration: TimeSpan.FromMinutes(7),
enabled: false);
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.True(options.Definitions.TryGetValue("jobs:test", out var definition));
Assert.NotNull(definition);
Assert.Equal(typeof(TestJob), definition.JobType);
Assert.Equal(TimeSpan.FromMinutes(42), definition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(7), definition.LeaseDuration);
Assert.Equal("*/5 * * * *", definition.CronExpression);
Assert.False(definition.Enabled);
}
[Fact]
public void AddJob_UsesDefaults_WhenOptionalMetadataExcluded()
{
var services = new ServiceCollection();
var builder = services.AddJobScheduler(options =>
{
options.DefaultTimeout = TimeSpan.FromSeconds(123);
options.DefaultLeaseDuration = TimeSpan.FromSeconds(45);
});
builder.AddJob<DefaultedJob>(kind: "jobs:defaults");
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.True(options.Definitions.TryGetValue("jobs:defaults", out var definition));
Assert.NotNull(definition);
Assert.Equal(typeof(DefaultedJob), definition.JobType);
Assert.Equal(TimeSpan.FromSeconds(123), definition.Timeout);
Assert.Equal(TimeSpan.FromSeconds(45), definition.LeaseDuration);
Assert.Null(definition.CronExpression);
Assert.True(definition.Enabled);
}
private sealed class TestJob : IJob
{
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
private sealed class DefaultedJob : IJob
{
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
}
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.Core.Tests;
public sealed class JobSchedulerBuilderTests
{
[Fact]
public void AddJob_RegistersDefinitionWithExplicitMetadata()
{
var services = new ServiceCollection();
var builder = services.AddJobScheduler();
builder.AddJob<TestJob>(
kind: "jobs:test",
cronExpression: "*/5 * * * *",
timeout: TimeSpan.FromMinutes(42),
leaseDuration: TimeSpan.FromMinutes(7),
enabled: false);
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.True(options.Definitions.TryGetValue("jobs:test", out var definition));
Assert.NotNull(definition);
Assert.Equal(typeof(TestJob), definition.JobType);
Assert.Equal(TimeSpan.FromMinutes(42), definition.Timeout);
Assert.Equal(TimeSpan.FromMinutes(7), definition.LeaseDuration);
Assert.Equal("*/5 * * * *", definition.CronExpression);
Assert.False(definition.Enabled);
}
[Fact]
public void AddJob_UsesDefaults_WhenOptionalMetadataExcluded()
{
var services = new ServiceCollection();
var builder = services.AddJobScheduler(options =>
{
options.DefaultTimeout = TimeSpan.FromSeconds(123);
options.DefaultLeaseDuration = TimeSpan.FromSeconds(45);
});
builder.AddJob<DefaultedJob>(kind: "jobs:defaults");
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<JobSchedulerOptions>>().Value;
Assert.True(options.Definitions.TryGetValue("jobs:defaults", out var definition));
Assert.NotNull(definition);
Assert.Equal(typeof(DefaultedJob), definition.JobType);
Assert.Equal(TimeSpan.FromSeconds(123), definition.Timeout);
Assert.Equal(TimeSpan.FromSeconds(45), definition.LeaseDuration);
Assert.Null(definition.CronExpression);
Assert.True(definition.Enabled);
}
private sealed class TestJob : IJob
{
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
private sealed class DefaultedJob : IJob
{
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
}

View File

@@ -1,124 +1,124 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.RawModels;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Linksets;
public sealed class AdvisoryLinksetMapperTests
{
[Fact]
public void Map_CollectsSignalsFromIdentifiersAndContent()
{
using var contentDoc = JsonDocument.Parse(
"""
{
"cve": { "id": "CVE-2025-0001" },
"metadata": {
"ghsa": "GHSA-xxxx-yyyy-zzzz"
},
"affected": [
{
"package": { "purl": "pkg:npm/package-a@1.0.0" },
"cpe": "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"
}
],
"references": [
{ "type": "Advisory", "url": "https://example.test/advisory" },
{ "url": "https://example.test/patch", "source": "vendor" }
]
}
""");
var document = new AdvisoryRawDocument(
Tenant: "tenant-a",
Source: new RawSourceMetadata("vendor", "connector", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: "GHSA-xxxx-yyyy-zzzz",
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:abc",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "OSV",
SpecVersion: "1.0",
Raw: contentDoc.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create("GHSA-xxxx-yyyy-zzzz"),
PrimaryId: "GHSA-xxxx-yyyy-zzzz"),
Linkset: new RawLinkset());
var mapper = new AdvisoryLinksetMapper();
var result = mapper.Map(document);
Assert.Equal(new[] { "cve-2025-0001", "ghsa-xxxx-yyyy-zzzz" }, result.Aliases);
Assert.Equal(new[] { "pkg:npm/package-a@1.0.0" }, result.PackageUrls);
Assert.Equal(new[] { "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*" }, result.Cpes);
Assert.Equal(2, result.References.Length);
Assert.Contains(result.References, reference => reference.Type == "advisory" && reference.Url == "https://example.test/advisory");
Assert.Contains(result.References, reference => reference.Type == "unspecified" && reference.Url == "https://example.test/patch" && reference.Source == "vendor");
var expectedPointers = new[]
{
"/content/raw/affected/0/cpe",
"/content/raw/affected/0/package/purl",
"/content/raw/cve/id",
"/content/raw/metadata/ghsa",
"/content/raw/references/0/url",
"/content/raw/references/1/url",
"/identifiers/aliases/0",
"/identifiers/primary"
};
Assert.Equal(expectedPointers.OrderBy(static value => value, StringComparer.Ordinal), result.ReconciledFrom);
}
[Fact]
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.RawModels;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Linksets;
public sealed class AdvisoryLinksetMapperTests
{
[Fact]
public void Map_CollectsSignalsFromIdentifiersAndContent()
{
using var contentDoc = JsonDocument.Parse(
"""
{
"cve": { "id": "CVE-2025-0001" },
"metadata": {
"ghsa": "GHSA-xxxx-yyyy-zzzz"
},
"affected": [
{
"package": { "purl": "pkg:npm/package-a@1.0.0" },
"cpe": "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"
}
],
"references": [
{ "type": "Advisory", "url": "https://example.test/advisory" },
{ "url": "https://example.test/patch", "source": "vendor" }
]
}
""");
var document = new AdvisoryRawDocument(
Tenant: "tenant-a",
Source: new RawSourceMetadata("vendor", "connector", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: "GHSA-xxxx-yyyy-zzzz",
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:abc",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "OSV",
SpecVersion: "1.0",
Raw: contentDoc.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create("GHSA-xxxx-yyyy-zzzz"),
PrimaryId: "GHSA-xxxx-yyyy-zzzz"),
Linkset: new RawLinkset());
var mapper = new AdvisoryLinksetMapper();
var result = mapper.Map(document);
Assert.Equal(new[] { "cve-2025-0001", "ghsa-xxxx-yyyy-zzzz" }, result.Aliases);
Assert.Equal(new[] { "pkg:npm/package-a@1.0.0" }, result.PackageUrls);
Assert.Equal(new[] { "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*" }, result.Cpes);
Assert.Equal(2, result.References.Length);
Assert.Contains(result.References, reference => reference.Type == "advisory" && reference.Url == "https://example.test/advisory");
Assert.Contains(result.References, reference => reference.Type == "unspecified" && reference.Url == "https://example.test/patch" && reference.Source == "vendor");
var expectedPointers = new[]
{
"/content/raw/affected/0/cpe",
"/content/raw/affected/0/package/purl",
"/content/raw/cve/id",
"/content/raw/metadata/ghsa",
"/content/raw/references/0/url",
"/content/raw/references/1/url",
"/identifiers/aliases/0",
"/identifiers/primary"
};
Assert.Equal(expectedPointers.OrderBy(static value => value, StringComparer.Ordinal), result.ReconciledFrom);
}
[Fact]
public void Map_DeduplicatesValuesButRetainsMultipleOrigins()
{
using var contentDoc = JsonDocument.Parse(
"""
{
"aliases": ["CVE-2025-0002", "CVE-2025-0002"],
"packages": [
{ "coordinates": "pkg:npm/package-b@2.0.0" },
{ "coordinates": "pkg:npm/package-b@2.0.0" }
]
}
""");
var document = new AdvisoryRawDocument(
Tenant: "tenant-a",
Source: new RawSourceMetadata("vendor", "connector", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: "GHSA-example",
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:def",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "custom",
SpecVersion: null,
Raw: contentDoc.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray<string>.Empty,
PrimaryId: "GHSA-example"),
Linkset: new RawLinkset());
var mapper = new AdvisoryLinksetMapper();
var result = mapper.Map(document);
Assert.Equal(new[] { "cve-2025-0002" }, result.Aliases);
Assert.Equal(new[] { "pkg:npm/package-b@2.0.0" }, result.PackageUrls);
Assert.Contains("/content/raw/aliases/0", result.ReconciledFrom);
Assert.Contains("/content/raw/aliases/1", result.ReconciledFrom);
"aliases": ["CVE-2025-0002", "CVE-2025-0002"],
"packages": [
{ "coordinates": "pkg:npm/package-b@2.0.0" },
{ "coordinates": "pkg:npm/package-b@2.0.0" }
]
}
""");
var document = new AdvisoryRawDocument(
Tenant: "tenant-a",
Source: new RawSourceMetadata("vendor", "connector", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: "GHSA-example",
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:def",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "custom",
SpecVersion: null,
Raw: contentDoc.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray<string>.Empty,
PrimaryId: "GHSA-example"),
Linkset: new RawLinkset());
var mapper = new AdvisoryLinksetMapper();
var result = mapper.Map(document);
Assert.Equal(new[] { "cve-2025-0002" }, result.Aliases);
Assert.Equal(new[] { "pkg:npm/package-b@2.0.0" }, result.PackageUrls);
Assert.Contains("/content/raw/aliases/0", result.ReconciledFrom);
Assert.Contains("/content/raw/aliases/1", result.ReconciledFrom);
Assert.Contains("/content/raw/packages/0/coordinates", result.ReconciledFrom);
Assert.Contains("/content/raw/packages/1/coordinates", result.ReconciledFrom);
}

View File

@@ -7,30 +7,30 @@ using StellaOps.Concelier.Models.Observations;
using StellaOps.Concelier.RawModels;
using Xunit;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Core.Tests.Linksets;
public sealed class AdvisoryObservationFactoryTests
{
private static readonly DateTimeOffset SampleTimestamp = DateTimeOffset.Parse("2025-10-26T12:34:56Z");
[Fact]
namespace StellaOps.Concelier.Core.Tests.Linksets;
public sealed class AdvisoryObservationFactoryTests
{
private static readonly DateTimeOffset SampleTimestamp = DateTimeOffset.Parse("2025-10-26T12:34:56Z");
[Fact]
public void Create_PreservesLinksetOrderAndDuplicates()
{
var factory = new AdvisoryObservationFactory();
var rawDocument = BuildRawDocument(
identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create(" CVE-2025-0001 ", "ghsa-XXXX-YYYY"),
PrimaryId: "GHSA-XXXX-YYYY"),
linkset: new RawLinkset
{
PackageUrls = ImmutableArray.Create("pkg:NPM/left-pad@1.0.0", "pkg:npm/left-pad@1.0.0?foo=bar"),
Cpes = ImmutableArray.Create("cpe:/a:Example:Product:1.0", "cpe:/a:example:product:1.0"),
Aliases = ImmutableArray.Create(" CVE-2025-0001 "),
References = ImmutableArray.Create(
new RawReference("Advisory", " https://example.test/advisory "),
new RawReference("ADVISORY", "https://example.test/advisory"))
});
Aliases: ImmutableArray.Create(" CVE-2025-0001 ", "ghsa-XXXX-YYYY"),
PrimaryId: "GHSA-XXXX-YYYY"),
linkset: new RawLinkset
{
PackageUrls = ImmutableArray.Create("pkg:NPM/left-pad@1.0.0", "pkg:npm/left-pad@1.0.0?foo=bar"),
Cpes = ImmutableArray.Create("cpe:/a:Example:Product:1.0", "cpe:/a:example:product:1.0"),
Aliases = ImmutableArray.Create(" CVE-2025-0001 "),
References = ImmutableArray.Create(
new RawReference("Advisory", " https://example.test/advisory "),
new RawReference("ADVISORY", "https://example.test/advisory"))
});
var observation = factory.Create(rawDocument, SampleTimestamp);
@@ -79,44 +79,44 @@ public sealed class AdvisoryObservationFactoryTests
Assert.Equal("https://example.test/advisory", second.Url);
});
}
[Fact]
public void Create_SetsSourceAndUpstreamFields()
{
var factory = new AdvisoryObservationFactory();
var upstreamProvenance = ImmutableDictionary.CreateRange(new Dictionary<string, string>
{
["api"] = "https://api.example.test/v1/feed"
});
var rawDocument = BuildRawDocument(
source: new RawSourceMetadata("vendor-x", "connector-y", "2.3.4", Stream: "stable"),
upstream: new RawUpstreamMetadata(
UpstreamId: "doc-123",
DocumentVersion: "2025.10.26",
RetrievedAt: SampleTimestamp,
ContentHash: "sha256:abcdef",
Signature: new RawSignatureMetadata(true, "dsse", "key-1", "signature-bytes"),
Provenance: upstreamProvenance),
identifiers: new RawIdentifiers(ImmutableArray<string>.Empty, "doc-123"),
linkset: new RawLinkset());
var observation = factory.Create(rawDocument);
Assert.Equal("vendor-x", observation.Source.Vendor);
Assert.Equal("stable", observation.Source.Stream);
Assert.Equal("https://api.example.test/v1/feed", observation.Source.Api);
Assert.Equal("2.3.4", observation.Source.CollectorVersion);
Assert.Equal("doc-123", observation.Upstream.UpstreamId);
Assert.Equal("2025.10.26", observation.Upstream.DocumentVersion);
Assert.Equal("sha256:abcdef", observation.Upstream.ContentHash);
Assert.True(observation.Upstream.Signature.Present);
Assert.Equal("dsse", observation.Upstream.Signature.Format);
Assert.Equal(upstreamProvenance, observation.Upstream.Metadata);
}
[Fact]
[Fact]
public void Create_SetsSourceAndUpstreamFields()
{
var factory = new AdvisoryObservationFactory();
var upstreamProvenance = ImmutableDictionary.CreateRange(new Dictionary<string, string>
{
["api"] = "https://api.example.test/v1/feed"
});
var rawDocument = BuildRawDocument(
source: new RawSourceMetadata("vendor-x", "connector-y", "2.3.4", Stream: "stable"),
upstream: new RawUpstreamMetadata(
UpstreamId: "doc-123",
DocumentVersion: "2025.10.26",
RetrievedAt: SampleTimestamp,
ContentHash: "sha256:abcdef",
Signature: new RawSignatureMetadata(true, "dsse", "key-1", "signature-bytes"),
Provenance: upstreamProvenance),
identifiers: new RawIdentifiers(ImmutableArray<string>.Empty, "doc-123"),
linkset: new RawLinkset());
var observation = factory.Create(rawDocument);
Assert.Equal("vendor-x", observation.Source.Vendor);
Assert.Equal("stable", observation.Source.Stream);
Assert.Equal("https://api.example.test/v1/feed", observation.Source.Api);
Assert.Equal("2.3.4", observation.Source.CollectorVersion);
Assert.Equal("doc-123", observation.Upstream.UpstreamId);
Assert.Equal("2025.10.26", observation.Upstream.DocumentVersion);
Assert.Equal("sha256:abcdef", observation.Upstream.ContentHash);
Assert.True(observation.Upstream.Signature.Present);
Assert.Equal("dsse", observation.Upstream.Signature.Format);
Assert.Equal(upstreamProvenance, observation.Upstream.Metadata);
}
[Fact]
public void Create_StoresNotesAsAttributes()
{
var factory = new AdvisoryObservationFactory();
@@ -259,48 +259,48 @@ public sealed class AdvisoryObservationFactoryTests
Assert.Equal(first.CreatedAt, second.CreatedAt);
}
private static AdvisoryRawDocument BuildRawDocument(
RawSourceMetadata? source = null,
RawUpstreamMetadata? upstream = null,
RawIdentifiers? identifiers = null,
RawLinkset? linkset = null,
string tenant = "tenant-a",
string? supersedes = null)
{
source ??= new RawSourceMetadata(
Vendor: "vendor-x",
Connector: "connector-y",
ConnectorVersion: "1.0.0",
Stream: null);
upstream ??= new RawUpstreamMetadata(
UpstreamId: "doc-1",
DocumentVersion: "v1",
RetrievedAt: SampleTimestamp,
ContentHash: "sha256:123",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty);
identifiers ??= new RawIdentifiers(
Aliases: ImmutableArray<string>.Empty,
PrimaryId: "doc-1");
linkset ??= new RawLinkset();
using var document = JsonDocument.Parse("""{"id":"doc-1"}""");
var content = new RawContent(
Format: "csaf",
SpecVersion: "2.0",
Raw: document.RootElement.Clone(),
Encoding: null);
return new AdvisoryRawDocument(
Tenant: tenant,
Source: source,
Upstream: upstream,
Content: content,
Identifiers: identifiers,
Linkset: linkset,
Supersedes: supersedes);
}
}
private static AdvisoryRawDocument BuildRawDocument(
RawSourceMetadata? source = null,
RawUpstreamMetadata? upstream = null,
RawIdentifiers? identifiers = null,
RawLinkset? linkset = null,
string tenant = "tenant-a",
string? supersedes = null)
{
source ??= new RawSourceMetadata(
Vendor: "vendor-x",
Connector: "connector-y",
ConnectorVersion: "1.0.0",
Stream: null);
upstream ??= new RawUpstreamMetadata(
UpstreamId: "doc-1",
DocumentVersion: "v1",
RetrievedAt: SampleTimestamp,
ContentHash: "sha256:123",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty);
identifiers ??= new RawIdentifiers(
Aliases: ImmutableArray<string>.Empty,
PrimaryId: "doc-1");
linkset ??= new RawLinkset();
using var document = JsonDocument.Parse("""{"id":"doc-1"}""");
var content = new RawContent(
Format: "csaf",
SpecVersion: "2.0",
Raw: document.RootElement.Clone(),
Encoding: null);
return new AdvisoryRawDocument(
Tenant: tenant,
Source: source,
Upstream: upstream,
Content: content,
Identifiers: identifiers,
Linkset: linkset,
Supersedes: supersedes);
}
}

View File

@@ -1,255 +1,255 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Core.Noise;
using StellaOps.Concelier.Models;
using StellaOps.Provenance.Mongo;
using StellaOps.Provenance;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Noise;
public sealed class NoisePriorServiceTests
{
[Fact]
public async Task RecomputeAsync_PersistsSummariesWithRules()
{
var statements = ImmutableArray.Create(
CreateStatement(
asOf: DateTimeOffset.Parse("2025-10-10T00:00:00Z"),
CreatePackage(
statuses: new[]
{
CreateStatus(AffectedPackageStatusCatalog.NotAffected, "vendor.redhat"),
},
platform: "linux")));
statements = statements.Add(CreateStatement(
asOf: DateTimeOffset.Parse("2025-10-11T00:00:00Z"),
CreatePackage(
statuses: new[]
{
CreateStatus(AffectedPackageStatusCatalog.KnownNotAffected, "vendor.canonical"),
},
platform: "linux")));
statements = statements.Add(CreateStatement(
asOf: DateTimeOffset.Parse("2025-10-12T00:00:00Z"),
CreatePackage(
statuses: new[]
{
CreateStatus(AffectedPackageStatusCatalog.Affected, "vendor.osv"),
},
platform: "linux",
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: "1.0.0",
fixedVersion: null,
lastAffectedVersion: null,
rangeExpression: null,
provenance: CreateProvenance("vendor.osv")),
})));
var replay = new AdvisoryReplay(
"cve-9999-0001",
null,
statements,
ImmutableArray<AdvisoryConflictSnapshot>.Empty);
var eventLog = new FakeEventLog(replay);
var repository = new FakeNoisePriorRepository();
var now = DateTimeOffset.Parse("2025-10-21T12:00:00Z");
var timeProvider = new FixedTimeProvider(now);
var service = new NoisePriorService(eventLog, repository, timeProvider);
var result = await service.RecomputeAsync(
new NoisePriorComputationRequest("CVE-9999-0001"),
CancellationToken.None);
Assert.Equal("cve-9999-0001", result.VulnerabilityKey);
Assert.Single(result.Summaries);
var summary = result.Summaries[0];
Assert.Equal("cve-9999-0001", summary.VulnerabilityKey);
Assert.Equal("semver", summary.PackageType);
Assert.Equal("pkg:npm/example", summary.PackageIdentifier);
Assert.Equal("linux", summary.Platform);
Assert.Equal(3, summary.ObservationCount);
Assert.Equal(2, summary.NegativeSignals);
Assert.Equal(1, summary.PositiveSignals);
Assert.Equal(0, summary.NeutralSignals);
Assert.Equal(1, summary.VersionRangeSignals);
Assert.Equal(2, summary.UniqueNegativeSources);
Assert.Equal(0.6, summary.Probability);
Assert.Equal(now, summary.GeneratedAt);
Assert.Equal(DateTimeOffset.Parse("2025-10-10T00:00:00Z"), summary.FirstObserved);
Assert.Equal(DateTimeOffset.Parse("2025-10-12T00:00:00Z"), summary.LastObserved);
Assert.Equal(
new[] { "conflicting_signals", "multi_source_negative", "positive_evidence" },
summary.RuleHits.ToArray());
Assert.Equal("cve-9999-0001", repository.LastUpsertKey);
Assert.NotNull(repository.LastUpsertSummaries);
Assert.Single(repository.LastUpsertSummaries!);
}
[Fact]
public async Task RecomputeAsync_AllNegativeSignalsProducesHighPrior()
{
var statements = ImmutableArray.Create(
CreateStatement(
asOf: DateTimeOffset.Parse("2025-10-01T00:00:00Z"),
CreatePackage(
statuses: new[]
{
CreateStatus(AffectedPackageStatusCatalog.NotAffected, "vendor.redhat"),
}),
vulnerabilityKey: "cve-2025-1111"));
statements = statements.Add(CreateStatement(
asOf: DateTimeOffset.Parse("2025-10-02T00:00:00Z"),
CreatePackage(
statuses: new[]
{
CreateStatus(AffectedPackageStatusCatalog.KnownNotAffected, "vendor.redhat"),
}),
vulnerabilityKey: "cve-2025-1111"));
var replay = new AdvisoryReplay(
"cve-2025-1111",
null,
statements,
ImmutableArray<AdvisoryConflictSnapshot>.Empty);
var eventLog = new FakeEventLog(replay);
var repository = new FakeNoisePriorRepository();
var now = DateTimeOffset.Parse("2025-10-21T13:00:00Z");
var timeProvider = new FixedTimeProvider(now);
var service = new NoisePriorService(eventLog, repository, timeProvider);
var result = await service.RecomputeAsync(
new NoisePriorComputationRequest("cve-2025-1111"),
CancellationToken.None);
var summary = Assert.Single(result.Summaries);
Assert.Equal(1.0, summary.Probability);
Assert.Equal(
new[] { "all_negative", "sparse_observations" },
summary.RuleHits.ToArray());
}
[Fact]
public async Task GetByPackageAsync_NormalizesInputs()
{
var statements = ImmutableArray.Create(
CreateStatement(
asOf: DateTimeOffset.Parse("2025-10-03T00:00:00Z"),
CreatePackage(
statuses: new[]
{
CreateStatus(AffectedPackageStatusCatalog.Unknown, "vendor.generic"),
},
platform: "linux"),
vulnerabilityKey: "cve-2025-2000"));
var replay = new AdvisoryReplay(
"cve-2025-2000",
null,
statements,
ImmutableArray<AdvisoryConflictSnapshot>.Empty);
var eventLog = new FakeEventLog(replay);
var repository = new FakeNoisePriorRepository();
var service = new NoisePriorService(eventLog, repository, new FixedTimeProvider(DateTimeOffset.UtcNow));
await service.RecomputeAsync(
new NoisePriorComputationRequest("CVE-2025-2000"),
CancellationToken.None);
var summaries = await service.GetByPackageAsync(
" SemVer ",
"pkg:npm/example",
" linux ",
CancellationToken.None);
Assert.Single(summaries);
Assert.Equal("semver", summaries[0].PackageType);
Assert.Equal("linux", summaries[0].Platform);
}
private static AdvisoryStatementSnapshot CreateStatement(
DateTimeOffset asOf,
AffectedPackage package,
string vulnerabilityKey = "cve-9999-0001")
{
var advisory = new Advisory(
advisoryKey: $"adv-{asOf:yyyyMMddHHmmss}",
title: "Example Advisory",
summary: null,
language: "en",
published: null,
modified: asOf,
severity: "high",
exploitKnown: false,
aliases: new[] { "CVE-TEST-0001" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[] { package },
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: Array.Empty<AdvisoryProvenance>());
return new AdvisoryStatementSnapshot(
Guid.NewGuid(),
vulnerabilityKey,
advisory.AdvisoryKey,
advisory,
StatementHash: ImmutableArray<byte>.Empty,
AsOf: asOf,
RecordedAt: asOf,
InputDocumentIds: ImmutableArray<Guid>.Empty);
}
private static AffectedPackage CreatePackage(
IEnumerable<AffectedPackageStatus> statuses,
string? platform = null,
IEnumerable<AffectedVersionRange>? versionRanges = null)
=> new(
type: "semver",
identifier: "pkg:npm/example",
platform: platform,
versionRanges: versionRanges,
statuses: statuses,
provenance: new[] { CreateProvenance("vendor.core") },
normalizedVersions: null);
private static AffectedPackageStatus CreateStatus(string status, string source)
=> new(
status,
CreateProvenance(source));
private static AdvisoryProvenance CreateProvenance(string source, string kind = "vendor")
=> new(
source,
kind,
value: string.Empty,
recordedAt: DateTimeOffset.Parse("2025-10-01T00:00:00Z"),
fieldMask: null,
decisionReason: null);
private sealed class FakeEventLog : IAdvisoryEventLog
{
private readonly AdvisoryReplay _replay;
public FakeEventLog(AdvisoryReplay replay)
{
_replay = replay;
}
namespace StellaOps.Concelier.Core.Tests.Noise;
public sealed class NoisePriorServiceTests
{
[Fact]
public async Task RecomputeAsync_PersistsSummariesWithRules()
{
var statements = ImmutableArray.Create(
CreateStatement(
asOf: DateTimeOffset.Parse("2025-10-10T00:00:00Z"),
CreatePackage(
statuses: new[]
{
CreateStatus(AffectedPackageStatusCatalog.NotAffected, "vendor.redhat"),
},
platform: "linux")));
statements = statements.Add(CreateStatement(
asOf: DateTimeOffset.Parse("2025-10-11T00:00:00Z"),
CreatePackage(
statuses: new[]
{
CreateStatus(AffectedPackageStatusCatalog.KnownNotAffected, "vendor.canonical"),
},
platform: "linux")));
statements = statements.Add(CreateStatement(
asOf: DateTimeOffset.Parse("2025-10-12T00:00:00Z"),
CreatePackage(
statuses: new[]
{
CreateStatus(AffectedPackageStatusCatalog.Affected, "vendor.osv"),
},
platform: "linux",
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: "1.0.0",
fixedVersion: null,
lastAffectedVersion: null,
rangeExpression: null,
provenance: CreateProvenance("vendor.osv")),
})));
var replay = new AdvisoryReplay(
"cve-9999-0001",
null,
statements,
ImmutableArray<AdvisoryConflictSnapshot>.Empty);
var eventLog = new FakeEventLog(replay);
var repository = new FakeNoisePriorRepository();
var now = DateTimeOffset.Parse("2025-10-21T12:00:00Z");
var timeProvider = new FixedTimeProvider(now);
var service = new NoisePriorService(eventLog, repository, timeProvider);
var result = await service.RecomputeAsync(
new NoisePriorComputationRequest("CVE-9999-0001"),
CancellationToken.None);
Assert.Equal("cve-9999-0001", result.VulnerabilityKey);
Assert.Single(result.Summaries);
var summary = result.Summaries[0];
Assert.Equal("cve-9999-0001", summary.VulnerabilityKey);
Assert.Equal("semver", summary.PackageType);
Assert.Equal("pkg:npm/example", summary.PackageIdentifier);
Assert.Equal("linux", summary.Platform);
Assert.Equal(3, summary.ObservationCount);
Assert.Equal(2, summary.NegativeSignals);
Assert.Equal(1, summary.PositiveSignals);
Assert.Equal(0, summary.NeutralSignals);
Assert.Equal(1, summary.VersionRangeSignals);
Assert.Equal(2, summary.UniqueNegativeSources);
Assert.Equal(0.6, summary.Probability);
Assert.Equal(now, summary.GeneratedAt);
Assert.Equal(DateTimeOffset.Parse("2025-10-10T00:00:00Z"), summary.FirstObserved);
Assert.Equal(DateTimeOffset.Parse("2025-10-12T00:00:00Z"), summary.LastObserved);
Assert.Equal(
new[] { "conflicting_signals", "multi_source_negative", "positive_evidence" },
summary.RuleHits.ToArray());
Assert.Equal("cve-9999-0001", repository.LastUpsertKey);
Assert.NotNull(repository.LastUpsertSummaries);
Assert.Single(repository.LastUpsertSummaries!);
}
[Fact]
public async Task RecomputeAsync_AllNegativeSignalsProducesHighPrior()
{
var statements = ImmutableArray.Create(
CreateStatement(
asOf: DateTimeOffset.Parse("2025-10-01T00:00:00Z"),
CreatePackage(
statuses: new[]
{
CreateStatus(AffectedPackageStatusCatalog.NotAffected, "vendor.redhat"),
}),
vulnerabilityKey: "cve-2025-1111"));
statements = statements.Add(CreateStatement(
asOf: DateTimeOffset.Parse("2025-10-02T00:00:00Z"),
CreatePackage(
statuses: new[]
{
CreateStatus(AffectedPackageStatusCatalog.KnownNotAffected, "vendor.redhat"),
}),
vulnerabilityKey: "cve-2025-1111"));
var replay = new AdvisoryReplay(
"cve-2025-1111",
null,
statements,
ImmutableArray<AdvisoryConflictSnapshot>.Empty);
var eventLog = new FakeEventLog(replay);
var repository = new FakeNoisePriorRepository();
var now = DateTimeOffset.Parse("2025-10-21T13:00:00Z");
var timeProvider = new FixedTimeProvider(now);
var service = new NoisePriorService(eventLog, repository, timeProvider);
var result = await service.RecomputeAsync(
new NoisePriorComputationRequest("cve-2025-1111"),
CancellationToken.None);
var summary = Assert.Single(result.Summaries);
Assert.Equal(1.0, summary.Probability);
Assert.Equal(
new[] { "all_negative", "sparse_observations" },
summary.RuleHits.ToArray());
}
[Fact]
public async Task GetByPackageAsync_NormalizesInputs()
{
var statements = ImmutableArray.Create(
CreateStatement(
asOf: DateTimeOffset.Parse("2025-10-03T00:00:00Z"),
CreatePackage(
statuses: new[]
{
CreateStatus(AffectedPackageStatusCatalog.Unknown, "vendor.generic"),
},
platform: "linux"),
vulnerabilityKey: "cve-2025-2000"));
var replay = new AdvisoryReplay(
"cve-2025-2000",
null,
statements,
ImmutableArray<AdvisoryConflictSnapshot>.Empty);
var eventLog = new FakeEventLog(replay);
var repository = new FakeNoisePriorRepository();
var service = new NoisePriorService(eventLog, repository, new FixedTimeProvider(DateTimeOffset.UtcNow));
await service.RecomputeAsync(
new NoisePriorComputationRequest("CVE-2025-2000"),
CancellationToken.None);
var summaries = await service.GetByPackageAsync(
" SemVer ",
"pkg:npm/example",
" linux ",
CancellationToken.None);
Assert.Single(summaries);
Assert.Equal("semver", summaries[0].PackageType);
Assert.Equal("linux", summaries[0].Platform);
}
private static AdvisoryStatementSnapshot CreateStatement(
DateTimeOffset asOf,
AffectedPackage package,
string vulnerabilityKey = "cve-9999-0001")
{
var advisory = new Advisory(
advisoryKey: $"adv-{asOf:yyyyMMddHHmmss}",
title: "Example Advisory",
summary: null,
language: "en",
published: null,
modified: asOf,
severity: "high",
exploitKnown: false,
aliases: new[] { "CVE-TEST-0001" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: new[] { package },
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: Array.Empty<AdvisoryProvenance>());
return new AdvisoryStatementSnapshot(
Guid.NewGuid(),
vulnerabilityKey,
advisory.AdvisoryKey,
advisory,
StatementHash: ImmutableArray<byte>.Empty,
AsOf: asOf,
RecordedAt: asOf,
InputDocumentIds: ImmutableArray<Guid>.Empty);
}
private static AffectedPackage CreatePackage(
IEnumerable<AffectedPackageStatus> statuses,
string? platform = null,
IEnumerable<AffectedVersionRange>? versionRanges = null)
=> new(
type: "semver",
identifier: "pkg:npm/example",
platform: platform,
versionRanges: versionRanges,
statuses: statuses,
provenance: new[] { CreateProvenance("vendor.core") },
normalizedVersions: null);
private static AffectedPackageStatus CreateStatus(string status, string source)
=> new(
status,
CreateProvenance(source));
private static AdvisoryProvenance CreateProvenance(string source, string kind = "vendor")
=> new(
source,
kind,
value: string.Empty,
recordedAt: DateTimeOffset.Parse("2025-10-01T00:00:00Z"),
fieldMask: null,
decisionReason: null);
private sealed class FakeEventLog : IAdvisoryEventLog
{
private readonly AdvisoryReplay _replay;
public FakeEventLog(AdvisoryReplay replay)
{
_replay = replay;
}
public ValueTask AppendAsync(AdvisoryEventAppendRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException("Append operations are not required for tests.");
@@ -263,66 +263,66 @@ public sealed class NoisePriorServiceTests
CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}
private sealed class FakeNoisePriorRepository : INoisePriorRepository
{
private readonly List<NoisePriorSummary> _store = new();
public string? LastUpsertKey { get; private set; }
public IReadOnlyCollection<NoisePriorSummary>? LastUpsertSummaries { get; private set; }
public ValueTask UpsertAsync(
string vulnerabilityKey,
IReadOnlyCollection<NoisePriorSummary> summaries,
CancellationToken cancellationToken)
{
LastUpsertKey = vulnerabilityKey;
LastUpsertSummaries = summaries;
_store.RemoveAll(summary =>
string.Equals(summary.VulnerabilityKey, vulnerabilityKey, StringComparison.Ordinal));
_store.AddRange(summaries);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<NoisePriorSummary>> GetByVulnerabilityAsync(
string vulnerabilityKey,
CancellationToken cancellationToken)
{
var matches = _store
.Where(summary => string.Equals(summary.VulnerabilityKey, vulnerabilityKey, StringComparison.Ordinal))
.ToList();
return ValueTask.FromResult<IReadOnlyList<NoisePriorSummary>>(matches);
}
public ValueTask<IReadOnlyList<NoisePriorSummary>> GetByPackageAsync(
string packageType,
string packageIdentifier,
string? platform,
CancellationToken cancellationToken)
{
var matches = _store
.Where(summary =>
string.Equals(summary.PackageType, packageType, StringComparison.Ordinal) &&
string.Equals(summary.PackageIdentifier, packageIdentifier, StringComparison.Ordinal) &&
string.Equals(summary.Platform ?? string.Empty, platform ?? string.Empty, StringComparison.Ordinal))
.ToList();
return ValueTask.FromResult<IReadOnlyList<NoisePriorSummary>>(matches);
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now)
{
_now = now.ToUniversalTime();
}
public override DateTimeOffset GetUtcNow() => _now;
}
}
private sealed class FakeNoisePriorRepository : INoisePriorRepository
{
private readonly List<NoisePriorSummary> _store = new();
public string? LastUpsertKey { get; private set; }
public IReadOnlyCollection<NoisePriorSummary>? LastUpsertSummaries { get; private set; }
public ValueTask UpsertAsync(
string vulnerabilityKey,
IReadOnlyCollection<NoisePriorSummary> summaries,
CancellationToken cancellationToken)
{
LastUpsertKey = vulnerabilityKey;
LastUpsertSummaries = summaries;
_store.RemoveAll(summary =>
string.Equals(summary.VulnerabilityKey, vulnerabilityKey, StringComparison.Ordinal));
_store.AddRange(summaries);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<NoisePriorSummary>> GetByVulnerabilityAsync(
string vulnerabilityKey,
CancellationToken cancellationToken)
{
var matches = _store
.Where(summary => string.Equals(summary.VulnerabilityKey, vulnerabilityKey, StringComparison.Ordinal))
.ToList();
return ValueTask.FromResult<IReadOnlyList<NoisePriorSummary>>(matches);
}
public ValueTask<IReadOnlyList<NoisePriorSummary>> GetByPackageAsync(
string packageType,
string packageIdentifier,
string? platform,
CancellationToken cancellationToken)
{
var matches = _store
.Where(summary =>
string.Equals(summary.PackageType, packageType, StringComparison.Ordinal) &&
string.Equals(summary.PackageIdentifier, packageIdentifier, StringComparison.Ordinal) &&
string.Equals(summary.Platform ?? string.Empty, platform ?? string.Empty, StringComparison.Ordinal))
.ToList();
return ValueTask.FromResult<IReadOnlyList<NoisePriorSummary>>(matches);
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now)
{
_now = now.ToUniversalTime();
}
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -1,34 +1,34 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Nodes;
using System.Text.Json.Nodes;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Models.Observations;
using StellaOps.Concelier.RawModels;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Observations;
public sealed class AdvisoryObservationQueryServiceTests
{
private static readonly AdvisoryObservationSource DefaultSource = new("ghsa", "stream", "https://example.test/api");
private static readonly AdvisoryObservationSignature DefaultSignature = new(false, null, null, null);
[Fact]
public async Task QueryAsync_WhenNoFilters_ReturnsTenantObservationsSortedAndAggregated()
{
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:ghsa:alpha:1",
tenant: "Tenant-A",
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:npm/package-a@1.0.0" },
cpes: new[] { "cpe:/a:vendor:product:1.0" },
references: new[]
{
new AdvisoryObservationReference("advisory", "https://example.test/advisory-1")
},
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Observations;
public sealed class AdvisoryObservationQueryServiceTests
{
private static readonly AdvisoryObservationSource DefaultSource = new("ghsa", "stream", "https://example.test/api");
private static readonly AdvisoryObservationSignature DefaultSignature = new(false, null, null, null);
[Fact]
public async Task QueryAsync_WhenNoFilters_ReturnsTenantObservationsSortedAndAggregated()
{
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:ghsa:alpha:1",
tenant: "Tenant-A",
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:npm/package-a@1.0.0" },
cpes: new[] { "cpe:/a:vendor:product:1.0" },
references: new[]
{
new AdvisoryObservationReference("advisory", "https://example.test/advisory-1")
},
scopes: new[] { "runtime" },
relationships: new[]
{
@@ -53,26 +53,26 @@ public sealed class AdvisoryObservationQueryServiceTests
},
createdAt: DateTimeOffset.UtcNow)
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var result = await service.QueryAsync(new AdvisoryObservationQueryOptions("tenant-a"), CancellationToken.None);
Assert.Equal(2, result.Observations.Length);
Assert.Equal("tenant-a:osv:beta:1", result.Observations[0].ObservationId);
Assert.Equal("tenant-a:ghsa:alpha:1", result.Observations[1].ObservationId);
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var result = await service.QueryAsync(new AdvisoryObservationQueryOptions("tenant-a"), CancellationToken.None);
Assert.Equal(2, result.Observations.Length);
Assert.Equal("tenant-a:osv:beta:1", result.Observations[0].ObservationId);
Assert.Equal("tenant-a:ghsa:alpha:1", result.Observations[1].ObservationId);
Assert.Equal(
new[] { "CVE-2025-0001", "CVE-2025-0002", "GHSA-xyzz" },
result.Linkset.Aliases);
Assert.Equal(
new[] { "pkg:npm/package-a@1.0.0", "pkg:pypi/package-b@2.0.0" },
result.Linkset.Purls);
Assert.Equal(new[] { "cpe:/a:vendor:product:1.0" }, result.Linkset.Cpes);
Assert.Equal(
new[] { "pkg:npm/package-a@1.0.0", "pkg:pypi/package-b@2.0.0" },
result.Linkset.Purls);
Assert.Equal(new[] { "cpe:/a:vendor:product:1.0" }, result.Linkset.Cpes);
Assert.Equal(3, result.Linkset.References.Length);
Assert.Equal("advisory", result.Linkset.References[0].Type);
Assert.Equal("https://example.test/advisory-1", result.Linkset.References[0].Url);
@@ -89,166 +89,166 @@ public sealed class AdvisoryObservationQueryServiceTests
Assert.False(result.HasMore);
Assert.Null(result.NextCursor);
}
[Fact]
public async Task QueryAsync_WithAliasFilter_UsesAliasLookupAndFilters()
{
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:ghsa:alpha:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0001" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow),
CreateObservation(
observationId: "tenant-a:nvd:gamma:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-9999" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow.AddMinutes(-10))
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var result = await service.QueryAsync(
new AdvisoryObservationQueryOptions("TEnant-A", aliases: new[] { " CVE-2025-0001 ", "CVE-2025-9999" }),
CancellationToken.None);
Assert.Equal(2, result.Observations.Length);
[Fact]
public async Task QueryAsync_WithAliasFilter_UsesAliasLookupAndFilters()
{
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:ghsa:alpha:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0001" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow),
CreateObservation(
observationId: "tenant-a:nvd:gamma:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-9999" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow.AddMinutes(-10))
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var result = await service.QueryAsync(
new AdvisoryObservationQueryOptions("TEnant-A", aliases: new[] { " CVE-2025-0001 ", "CVE-2025-9999" }),
CancellationToken.None);
Assert.Equal(2, result.Observations.Length);
Assert.All(result.Observations, observation =>
Assert.Contains(
observation.Linkset.Aliases,
alias => alias.Equals("CVE-2025-0001", StringComparison.OrdinalIgnoreCase)
|| alias.Equals("CVE-2025-9999", StringComparison.OrdinalIgnoreCase)));
Assert.False(result.HasMore);
Assert.Null(result.NextCursor);
}
[Fact]
public async Task QueryAsync_WithObservationIdAndLinksetFilters_ReturnsIntersection()
{
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:ghsa:alpha:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:npm/package-a@1.0.0" },
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow),
CreateObservation(
observationId: "tenant-a:ghsa:beta:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:pypi/package-b@2.0.0" },
cpes: new[] { "cpe:/a:vendor:product:2.0" },
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow.AddMinutes(-1))
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var options = new AdvisoryObservationQueryOptions(
tenant: "tenant-a",
observationIds: new[] { "tenant-a:ghsa:beta:1" },
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:pypi/package-b@2.0.0" },
cpes: new[] { "cpe:/a:vendor:product:2.0" });
var result = await service.QueryAsync(options, CancellationToken.None);
Assert.Single(result.Observations);
Assert.Equal("tenant-a:ghsa:beta:1", result.Observations[0].ObservationId);
Assert.Equal(new[] { "pkg:pypi/package-b@2.0.0" }, result.Linkset.Purls);
Assert.Equal(new[] { "cpe:/a:vendor:product:2.0" }, result.Linkset.Cpes);
Assert.False(result.HasMore);
Assert.Null(result.NextCursor);
}
[Fact]
public async Task QueryAsync_WithLimitEmitsCursorForNextPage()
{
var now = DateTimeOffset.UtcNow;
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:source:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-2000" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: now),
CreateObservation(
observationId: "tenant-a:source:2",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-2001" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: now.AddMinutes(-1)),
CreateObservation(
observationId: "tenant-a:source:3",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-2002" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: now.AddMinutes(-2))
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var firstPage = await service.QueryAsync(
new AdvisoryObservationQueryOptions("tenant-a", limit: 2),
CancellationToken.None);
Assert.Equal(2, firstPage.Observations.Length);
Assert.True(firstPage.HasMore);
Assert.NotNull(firstPage.NextCursor);
var secondPage = await service.QueryAsync(
new AdvisoryObservationQueryOptions("tenant-a", limit: 2, cursor: firstPage.NextCursor),
CancellationToken.None);
Assert.Single(secondPage.Observations);
Assert.False(secondPage.HasMore);
Assert.Null(secondPage.NextCursor);
Assert.Equal("tenant-a:source:3", secondPage.Observations[0].ObservationId);
}
private static AdvisoryObservation CreateObservation(
string observationId,
string tenant,
IEnumerable<string> aliases,
IEnumerable<string> purls,
IEnumerable<string> cpes,
IEnumerable<AdvisoryObservationReference> references,
Assert.False(result.HasMore);
Assert.Null(result.NextCursor);
}
[Fact]
public async Task QueryAsync_WithObservationIdAndLinksetFilters_ReturnsIntersection()
{
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:ghsa:alpha:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:npm/package-a@1.0.0" },
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow),
CreateObservation(
observationId: "tenant-a:ghsa:beta:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:pypi/package-b@2.0.0" },
cpes: new[] { "cpe:/a:vendor:product:2.0" },
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow.AddMinutes(-1))
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var options = new AdvisoryObservationQueryOptions(
tenant: "tenant-a",
observationIds: new[] { "tenant-a:ghsa:beta:1" },
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:pypi/package-b@2.0.0" },
cpes: new[] { "cpe:/a:vendor:product:2.0" });
var result = await service.QueryAsync(options, CancellationToken.None);
Assert.Single(result.Observations);
Assert.Equal("tenant-a:ghsa:beta:1", result.Observations[0].ObservationId);
Assert.Equal(new[] { "pkg:pypi/package-b@2.0.0" }, result.Linkset.Purls);
Assert.Equal(new[] { "cpe:/a:vendor:product:2.0" }, result.Linkset.Cpes);
Assert.False(result.HasMore);
Assert.Null(result.NextCursor);
}
[Fact]
public async Task QueryAsync_WithLimitEmitsCursorForNextPage()
{
var now = DateTimeOffset.UtcNow;
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:source:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-2000" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: now),
CreateObservation(
observationId: "tenant-a:source:2",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-2001" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: now.AddMinutes(-1)),
CreateObservation(
observationId: "tenant-a:source:3",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-2002" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: now.AddMinutes(-2))
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var firstPage = await service.QueryAsync(
new AdvisoryObservationQueryOptions("tenant-a", limit: 2),
CancellationToken.None);
Assert.Equal(2, firstPage.Observations.Length);
Assert.True(firstPage.HasMore);
Assert.NotNull(firstPage.NextCursor);
var secondPage = await service.QueryAsync(
new AdvisoryObservationQueryOptions("tenant-a", limit: 2, cursor: firstPage.NextCursor),
CancellationToken.None);
Assert.Single(secondPage.Observations);
Assert.False(secondPage.HasMore);
Assert.Null(secondPage.NextCursor);
Assert.Equal("tenant-a:source:3", secondPage.Observations[0].ObservationId);
}
private static AdvisoryObservation CreateObservation(
string observationId,
string tenant,
IEnumerable<string> aliases,
IEnumerable<string> purls,
IEnumerable<string> cpes,
IEnumerable<AdvisoryObservationReference> references,
DateTimeOffset createdAt,
IEnumerable<string>? scopes = null,
IEnumerable<RawRelationship>? relationships = null)
{
var raw = JsonNode.Parse("""{"message":"payload"}""") ?? throw new InvalidOperationException("Raw payload must not be null.");
var upstream = new AdvisoryObservationUpstream(
upstreamId: observationId,
documentVersion: null,
fetchedAt: createdAt,
receivedAt: createdAt,
contentHash: $"sha256:{observationId}",
signature: DefaultSignature);
var upstream = new AdvisoryObservationUpstream(
upstreamId: observationId,
documentVersion: null,
fetchedAt: createdAt,
receivedAt: createdAt,
contentHash: $"sha256:{observationId}",
signature: DefaultSignature);
var content = new AdvisoryObservationContent("CSAF", "2.0", raw);
var linkset = new AdvisoryObservationLinkset(aliases, purls, cpes, references);
var rawLinkset = new RawLinkset
@@ -273,91 +273,91 @@ public sealed class AdvisoryObservationQueryServiceTests
rawLinkset,
createdAt);
}
private sealed class InMemoryLookup : IAdvisoryObservationLookup
{
private readonly ImmutableDictionary<string, ImmutableArray<AdvisoryObservation>> _observationsByTenant;
public InMemoryLookup(IEnumerable<AdvisoryObservation> observations)
{
ArgumentNullException.ThrowIfNull(observations);
_observationsByTenant = observations
.GroupBy(static observation => observation.Tenant, StringComparer.Ordinal)
.ToImmutableDictionary(
static group => group.Key,
static group => group.ToImmutableArray(),
StringComparer.Ordinal);
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
cancellationToken.ThrowIfCancellationRequested();
if (_observationsByTenant.TryGetValue(tenant, out var observations))
{
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(observations);
}
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(Array.Empty<AdvisoryObservation>());
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> FindByFiltersAsync(
string tenant,
IReadOnlyCollection<string> observationIds,
IReadOnlyCollection<string> aliases,
IReadOnlyCollection<string> purls,
IReadOnlyCollection<string> cpes,
AdvisoryObservationCursor? cursor,
int limit,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentNullException.ThrowIfNull(observationIds);
ArgumentNullException.ThrowIfNull(aliases);
ArgumentNullException.ThrowIfNull(purls);
ArgumentNullException.ThrowIfNull(cpes);
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit));
}
cancellationToken.ThrowIfCancellationRequested();
if (!_observationsByTenant.TryGetValue(tenant, out var observations))
{
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(Array.Empty<AdvisoryObservation>());
}
var observationIdSet = observationIds.ToImmutableHashSet(StringComparer.Ordinal);
private sealed class InMemoryLookup : IAdvisoryObservationLookup
{
private readonly ImmutableDictionary<string, ImmutableArray<AdvisoryObservation>> _observationsByTenant;
public InMemoryLookup(IEnumerable<AdvisoryObservation> observations)
{
ArgumentNullException.ThrowIfNull(observations);
_observationsByTenant = observations
.GroupBy(static observation => observation.Tenant, StringComparer.Ordinal)
.ToImmutableDictionary(
static group => group.Key,
static group => group.ToImmutableArray(),
StringComparer.Ordinal);
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
cancellationToken.ThrowIfCancellationRequested();
if (_observationsByTenant.TryGetValue(tenant, out var observations))
{
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(observations);
}
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(Array.Empty<AdvisoryObservation>());
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> FindByFiltersAsync(
string tenant,
IReadOnlyCollection<string> observationIds,
IReadOnlyCollection<string> aliases,
IReadOnlyCollection<string> purls,
IReadOnlyCollection<string> cpes,
AdvisoryObservationCursor? cursor,
int limit,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentNullException.ThrowIfNull(observationIds);
ArgumentNullException.ThrowIfNull(aliases);
ArgumentNullException.ThrowIfNull(purls);
ArgumentNullException.ThrowIfNull(cpes);
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit));
}
cancellationToken.ThrowIfCancellationRequested();
if (!_observationsByTenant.TryGetValue(tenant, out var observations))
{
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(Array.Empty<AdvisoryObservation>());
}
var observationIdSet = observationIds.ToImmutableHashSet(StringComparer.Ordinal);
var aliasSet = aliases.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
var purlSet = purls.ToImmutableHashSet(StringComparer.Ordinal);
var cpeSet = cpes.ToImmutableHashSet(StringComparer.Ordinal);
var filtered = observations
.Where(observation =>
(observationIdSet.Count == 0 || observationIdSet.Contains(observation.ObservationId)) &&
(aliasSet.Count == 0 || observation.Linkset.Aliases.Any(aliasSet.Contains)) &&
(purlSet.Count == 0 || observation.Linkset.Purls.Any(purlSet.Contains)) &&
(cpeSet.Count == 0 || observation.Linkset.Cpes.Any(cpeSet.Contains)));
if (cursor.HasValue)
{
var createdAt = cursor.Value.CreatedAt;
var observationId = cursor.Value.ObservationId;
filtered = filtered.Where(observation =>
observation.CreatedAt < createdAt
|| (observation.CreatedAt == createdAt && string.CompareOrdinal(observation.ObservationId, observationId) > 0));
}
var page = filtered
.OrderByDescending(static observation => observation.CreatedAt)
.ThenBy(static observation => observation.ObservationId, StringComparer.Ordinal)
.Take(limit)
.ToImmutableArray();
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(page);
}
}
}
var purlSet = purls.ToImmutableHashSet(StringComparer.Ordinal);
var cpeSet = cpes.ToImmutableHashSet(StringComparer.Ordinal);
var filtered = observations
.Where(observation =>
(observationIdSet.Count == 0 || observationIdSet.Contains(observation.ObservationId)) &&
(aliasSet.Count == 0 || observation.Linkset.Aliases.Any(aliasSet.Contains)) &&
(purlSet.Count == 0 || observation.Linkset.Purls.Any(purlSet.Contains)) &&
(cpeSet.Count == 0 || observation.Linkset.Cpes.Any(cpeSet.Contains)));
if (cursor.HasValue)
{
var createdAt = cursor.Value.CreatedAt;
var observationId = cursor.Value.ObservationId;
filtered = filtered.Where(observation =>
observation.CreatedAt < createdAt
|| (observation.CreatedAt == createdAt && string.CompareOrdinal(observation.ObservationId, observationId) > 0));
}
var page = filtered
.OrderByDescending(static observation => observation.CreatedAt)
.ThenBy(static observation => observation.ObservationId, StringComparer.Ordinal)
.Take(limit)
.ToImmutableArray();
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(page);
}
}
}

View File

@@ -1,42 +1,42 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.Core.Tests;
public sealed class TestPluginRoutine : IDependencyInjectionRoutine
{
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var builder = new JobSchedulerBuilder(services);
var timeoutSeconds = configuration.GetValue<int?>("plugin:test:timeoutSeconds") ?? 30;
builder.AddJob<PluginJob>(
PluginJob.JobKind,
cronExpression: "*/10 * * * *",
timeout: TimeSpan.FromSeconds(timeoutSeconds),
leaseDuration: TimeSpan.FromSeconds(5));
services.AddSingleton<PluginRoutineExecuted>();
return services;
}
}
public sealed class PluginRoutineExecuted
{
}
public sealed class PluginJob : IJob
{
public const string JobKind = "plugin:test";
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.Core.Tests;
public sealed class TestPluginRoutine : IDependencyInjectionRoutine
{
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var builder = new JobSchedulerBuilder(services);
var timeoutSeconds = configuration.GetValue<int?>("plugin:test:timeoutSeconds") ?? 30;
builder.AddJob<PluginJob>(
PluginJob.JobKind,
cronExpression: "*/10 * * * *",
timeout: TimeSpan.FromSeconds(timeoutSeconds),
leaseDuration: TimeSpan.FromSeconds(5));
services.AddSingleton<PluginRoutineExecuted>();
return services;
}
}
public sealed class PluginRoutineExecuted
{
}
public sealed class PluginJob : IJob
{
public const string JobKind = "plugin:test";
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
}

View File

@@ -10,39 +10,39 @@ using System.Threading.Tasks;
using StellaOps.Concelier.Exporter.Json;
using StellaOps.Concelier.Models;
using StellaOps.Cryptography;
namespace StellaOps.Concelier.Exporter.Json.Tests;
public sealed class JsonExportSnapshotBuilderTests : IDisposable
{
private readonly string _root;
public JsonExportSnapshotBuilderTests()
{
_root = Directory.CreateTempSubdirectory("concelier-json-export-tests").FullName;
}
[Fact]
public async Task WritesDeterministicTree()
{
var options = new JsonExportOptions { OutputRoot = _root };
var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
var exportedAt = DateTimeOffset.Parse("2024-07-15T12:00:00Z", CultureInfo.InvariantCulture);
var advisories = new[]
{
CreateAdvisory(
advisoryKey: "CVE-2024-9999",
aliases: new[] { "GHSA-zzzz-yyyy-xxxx", "CVE-2024-9999" },
title: "Deterministic Snapshot",
severity: "critical"),
CreateAdvisory(
advisoryKey: "VENDOR-2024-42",
aliases: new[] { "ALIAS-1", "ALIAS-2" },
title: "Vendor Advisory",
severity: "medium"),
};
namespace StellaOps.Concelier.Exporter.Json.Tests;
public sealed class JsonExportSnapshotBuilderTests : IDisposable
{
private readonly string _root;
public JsonExportSnapshotBuilderTests()
{
_root = Directory.CreateTempSubdirectory("concelier-json-export-tests").FullName;
}
[Fact]
public async Task WritesDeterministicTree()
{
var options = new JsonExportOptions { OutputRoot = _root };
var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
var exportedAt = DateTimeOffset.Parse("2024-07-15T12:00:00Z", CultureInfo.InvariantCulture);
var advisories = new[]
{
CreateAdvisory(
advisoryKey: "CVE-2024-9999",
aliases: new[] { "GHSA-zzzz-yyyy-xxxx", "CVE-2024-9999" },
title: "Deterministic Snapshot",
severity: "critical"),
CreateAdvisory(
advisoryKey: "VENDOR-2024-42",
aliases: new[] { "ALIAS-1", "ALIAS-2" },
title: "Vendor Advisory",
severity: "medium"),
};
var result = await builder.WriteAsync(advisories, exportedAt, cancellationToken: CancellationToken.None);
Assert.Equal(advisories.Length, result.AdvisoryCount);
@@ -51,49 +51,49 @@ public sealed class JsonExportSnapshotBuilderTests : IDisposable
advisories.Select(a => a.AdvisoryKey).OrderBy(key => key, StringComparer.Ordinal),
result.Advisories.Select(a => a.AdvisoryKey).OrderBy(key => key, StringComparer.Ordinal));
Assert.Equal(exportedAt, result.ExportedAt);
var expectedFiles = result.FilePaths.OrderBy(x => x, StringComparer.Ordinal).ToArray();
Assert.Contains("nvd/2024/CVE-2024-9999.json", expectedFiles);
Assert.Contains("misc/VENDOR-2024-42.json", expectedFiles);
var cvePath = ResolvePath(result.ExportDirectory, "nvd/2024/CVE-2024-9999.json");
Assert.True(File.Exists(cvePath));
var actualJson = await File.ReadAllTextAsync(cvePath, CancellationToken.None);
Assert.Equal(SnapshotSerializer.ToSnapshot(advisories[0]), actualJson);
}
[Fact]
public async Task ProducesIdenticalBytesAcrossRuns()
{
var options = new JsonExportOptions { OutputRoot = _root };
var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
var exportedAt = DateTimeOffset.Parse("2024-05-01T00:00:00Z", CultureInfo.InvariantCulture);
var advisories = new[]
{
CreateAdvisory("CVE-2024-1000", new[] { "CVE-2024-1000", "GHSA-aaaa-bbbb-cccc" }, "Snapshot Stable", "high"),
};
var first = await builder.WriteAsync(advisories, exportedAt, exportName: "20240501T000000Z", CancellationToken.None);
var firstDigest = ComputeDigest(first);
var second = await builder.WriteAsync(advisories, exportedAt, exportName: "20240501T000000Z", CancellationToken.None);
var secondDigest = ComputeDigest(second);
Assert.Equal(Convert.ToHexString(firstDigest), Convert.ToHexString(secondDigest));
}
[Fact]
var expectedFiles = result.FilePaths.OrderBy(x => x, StringComparer.Ordinal).ToArray();
Assert.Contains("nvd/2024/CVE-2024-9999.json", expectedFiles);
Assert.Contains("misc/VENDOR-2024-42.json", expectedFiles);
var cvePath = ResolvePath(result.ExportDirectory, "nvd/2024/CVE-2024-9999.json");
Assert.True(File.Exists(cvePath));
var actualJson = await File.ReadAllTextAsync(cvePath, CancellationToken.None);
Assert.Equal(SnapshotSerializer.ToSnapshot(advisories[0]), actualJson);
}
[Fact]
public async Task ProducesIdenticalBytesAcrossRuns()
{
var options = new JsonExportOptions { OutputRoot = _root };
var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
var exportedAt = DateTimeOffset.Parse("2024-05-01T00:00:00Z", CultureInfo.InvariantCulture);
var advisories = new[]
{
CreateAdvisory("CVE-2024-1000", new[] { "CVE-2024-1000", "GHSA-aaaa-bbbb-cccc" }, "Snapshot Stable", "high"),
};
var first = await builder.WriteAsync(advisories, exportedAt, exportName: "20240501T000000Z", CancellationToken.None);
var firstDigest = ComputeDigest(first);
var second = await builder.WriteAsync(advisories, exportedAt, exportName: "20240501T000000Z", CancellationToken.None);
var secondDigest = ComputeDigest(second);
Assert.Equal(Convert.ToHexString(firstDigest), Convert.ToHexString(secondDigest));
}
[Fact]
public async Task WriteAsync_NormalizesInputOrdering()
{
var options = new JsonExportOptions { OutputRoot = _root };
var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
var exportedAt = DateTimeOffset.Parse("2024-06-01T00:00:00Z", CultureInfo.InvariantCulture);
var advisoryA = CreateAdvisory("CVE-2024-1000", new[] { "CVE-2024-1000" }, "Alpha", "high");
var advisoryB = CreateAdvisory("VENDOR-0001", new[] { "VENDOR-0001" }, "Vendor Advisory", "medium");
var result = await builder.WriteAsync(new[] { advisoryB, advisoryA }, exportedAt, cancellationToken: CancellationToken.None);
var advisoryA = CreateAdvisory("CVE-2024-1000", new[] { "CVE-2024-1000" }, "Alpha", "high");
var advisoryB = CreateAdvisory("VENDOR-0001", new[] { "VENDOR-0001" }, "Vendor Advisory", "medium");
var result = await builder.WriteAsync(new[] { advisoryB, advisoryA }, exportedAt, cancellationToken: CancellationToken.None);
var expectedOrder = result.FilePaths.OrderBy(path => path, StringComparer.Ordinal).ToArray();
Assert.Equal(expectedOrder, result.FilePaths.ToArray());
}
@@ -129,54 +129,54 @@ public sealed class JsonExportSnapshotBuilderTests : IDisposable
{
var options = new JsonExportOptions { OutputRoot = _root };
var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
var exportedAt = DateTimeOffset.Parse("2024-08-01T00:00:00Z", CultureInfo.InvariantCulture);
var advisories = new[]
{
CreateAdvisory("CVE-2024-2000", new[] { "CVE-2024-2000" }, "Streaming One", "medium"),
CreateAdvisory("CVE-2024-2001", new[] { "CVE-2024-2001" }, "Streaming Two", "low"),
};
var sequence = new SingleEnumerationAsyncSequence(advisories);
var exportedAt = DateTimeOffset.Parse("2024-08-01T00:00:00Z", CultureInfo.InvariantCulture);
var advisories = new[]
{
CreateAdvisory("CVE-2024-2000", new[] { "CVE-2024-2000" }, "Streaming One", "medium"),
CreateAdvisory("CVE-2024-2001", new[] { "CVE-2024-2001" }, "Streaming Two", "low"),
};
var sequence = new SingleEnumerationAsyncSequence(advisories);
var result = await builder.WriteAsync(sequence, exportedAt, cancellationToken: CancellationToken.None);
Assert.Equal(advisories.Length, result.AdvisoryCount);
Assert.Equal(advisories.Length, result.Advisories.Length);
}
private static Advisory CreateAdvisory(string advisoryKey, string[] aliases, string title, string severity)
{
return new Advisory(
advisoryKey: advisoryKey,
title: title,
summary: null,
language: "EN",
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z", CultureInfo.InvariantCulture),
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z", CultureInfo.InvariantCulture),
severity: severity,
exploitKnown: false,
aliases: aliases,
references: new[]
{
new AdvisoryReference("https://example.com/advisory", "advisory", null, null, AdvisoryProvenance.Empty),
},
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"sample/package",
platform: null,
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: Array.Empty<AdvisoryProvenance>()),
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[]
{
new AdvisoryProvenance("concelier", "normalized", "canonical", DateTimeOffset.Parse("2024-01-02T00:00:00Z", CultureInfo.InvariantCulture)),
});
}
private static Advisory CreateAdvisory(string advisoryKey, string[] aliases, string title, string severity)
{
return new Advisory(
advisoryKey: advisoryKey,
title: title,
summary: null,
language: "EN",
published: DateTimeOffset.Parse("2024-01-01T00:00:00Z", CultureInfo.InvariantCulture),
modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z", CultureInfo.InvariantCulture),
severity: severity,
exploitKnown: false,
aliases: aliases,
references: new[]
{
new AdvisoryReference("https://example.com/advisory", "advisory", null, null, AdvisoryProvenance.Empty),
},
affectedPackages: new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"sample/package",
platform: null,
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: Array.Empty<AdvisoryProvenance>()),
},
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: new[]
{
new AdvisoryProvenance("concelier", "normalized", "canonical", DateTimeOffset.Parse("2024-01-02T00:00:00Z", CultureInfo.InvariantCulture)),
});
}
private static byte[] ComputeDigest(JsonExportResult result)
{
var hash = CryptoHashFactory.CreateDefault();
@@ -191,56 +191,56 @@ public sealed class JsonExportSnapshotBuilderTests : IDisposable
return hash.ComputeHash(buffer.WrittenSpan, HashAlgorithms.Sha256);
}
private static string ResolvePath(string root, string relative)
{
var segments = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
return Path.Combine(new[] { root }.Concat(segments).ToArray());
}
public void Dispose()
{
try
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
catch
{
// best effort cleanup
}
}
private sealed class SingleEnumerationAsyncSequence : IAsyncEnumerable<Advisory>
{
private readonly IReadOnlyList<Advisory> _advisories;
private int _enumerated;
public SingleEnumerationAsyncSequence(IReadOnlyList<Advisory> advisories)
{
_advisories = advisories ?? throw new ArgumentNullException(nameof(advisories));
}
public IAsyncEnumerator<Advisory> GetAsyncEnumerator(CancellationToken cancellationToken = default)
{
if (Interlocked.Exchange(ref _enumerated, 1) == 1)
{
throw new InvalidOperationException("Sequence was enumerated more than once.");
}
return Enumerate(cancellationToken);
async IAsyncEnumerator<Advisory> Enumerate([EnumeratorCancellation] CancellationToken ct)
{
foreach (var advisory in _advisories)
{
ct.ThrowIfCancellationRequested();
yield return advisory;
await Task.Yield();
}
}
}
}
}
private static string ResolvePath(string root, string relative)
{
var segments = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
return Path.Combine(new[] { root }.Concat(segments).ToArray());
}
public void Dispose()
{
try
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
catch
{
// best effort cleanup
}
}
private sealed class SingleEnumerationAsyncSequence : IAsyncEnumerable<Advisory>
{
private readonly IReadOnlyList<Advisory> _advisories;
private int _enumerated;
public SingleEnumerationAsyncSequence(IReadOnlyList<Advisory> advisories)
{
_advisories = advisories ?? throw new ArgumentNullException(nameof(advisories));
}
public IAsyncEnumerator<Advisory> GetAsyncEnumerator(CancellationToken cancellationToken = default)
{
if (Interlocked.Exchange(ref _enumerated, 1) == 1)
{
throw new InvalidOperationException("Sequence was enumerated more than once.");
}
return Enumerate(cancellationToken);
async IAsyncEnumerator<Advisory> Enumerate([EnumeratorCancellation] CancellationToken ct)
{
foreach (var advisory in _advisories)
{
ct.ThrowIfCancellationRequested();
yield return advisory;
await Task.Yield();
}
}
}
}
}

View File

@@ -14,7 +14,7 @@ using StellaOps.Concelier.Storage.Exporting;
using StellaOps.Concelier.Models;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Provenance.Mongo;
using StellaOps.Provenance;
namespace StellaOps.Concelier.Exporter.Json.Tests;

View File

@@ -1,182 +1,182 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Concelier.Exporter.Json;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Exporter.Json.Tests;
public sealed class JsonExporterParitySmokeTests : IDisposable
{
private readonly string _root;
public JsonExporterParitySmokeTests()
{
_root = Directory.CreateTempSubdirectory("concelier-json-parity-tests").FullName;
}
[Fact]
public async Task ExportProducesVulnListCompatiblePaths()
{
var options = new JsonExportOptions { OutputRoot = _root };
var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
var exportedAt = DateTimeOffset.Parse("2024-09-01T12:00:00Z", CultureInfo.InvariantCulture);
var advisories = CreateSampleAdvisories();
var result = await builder.WriteAsync(advisories, exportedAt, exportName: "parity-test", CancellationToken.None);
var expected = new[]
{
"amazon/2/ALAS2-2024-1234.json",
"debian/DLA-2024-1234.json",
"ghsa/go/github.com%2Facme%2Fsample/GHSA-AAAA-BBBB-CCCC.json",
"nvd/2023/CVE-2023-27524.json",
"oracle/linux/ELSA-2024-12345.json",
"redhat/oval/RHSA-2024_0252.json",
"ubuntu/USN-6620-1.json",
"wolfi/WOLFI-2024-0001.json",
};
Assert.Equal(expected, result.FilePaths.ToArray());
foreach (var path in expected)
{
var fullPath = ResolvePath(result.ExportDirectory, path);
Assert.True(File.Exists(fullPath), $"Expected export file '{path}' to be present");
}
}
private static IReadOnlyList<Advisory> CreateSampleAdvisories()
{
var published = DateTimeOffset.Parse("2024-01-01T00:00:00Z", CultureInfo.InvariantCulture);
var modified = DateTimeOffset.Parse("2024-02-01T00:00:00Z", CultureInfo.InvariantCulture);
return new[]
{
CreateAdvisory(
"CVE-2023-27524",
"Apache Superset Improper Authentication",
new[] { "CVE-2023-27524" },
null,
"nvd",
published,
modified),
CreateAdvisory(
"GHSA-aaaa-bbbb-cccc",
"Sample GHSA",
new[] { "CVE-2024-2000" },
new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:go/github.com/acme/sample@1.0.0",
provenance: new[] { new AdvisoryProvenance("ghsa", "map", "", published) })
},
"ghsa",
published,
modified),
CreateAdvisory(
"USN-6620-1",
"Ubuntu Security Notice",
null,
null,
"ubuntu",
published,
modified),
CreateAdvisory(
"DLA-2024-1234",
"Debian LTS Advisory",
null,
null,
"debian",
published,
modified),
CreateAdvisory(
"RHSA-2024:0252",
"Red Hat Security Advisory",
null,
null,
"redhat",
published,
modified),
CreateAdvisory(
"ALAS2-2024-1234",
"Amazon Linux Advisory",
null,
null,
"amazon",
published,
modified),
CreateAdvisory(
"ELSA-2024-12345",
"Oracle Linux Advisory",
null,
null,
"oracle",
published,
modified),
CreateAdvisory(
"WOLFI-2024-0001",
"Wolfi Advisory",
null,
null,
"wolfi",
published,
modified),
};
}
private static Advisory CreateAdvisory(
string advisoryKey,
string title,
IEnumerable<string>? aliases,
IEnumerable<AffectedPackage>? packages,
string? provenanceSource,
DateTimeOffset? published,
DateTimeOffset? modified)
{
var provenance = provenanceSource is null
? Array.Empty<AdvisoryProvenance>()
: new[] { new AdvisoryProvenance(provenanceSource, "normalize", "", modified ?? DateTimeOffset.UtcNow) };
return new Advisory(
advisoryKey,
title,
summary: null,
language: "en",
published,
modified,
severity: "medium",
exploitKnown: false,
aliases: aliases ?? Array.Empty<string>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: provenance);
}
private static string ResolvePath(string root, string relative)
{
var segments = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
return Path.Combine(new[] { root }.Concat(segments).ToArray());
}
public void Dispose()
{
try
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
catch
{
// best effort cleanup
}
}
}
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Concelier.Exporter.Json;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Exporter.Json.Tests;
public sealed class JsonExporterParitySmokeTests : IDisposable
{
private readonly string _root;
public JsonExporterParitySmokeTests()
{
_root = Directory.CreateTempSubdirectory("concelier-json-parity-tests").FullName;
}
[Fact]
public async Task ExportProducesVulnListCompatiblePaths()
{
var options = new JsonExportOptions { OutputRoot = _root };
var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver());
var exportedAt = DateTimeOffset.Parse("2024-09-01T12:00:00Z", CultureInfo.InvariantCulture);
var advisories = CreateSampleAdvisories();
var result = await builder.WriteAsync(advisories, exportedAt, exportName: "parity-test", CancellationToken.None);
var expected = new[]
{
"amazon/2/ALAS2-2024-1234.json",
"debian/DLA-2024-1234.json",
"ghsa/go/github.com%2Facme%2Fsample/GHSA-AAAA-BBBB-CCCC.json",
"nvd/2023/CVE-2023-27524.json",
"oracle/linux/ELSA-2024-12345.json",
"redhat/oval/RHSA-2024_0252.json",
"ubuntu/USN-6620-1.json",
"wolfi/WOLFI-2024-0001.json",
};
Assert.Equal(expected, result.FilePaths.ToArray());
foreach (var path in expected)
{
var fullPath = ResolvePath(result.ExportDirectory, path);
Assert.True(File.Exists(fullPath), $"Expected export file '{path}' to be present");
}
}
private static IReadOnlyList<Advisory> CreateSampleAdvisories()
{
var published = DateTimeOffset.Parse("2024-01-01T00:00:00Z", CultureInfo.InvariantCulture);
var modified = DateTimeOffset.Parse("2024-02-01T00:00:00Z", CultureInfo.InvariantCulture);
return new[]
{
CreateAdvisory(
"CVE-2023-27524",
"Apache Superset Improper Authentication",
new[] { "CVE-2023-27524" },
null,
"nvd",
published,
modified),
CreateAdvisory(
"GHSA-aaaa-bbbb-cccc",
"Sample GHSA",
new[] { "CVE-2024-2000" },
new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:go/github.com/acme/sample@1.0.0",
provenance: new[] { new AdvisoryProvenance("ghsa", "map", "", published) })
},
"ghsa",
published,
modified),
CreateAdvisory(
"USN-6620-1",
"Ubuntu Security Notice",
null,
null,
"ubuntu",
published,
modified),
CreateAdvisory(
"DLA-2024-1234",
"Debian LTS Advisory",
null,
null,
"debian",
published,
modified),
CreateAdvisory(
"RHSA-2024:0252",
"Red Hat Security Advisory",
null,
null,
"redhat",
published,
modified),
CreateAdvisory(
"ALAS2-2024-1234",
"Amazon Linux Advisory",
null,
null,
"amazon",
published,
modified),
CreateAdvisory(
"ELSA-2024-12345",
"Oracle Linux Advisory",
null,
null,
"oracle",
published,
modified),
CreateAdvisory(
"WOLFI-2024-0001",
"Wolfi Advisory",
null,
null,
"wolfi",
published,
modified),
};
}
private static Advisory CreateAdvisory(
string advisoryKey,
string title,
IEnumerable<string>? aliases,
IEnumerable<AffectedPackage>? packages,
string? provenanceSource,
DateTimeOffset? published,
DateTimeOffset? modified)
{
var provenance = provenanceSource is null
? Array.Empty<AdvisoryProvenance>()
: new[] { new AdvisoryProvenance(provenanceSource, "normalize", "", modified ?? DateTimeOffset.UtcNow) };
return new Advisory(
advisoryKey,
title,
summary: null,
language: "en",
published,
modified,
severity: "medium",
exploitKnown: false,
aliases: aliases ?? Array.Empty<string>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: provenance);
}
private static string ResolvePath(string root, string relative)
{
var segments = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
return Path.Combine(new[] { root }.Concat(segments).ToArray());
}
public void Dispose()
{
try
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
catch
{
// best effort cleanup
}
}
}

View File

@@ -18,7 +18,7 @@ using StellaOps.Concelier.Exporter.Json;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage.Exporting;
using StellaOps.Provenance.Mongo;
using StellaOps.Provenance;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;

View File

@@ -1,109 +1,109 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using StellaOps.Concelier.Exporter.Json;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Exporter.Json.Tests;
public sealed class VulnListJsonExportPathResolverTests
{
private static readonly DateTimeOffset DefaultPublished = DateTimeOffset.Parse("2024-01-01T00:00:00Z", CultureInfo.InvariantCulture);
[Fact]
public void ResolvesCvePath()
{
var advisory = CreateAdvisory("CVE-2024-1234");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("nvd", "2024", "CVE-2024-1234.json"), path);
}
[Fact]
public void ResolvesGhsaWithPackage()
{
var package = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:go/github.com/acme/widget@1.0.0",
platform: null,
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: Array.Empty<AdvisoryProvenance>());
var advisory = CreateAdvisory(
"GHSA-aaaa-bbbb-cccc",
aliases: new[] { "CVE-2024-2000" },
packages: new[] { package });
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("ghsa", "go", "github.com%2Facme%2Fwidget", "GHSA-AAAA-BBBB-CCCC.json"), path);
}
[Fact]
public void ResolvesUbuntuUsn()
{
var advisory = CreateAdvisory("USN-6620-1");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("ubuntu", "USN-6620-1.json"), path);
}
[Fact]
public void ResolvesDebianDla()
{
var advisory = CreateAdvisory("DLA-1234-1");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("debian", "DLA-1234-1.json"), path);
}
[Fact]
public void ResolvesRedHatRhsa()
{
var advisory = CreateAdvisory("RHSA-2024:0252");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("redhat", "oval", "RHSA-2024_0252.json"), path);
}
[Fact]
public void ResolvesAmazonAlas()
{
var advisory = CreateAdvisory("ALAS2-2024-1234");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("amazon", "2", "ALAS2-2024-1234.json"), path);
}
[Fact]
public void ResolvesOracleElsa()
{
var advisory = CreateAdvisory("ELSA-2024-12345");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("oracle", "linux", "ELSA-2024-12345.json"), path);
}
[Fact]
public void ResolvesRockyRlsa()
{
var advisory = CreateAdvisory("RLSA-2024:0417");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("rocky", "RLSA-2024_0417.json"), path);
}
[Fact]
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using StellaOps.Concelier.Exporter.Json;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Exporter.Json.Tests;
public sealed class VulnListJsonExportPathResolverTests
{
private static readonly DateTimeOffset DefaultPublished = DateTimeOffset.Parse("2024-01-01T00:00:00Z", CultureInfo.InvariantCulture);
[Fact]
public void ResolvesCvePath()
{
var advisory = CreateAdvisory("CVE-2024-1234");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("nvd", "2024", "CVE-2024-1234.json"), path);
}
[Fact]
public void ResolvesGhsaWithPackage()
{
var package = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:go/github.com/acme/widget@1.0.0",
platform: null,
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: Array.Empty<AdvisoryProvenance>());
var advisory = CreateAdvisory(
"GHSA-aaaa-bbbb-cccc",
aliases: new[] { "CVE-2024-2000" },
packages: new[] { package });
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("ghsa", "go", "github.com%2Facme%2Fwidget", "GHSA-AAAA-BBBB-CCCC.json"), path);
}
[Fact]
public void ResolvesUbuntuUsn()
{
var advisory = CreateAdvisory("USN-6620-1");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("ubuntu", "USN-6620-1.json"), path);
}
[Fact]
public void ResolvesDebianDla()
{
var advisory = CreateAdvisory("DLA-1234-1");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("debian", "DLA-1234-1.json"), path);
}
[Fact]
public void ResolvesRedHatRhsa()
{
var advisory = CreateAdvisory("RHSA-2024:0252");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("redhat", "oval", "RHSA-2024_0252.json"), path);
}
[Fact]
public void ResolvesAmazonAlas()
{
var advisory = CreateAdvisory("ALAS2-2024-1234");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("amazon", "2", "ALAS2-2024-1234.json"), path);
}
[Fact]
public void ResolvesOracleElsa()
{
var advisory = CreateAdvisory("ELSA-2024-12345");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("oracle", "linux", "ELSA-2024-12345.json"), path);
}
[Fact]
public void ResolvesRockyRlsa()
{
var advisory = CreateAdvisory("RLSA-2024:0417");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("rocky", "RLSA-2024_0417.json"), path);
}
[Fact]
public void ResolvesByProvenanceFallback()
{
var provenance = new[] { new AdvisoryProvenance("wolfi", "map", "", DefaultPublished) };
@@ -130,30 +130,30 @@ public sealed class VulnListJsonExportPathResolverTests
{
var advisory = CreateAdvisory("CUSTOM-2024-99");
var resolver = new VulnListJsonExportPathResolver();
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("misc", "CUSTOM-2024-99.json"), path);
}
private static Advisory CreateAdvisory(
string advisoryKey,
IEnumerable<string>? aliases = null,
IEnumerable<AffectedPackage>? packages = null,
IEnumerable<AdvisoryProvenance>? provenance = null)
{
return new Advisory(
advisoryKey: advisoryKey,
title: $"Advisory {advisoryKey}",
summary: null,
language: "en",
published: DefaultPublished,
modified: DefaultPublished,
severity: "medium",
exploitKnown: false,
aliases: aliases ?? Array.Empty<string>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: provenance ?? Array.Empty<AdvisoryProvenance>());
}
}
var path = resolver.GetRelativePath(advisory);
Assert.Equal(Path.Combine("misc", "CUSTOM-2024-99.json"), path);
}
private static Advisory CreateAdvisory(
string advisoryKey,
IEnumerable<string>? aliases = null,
IEnumerable<AffectedPackage>? packages = null,
IEnumerable<AdvisoryProvenance>? provenance = null)
{
return new Advisory(
advisoryKey: advisoryKey,
title: $"Advisory {advisoryKey}",
summary: null,
language: "en",
published: DefaultPublished,
modified: DefaultPublished,
severity: "medium",
exploitKnown: false,
aliases: aliases ?? Array.Empty<string>(),
references: Array.Empty<AdvisoryReference>(),
affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: provenance ?? Array.Empty<AdvisoryProvenance>());
}
}

View File

@@ -1,86 +1,86 @@
using System;
using StellaOps.Concelier.Exporter.TrivyDb;
using StellaOps.Concelier.Storage.Exporting;
namespace StellaOps.Concelier.Exporter.TrivyDb.Tests;
public sealed class TrivyDbExportPlannerTests
{
[Fact]
public void CreatePlan_ReturnsFullWhenStateMissing()
{
var planner = new TrivyDbExportPlanner();
var manifest = new[] { new ExportFileRecord("path.json", 10, "sha256:a") };
var plan = planner.CreatePlan(existingState: null, treeDigest: "sha256:abcd", manifest);
Assert.Equal(TrivyDbExportMode.Full, plan.Mode);
Assert.Equal("sha256:abcd", plan.TreeDigest);
Assert.Null(plan.BaseExportId);
Assert.Null(plan.BaseManifestDigest);
Assert.True(plan.ResetBaseline);
Assert.Equal(manifest, plan.Manifest);
}
[Fact]
public void CreatePlan_ReturnsSkipWhenCursorMatches()
{
var planner = new TrivyDbExportPlanner();
var existingManifest = new[] { new ExportFileRecord("path.json", 10, "sha256:a") };
var state = new ExportStateRecord(
Id: TrivyDbFeedExporter.ExporterId,
BaseExportId: "20240810T000000Z",
BaseDigest: "sha256:base",
LastFullDigest: "sha256:base",
LastDeltaDigest: null,
ExportCursor: "sha256:unchanged",
TargetRepository: "concelier/trivy",
ExporterVersion: "1.0",
UpdatedAt: DateTimeOffset.UtcNow,
Files: existingManifest);
var plan = planner.CreatePlan(state, "sha256:unchanged", existingManifest);
Assert.Equal(TrivyDbExportMode.Skip, plan.Mode);
Assert.Equal("sha256:unchanged", plan.TreeDigest);
Assert.Equal("20240810T000000Z", plan.BaseExportId);
Assert.Equal("sha256:base", plan.BaseManifestDigest);
Assert.False(plan.ResetBaseline);
Assert.Empty(plan.ChangedFiles);
Assert.Empty(plan.RemovedPaths);
}
[Fact]
public void CreatePlan_ReturnsFullWhenCursorDiffers()
{
var planner = new TrivyDbExportPlanner();
var manifest = new[] { new ExportFileRecord("path.json", 10, "sha256:a") };
var state = new ExportStateRecord(
Id: TrivyDbFeedExporter.ExporterId,
BaseExportId: "20240810T000000Z",
BaseDigest: "sha256:base",
LastFullDigest: "sha256:base",
LastDeltaDigest: null,
ExportCursor: "sha256:old",
TargetRepository: "concelier/trivy",
ExporterVersion: "1.0",
UpdatedAt: DateTimeOffset.UtcNow,
Files: manifest);
var newManifest = new[] { new ExportFileRecord("path.json", 10, "sha256:b") };
var plan = planner.CreatePlan(state, "sha256:new", newManifest);
Assert.Equal(TrivyDbExportMode.Delta, plan.Mode);
Assert.Equal("sha256:new", plan.TreeDigest);
Assert.Equal("20240810T000000Z", plan.BaseExportId);
Assert.Equal("sha256:base", plan.BaseManifestDigest);
Assert.False(plan.ResetBaseline);
Assert.Single(plan.ChangedFiles);
var deltaState = state with { LastDeltaDigest = "sha256:delta" };
var deltaPlan = planner.CreatePlan(deltaState, "sha256:newer", newManifest);
Assert.Equal(TrivyDbExportMode.Full, deltaPlan.Mode);
Assert.True(deltaPlan.ResetBaseline);
Assert.Equal(deltaPlan.Manifest, deltaPlan.ChangedFiles);
}
}
using System;
using StellaOps.Concelier.Exporter.TrivyDb;
using StellaOps.Concelier.Storage.Exporting;
namespace StellaOps.Concelier.Exporter.TrivyDb.Tests;
public sealed class TrivyDbExportPlannerTests
{
[Fact]
public void CreatePlan_ReturnsFullWhenStateMissing()
{
var planner = new TrivyDbExportPlanner();
var manifest = new[] { new ExportFileRecord("path.json", 10, "sha256:a") };
var plan = planner.CreatePlan(existingState: null, treeDigest: "sha256:abcd", manifest);
Assert.Equal(TrivyDbExportMode.Full, plan.Mode);
Assert.Equal("sha256:abcd", plan.TreeDigest);
Assert.Null(plan.BaseExportId);
Assert.Null(plan.BaseManifestDigest);
Assert.True(plan.ResetBaseline);
Assert.Equal(manifest, plan.Manifest);
}
[Fact]
public void CreatePlan_ReturnsSkipWhenCursorMatches()
{
var planner = new TrivyDbExportPlanner();
var existingManifest = new[] { new ExportFileRecord("path.json", 10, "sha256:a") };
var state = new ExportStateRecord(
Id: TrivyDbFeedExporter.ExporterId,
BaseExportId: "20240810T000000Z",
BaseDigest: "sha256:base",
LastFullDigest: "sha256:base",
LastDeltaDigest: null,
ExportCursor: "sha256:unchanged",
TargetRepository: "concelier/trivy",
ExporterVersion: "1.0",
UpdatedAt: DateTimeOffset.UtcNow,
Files: existingManifest);
var plan = planner.CreatePlan(state, "sha256:unchanged", existingManifest);
Assert.Equal(TrivyDbExportMode.Skip, plan.Mode);
Assert.Equal("sha256:unchanged", plan.TreeDigest);
Assert.Equal("20240810T000000Z", plan.BaseExportId);
Assert.Equal("sha256:base", plan.BaseManifestDigest);
Assert.False(plan.ResetBaseline);
Assert.Empty(plan.ChangedFiles);
Assert.Empty(plan.RemovedPaths);
}
[Fact]
public void CreatePlan_ReturnsFullWhenCursorDiffers()
{
var planner = new TrivyDbExportPlanner();
var manifest = new[] { new ExportFileRecord("path.json", 10, "sha256:a") };
var state = new ExportStateRecord(
Id: TrivyDbFeedExporter.ExporterId,
BaseExportId: "20240810T000000Z",
BaseDigest: "sha256:base",
LastFullDigest: "sha256:base",
LastDeltaDigest: null,
ExportCursor: "sha256:old",
TargetRepository: "concelier/trivy",
ExporterVersion: "1.0",
UpdatedAt: DateTimeOffset.UtcNow,
Files: manifest);
var newManifest = new[] { new ExportFileRecord("path.json", 10, "sha256:b") };
var plan = planner.CreatePlan(state, "sha256:new", newManifest);
Assert.Equal(TrivyDbExportMode.Delta, plan.Mode);
Assert.Equal("sha256:new", plan.TreeDigest);
Assert.Equal("20240810T000000Z", plan.BaseExportId);
Assert.Equal("sha256:base", plan.BaseManifestDigest);
Assert.False(plan.ResetBaseline);
Assert.Single(plan.ChangedFiles);
var deltaState = state with { LastDeltaDigest = "sha256:delta" };
var deltaPlan = planner.CreatePlan(deltaState, "sha256:newer", newManifest);
Assert.Equal(TrivyDbExportMode.Full, deltaPlan.Mode);
Assert.True(deltaPlan.ResetBaseline);
Assert.Equal(deltaPlan.Manifest, deltaPlan.ChangedFiles);
}
}

View File

@@ -1,149 +1,149 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Concelier.Storage.Exporting;
using Xunit;
namespace StellaOps.Concelier.Exporter.TrivyDb.Tests;
public sealed class TrivyDbOciWriterTests : IDisposable
{
private readonly string _root;
public TrivyDbOciWriterTests()
{
_root = Directory.CreateTempSubdirectory("trivy-writer-tests").FullName;
}
[Fact]
public async Task WriteAsync_ReusesBlobsFromBaseLayout_WhenDigestMatches()
{
var baseLayout = Path.Combine(_root, "base");
Directory.CreateDirectory(Path.Combine(baseLayout, "blobs", "sha256"));
var configBytes = Encoding.UTF8.GetBytes("base-config");
var configDigest = ComputeDigest(configBytes);
WriteBlob(baseLayout, configDigest, configBytes);
var layerBytes = Encoding.UTF8.GetBytes("base-layer");
var layerDigest = ComputeDigest(layerBytes);
WriteBlob(baseLayout, layerDigest, layerBytes);
var manifest = CreateManifest(configDigest, layerDigest);
var manifestBytes = SerializeManifest(manifest);
var manifestDigest = ComputeDigest(manifestBytes);
WriteBlob(baseLayout, manifestDigest, manifestBytes);
var plan = new TrivyDbExportPlan(
TrivyDbExportMode.Delta,
TreeDigest: "sha256:tree",
BaseExportId: "20241101T000000Z",
BaseManifestDigest: manifestDigest,
ResetBaseline: false,
Manifest: Array.Empty<ExportFileRecord>(),
ChangedFiles: new[] { new ExportFileRecord("data.json", 1, "sha256:data") },
RemovedPaths: Array.Empty<string>());
var configDescriptor = new OciDescriptor(TrivyDbMediaTypes.TrivyConfig, configDigest, configBytes.Length);
var layerDescriptor = new OciDescriptor(TrivyDbMediaTypes.TrivyLayer, layerDigest, layerBytes.Length);
var package = new TrivyDbPackage(
manifest,
new TrivyConfigDocument(
TrivyDbMediaTypes.TrivyConfig,
DateTimeOffset.Parse("2024-11-01T00:00:00Z"),
"20241101T000000Z",
layerDigest,
layerBytes.Length),
new Dictionary<string, TrivyDbBlob>(StringComparer.Ordinal)
{
[configDigest] = CreateThrowingBlob(),
[layerDigest] = CreateThrowingBlob(),
},
JsonSerializer.SerializeToUtf8Bytes(new { mode = "delta" }));
var writer = new TrivyDbOciWriter();
var destination = Path.Combine(_root, "delta");
await writer.WriteAsync(package, destination, reference: "example/trivy:delta", plan, baseLayout, CancellationToken.None);
var reusedConfig = File.ReadAllBytes(GetBlobPath(destination, configDigest));
Assert.Equal(configBytes, reusedConfig);
var reusedLayer = File.ReadAllBytes(GetBlobPath(destination, layerDigest));
Assert.Equal(layerBytes, reusedLayer);
}
private static TrivyDbBlob CreateThrowingBlob()
{
var ctor = typeof(TrivyDbBlob).GetConstructor(
BindingFlags.NonPublic | BindingFlags.Instance,
binder: null,
new[] { typeof(Func<CancellationToken, ValueTask<Stream>>), typeof(long) },
modifiers: null)
?? throw new InvalidOperationException("Unable to access TrivyDbBlob constructor.");
Func<CancellationToken, ValueTask<Stream>> factory = _ => throw new InvalidOperationException("Blob should have been reused from base layout.");
return (TrivyDbBlob)ctor.Invoke(new object[] { factory, 0L });
}
private static OciManifest CreateManifest(string configDigest, string layerDigest)
{
var configDescriptor = new OciDescriptor(TrivyDbMediaTypes.TrivyConfig, configDigest, 0);
var layerDescriptor = new OciDescriptor(TrivyDbMediaTypes.TrivyLayer, layerDigest, 0);
return new OciManifest(
SchemaVersion: 2,
MediaType: TrivyDbMediaTypes.OciManifest,
Config: configDescriptor,
Layers: new[] { layerDescriptor });
}
private static byte[] SerializeManifest(OciManifest manifest)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
};
return JsonSerializer.SerializeToUtf8Bytes(manifest, options);
}
private static void WriteBlob(string layoutRoot, string digest, byte[] payload)
{
var path = GetBlobPath(layoutRoot, digest);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, payload);
}
private static string GetBlobPath(string layoutRoot, string digest)
{
var fileName = digest[7..];
return Path.Combine(layoutRoot, "blobs", "sha256", fileName);
}
private static string ComputeDigest(byte[] payload)
{
var hash = SHA256.HashData(payload);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
public void Dispose()
{
try
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
catch
{
// best effort cleanup
}
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Concelier.Storage.Exporting;
using Xunit;
namespace StellaOps.Concelier.Exporter.TrivyDb.Tests;
public sealed class TrivyDbOciWriterTests : IDisposable
{
private readonly string _root;
public TrivyDbOciWriterTests()
{
_root = Directory.CreateTempSubdirectory("trivy-writer-tests").FullName;
}
[Fact]
public async Task WriteAsync_ReusesBlobsFromBaseLayout_WhenDigestMatches()
{
var baseLayout = Path.Combine(_root, "base");
Directory.CreateDirectory(Path.Combine(baseLayout, "blobs", "sha256"));
var configBytes = Encoding.UTF8.GetBytes("base-config");
var configDigest = ComputeDigest(configBytes);
WriteBlob(baseLayout, configDigest, configBytes);
var layerBytes = Encoding.UTF8.GetBytes("base-layer");
var layerDigest = ComputeDigest(layerBytes);
WriteBlob(baseLayout, layerDigest, layerBytes);
var manifest = CreateManifest(configDigest, layerDigest);
var manifestBytes = SerializeManifest(manifest);
var manifestDigest = ComputeDigest(manifestBytes);
WriteBlob(baseLayout, manifestDigest, manifestBytes);
var plan = new TrivyDbExportPlan(
TrivyDbExportMode.Delta,
TreeDigest: "sha256:tree",
BaseExportId: "20241101T000000Z",
BaseManifestDigest: manifestDigest,
ResetBaseline: false,
Manifest: Array.Empty<ExportFileRecord>(),
ChangedFiles: new[] { new ExportFileRecord("data.json", 1, "sha256:data") },
RemovedPaths: Array.Empty<string>());
var configDescriptor = new OciDescriptor(TrivyDbMediaTypes.TrivyConfig, configDigest, configBytes.Length);
var layerDescriptor = new OciDescriptor(TrivyDbMediaTypes.TrivyLayer, layerDigest, layerBytes.Length);
var package = new TrivyDbPackage(
manifest,
new TrivyConfigDocument(
TrivyDbMediaTypes.TrivyConfig,
DateTimeOffset.Parse("2024-11-01T00:00:00Z"),
"20241101T000000Z",
layerDigest,
layerBytes.Length),
new Dictionary<string, TrivyDbBlob>(StringComparer.Ordinal)
{
[configDigest] = CreateThrowingBlob(),
[layerDigest] = CreateThrowingBlob(),
},
JsonSerializer.SerializeToUtf8Bytes(new { mode = "delta" }));
var writer = new TrivyDbOciWriter();
var destination = Path.Combine(_root, "delta");
await writer.WriteAsync(package, destination, reference: "example/trivy:delta", plan, baseLayout, CancellationToken.None);
var reusedConfig = File.ReadAllBytes(GetBlobPath(destination, configDigest));
Assert.Equal(configBytes, reusedConfig);
var reusedLayer = File.ReadAllBytes(GetBlobPath(destination, layerDigest));
Assert.Equal(layerBytes, reusedLayer);
}
private static TrivyDbBlob CreateThrowingBlob()
{
var ctor = typeof(TrivyDbBlob).GetConstructor(
BindingFlags.NonPublic | BindingFlags.Instance,
binder: null,
new[] { typeof(Func<CancellationToken, ValueTask<Stream>>), typeof(long) },
modifiers: null)
?? throw new InvalidOperationException("Unable to access TrivyDbBlob constructor.");
Func<CancellationToken, ValueTask<Stream>> factory = _ => throw new InvalidOperationException("Blob should have been reused from base layout.");
return (TrivyDbBlob)ctor.Invoke(new object[] { factory, 0L });
}
private static OciManifest CreateManifest(string configDigest, string layerDigest)
{
var configDescriptor = new OciDescriptor(TrivyDbMediaTypes.TrivyConfig, configDigest, 0);
var layerDescriptor = new OciDescriptor(TrivyDbMediaTypes.TrivyLayer, layerDigest, 0);
return new OciManifest(
SchemaVersion: 2,
MediaType: TrivyDbMediaTypes.OciManifest,
Config: configDescriptor,
Layers: new[] { layerDescriptor });
}
private static byte[] SerializeManifest(OciManifest manifest)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
};
return JsonSerializer.SerializeToUtf8Bytes(manifest, options);
}
private static void WriteBlob(string layoutRoot, string digest, byte[] payload)
{
var path = GetBlobPath(layoutRoot, digest);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, payload);
}
private static string GetBlobPath(string layoutRoot, string digest)
{
var fileName = digest[7..];
return Path.Combine(layoutRoot, "blobs", "sha256", fileName);
}
private static string ComputeDigest(byte[] payload)
{
var hash = SHA256.HashData(payload);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
public void Dispose()
{
try
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
catch
{
// best effort cleanup
}
}
}

View File

@@ -1,93 +1,93 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Concelier.Exporter.TrivyDb;
namespace StellaOps.Concelier.Exporter.TrivyDb.Tests;
public sealed class TrivyDbPackageBuilderTests
{
[Fact]
public void BuildsOciManifestWithExpectedMediaTypes()
{
var metadata = Encoding.UTF8.GetBytes("{\"generatedAt\":\"2024-07-15T12:00:00Z\"}");
var archive = Enumerable.Range(0, 256).Select(static b => (byte)b).ToArray();
var archivePath = Path.GetTempFileName();
File.WriteAllBytes(archivePath, archive);
var archiveDigest = ComputeDigest(archive);
try
{
var request = new TrivyDbPackageRequest(
metadata,
archivePath,
archiveDigest,
archive.LongLength,
DateTimeOffset.Parse("2024-07-15T12:00:00Z"),
"2024.07.15");
var builder = new TrivyDbPackageBuilder();
var package = builder.BuildPackage(request);
Assert.Equal(TrivyDbMediaTypes.OciManifest, package.Manifest.MediaType);
Assert.Equal(TrivyDbMediaTypes.TrivyConfig, package.Manifest.Config.MediaType);
var layer = Assert.Single(package.Manifest.Layers);
Assert.Equal(TrivyDbMediaTypes.TrivyLayer, layer.MediaType);
var configBytes = JsonSerializer.SerializeToUtf8Bytes(package.Config, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var expectedConfigDigest = ComputeDigest(configBytes);
Assert.Equal(expectedConfigDigest, package.Manifest.Config.Digest);
Assert.Equal(archiveDigest, layer.Digest);
Assert.True(package.Blobs.ContainsKey(archiveDigest));
Assert.Equal(archive.LongLength, package.Blobs[archiveDigest].Length);
Assert.True(package.Blobs.ContainsKey(expectedConfigDigest));
Assert.Equal(metadata, package.MetadataJson.ToArray());
}
finally
{
if (File.Exists(archivePath))
{
File.Delete(archivePath);
}
}
}
[Fact]
public void ThrowsWhenMetadataMissing()
{
var builder = new TrivyDbPackageBuilder();
var archivePath = Path.GetTempFileName();
var archiveBytes = new byte[] { 1, 2, 3 };
File.WriteAllBytes(archivePath, archiveBytes);
var digest = ComputeDigest(archiveBytes);
try
{
Assert.Throws<ArgumentException>(() => builder.BuildPackage(new TrivyDbPackageRequest(
ReadOnlyMemory<byte>.Empty,
archivePath,
digest,
archiveBytes.LongLength,
DateTimeOffset.UtcNow,
"1")));
}
finally
{
if (File.Exists(archivePath))
{
File.Delete(archivePath);
}
}
}
private static string ComputeDigest(ReadOnlySpan<byte> payload)
{
var hash = SHA256.HashData(payload);
var hex = Convert.ToHexString(hash);
return "sha256:" + hex.ToLowerInvariant();
}
}
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Concelier.Exporter.TrivyDb;
namespace StellaOps.Concelier.Exporter.TrivyDb.Tests;
public sealed class TrivyDbPackageBuilderTests
{
[Fact]
public void BuildsOciManifestWithExpectedMediaTypes()
{
var metadata = Encoding.UTF8.GetBytes("{\"generatedAt\":\"2024-07-15T12:00:00Z\"}");
var archive = Enumerable.Range(0, 256).Select(static b => (byte)b).ToArray();
var archivePath = Path.GetTempFileName();
File.WriteAllBytes(archivePath, archive);
var archiveDigest = ComputeDigest(archive);
try
{
var request = new TrivyDbPackageRequest(
metadata,
archivePath,
archiveDigest,
archive.LongLength,
DateTimeOffset.Parse("2024-07-15T12:00:00Z"),
"2024.07.15");
var builder = new TrivyDbPackageBuilder();
var package = builder.BuildPackage(request);
Assert.Equal(TrivyDbMediaTypes.OciManifest, package.Manifest.MediaType);
Assert.Equal(TrivyDbMediaTypes.TrivyConfig, package.Manifest.Config.MediaType);
var layer = Assert.Single(package.Manifest.Layers);
Assert.Equal(TrivyDbMediaTypes.TrivyLayer, layer.MediaType);
var configBytes = JsonSerializer.SerializeToUtf8Bytes(package.Config, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var expectedConfigDigest = ComputeDigest(configBytes);
Assert.Equal(expectedConfigDigest, package.Manifest.Config.Digest);
Assert.Equal(archiveDigest, layer.Digest);
Assert.True(package.Blobs.ContainsKey(archiveDigest));
Assert.Equal(archive.LongLength, package.Blobs[archiveDigest].Length);
Assert.True(package.Blobs.ContainsKey(expectedConfigDigest));
Assert.Equal(metadata, package.MetadataJson.ToArray());
}
finally
{
if (File.Exists(archivePath))
{
File.Delete(archivePath);
}
}
}
[Fact]
public void ThrowsWhenMetadataMissing()
{
var builder = new TrivyDbPackageBuilder();
var archivePath = Path.GetTempFileName();
var archiveBytes = new byte[] { 1, 2, 3 };
File.WriteAllBytes(archivePath, archiveBytes);
var digest = ComputeDigest(archiveBytes);
try
{
Assert.Throws<ArgumentException>(() => builder.BuildPackage(new TrivyDbPackageRequest(
ReadOnlyMemory<byte>.Empty,
archivePath,
digest,
archiveBytes.LongLength,
DateTimeOffset.UtcNow,
"1")));
}
finally
{
if (File.Exists(archivePath))
{
File.Delete(archivePath);
}
}
}
private static string ComputeDigest(ReadOnlySpan<byte> payload)
{
var hash = SHA256.HashData(payload);
var hex = Convert.ToHexString(hash);
return "sha256:" + hex.ToLowerInvariant();
}
}

View File

@@ -1,92 +1,92 @@
using System;
using System.Linq;
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class AdvisoryIdentityResolverTests
{
private readonly AdvisoryIdentityResolver _resolver = new();
[Fact]
public void Resolve_GroupsBySharedCveAlias()
{
var nvd = CreateAdvisory("CVE-2025-1234", aliases: new[] { "CVE-2025-1234" }, source: "nvd");
var vendor = CreateAdvisory("VSA-2025-01", aliases: new[] { "CVE-2025-1234", "VSA-2025-01" }, source: "vendor");
var clusters = _resolver.Resolve(new[] { nvd, vendor });
var cluster = Assert.Single(clusters);
Assert.Equal("CVE-2025-1234", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "CVE-2025-1234"));
}
[Fact]
public void Resolve_PrefersPsirtAliasWhenNoCve()
{
var vendor = CreateAdvisory("VMSA-2025-0001", aliases: new[] { "VMSA-2025-0001" }, source: "vmware");
var osv = CreateAdvisory("OSV-2025-1", aliases: new[] { "OSV-2025-1", "GHSA-xxxx-yyyy-zzzz", "VMSA-2025-0001" }, source: "osv");
var clusters = _resolver.Resolve(new[] { vendor, osv });
var cluster = Assert.Single(clusters);
Assert.Equal("VMSA-2025-0001", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "VMSA-2025-0001"));
}
[Fact]
public void Resolve_FallsBackToGhsaWhenOnlyGhsaPresent()
{
var ghsa = CreateAdvisory("GHSA-aaaa-bbbb-cccc", aliases: new[] { "GHSA-aaaa-bbbb-cccc" }, source: "ghsa");
var osv = CreateAdvisory("OSV-2025-99", aliases: new[] { "OSV-2025-99", "GHSA-aaaa-bbbb-cccc" }, source: "osv");
var clusters = _resolver.Resolve(new[] { ghsa, osv });
var cluster = Assert.Single(clusters);
Assert.Equal("GHSA-aaaa-bbbb-cccc", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "GHSA-aaaa-bbbb-cccc"));
}
[Fact]
public void Resolve_GroupsByKeyWhenNoAliases()
{
var first = CreateAdvisory("custom-1", aliases: Array.Empty<string>(), source: "source-a");
var second = CreateAdvisory("custom-1", aliases: Array.Empty<string>(), source: "source-b");
var clusters = _resolver.Resolve(new[] { first, second });
var cluster = Assert.Single(clusters);
Assert.Equal("custom-1", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.Contains(cluster.Aliases, alias => alias.Value == "custom-1");
}
private static Advisory CreateAdvisory(string key, string[] aliases, string source)
{
var provenance = new[]
{
new AdvisoryProvenance(source, "mapping", key, DateTimeOffset.UtcNow),
};
return new Advisory(
key,
$"{key} title",
$"{key} summary",
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
null,
exploitKnown: false,
aliases,
Array.Empty<AdvisoryCredit>(),
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
provenance);
}
}
using System;
using System.Linq;
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Merge.Tests;
public sealed class AdvisoryIdentityResolverTests
{
private readonly AdvisoryIdentityResolver _resolver = new();
[Fact]
public void Resolve_GroupsBySharedCveAlias()
{
var nvd = CreateAdvisory("CVE-2025-1234", aliases: new[] { "CVE-2025-1234" }, source: "nvd");
var vendor = CreateAdvisory("VSA-2025-01", aliases: new[] { "CVE-2025-1234", "VSA-2025-01" }, source: "vendor");
var clusters = _resolver.Resolve(new[] { nvd, vendor });
var cluster = Assert.Single(clusters);
Assert.Equal("CVE-2025-1234", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "CVE-2025-1234"));
}
[Fact]
public void Resolve_PrefersPsirtAliasWhenNoCve()
{
var vendor = CreateAdvisory("VMSA-2025-0001", aliases: new[] { "VMSA-2025-0001" }, source: "vmware");
var osv = CreateAdvisory("OSV-2025-1", aliases: new[] { "OSV-2025-1", "GHSA-xxxx-yyyy-zzzz", "VMSA-2025-0001" }, source: "osv");
var clusters = _resolver.Resolve(new[] { vendor, osv });
var cluster = Assert.Single(clusters);
Assert.Equal("VMSA-2025-0001", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "VMSA-2025-0001"));
}
[Fact]
public void Resolve_FallsBackToGhsaWhenOnlyGhsaPresent()
{
var ghsa = CreateAdvisory("GHSA-aaaa-bbbb-cccc", aliases: new[] { "GHSA-aaaa-bbbb-cccc" }, source: "ghsa");
var osv = CreateAdvisory("OSV-2025-99", aliases: new[] { "OSV-2025-99", "GHSA-aaaa-bbbb-cccc" }, source: "osv");
var clusters = _resolver.Resolve(new[] { ghsa, osv });
var cluster = Assert.Single(clusters);
Assert.Equal("GHSA-aaaa-bbbb-cccc", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.True(cluster.Aliases.Any(alias => alias.Value == "GHSA-aaaa-bbbb-cccc"));
}
[Fact]
public void Resolve_GroupsByKeyWhenNoAliases()
{
var first = CreateAdvisory("custom-1", aliases: Array.Empty<string>(), source: "source-a");
var second = CreateAdvisory("custom-1", aliases: Array.Empty<string>(), source: "source-b");
var clusters = _resolver.Resolve(new[] { first, second });
var cluster = Assert.Single(clusters);
Assert.Equal("custom-1", cluster.AdvisoryKey);
Assert.Equal(2, cluster.Advisories.Length);
Assert.Contains(cluster.Aliases, alias => alias.Value == "custom-1");
}
private static Advisory CreateAdvisory(string key, string[] aliases, string source)
{
var provenance = new[]
{
new AdvisoryProvenance(source, "mapping", key, DateTimeOffset.UtcNow),
};
return new Advisory(
key,
$"{key} title",
$"{key} summary",
"en",
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
null,
exploitKnown: false,
aliases,
Array.Empty<AdvisoryCredit>(),
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
provenance);
}
}

View File

@@ -11,7 +11,7 @@ using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Advisories;
using StellaOps.Concelier.Storage.Aliases;
using StellaOps.Concelier.Storage.MergeEvents;
using StellaOps.Provenance.Mongo;
using StellaOps.Provenance;
namespace StellaOps.Concelier.Merge.Tests;

Some files were not shown because too many files have changed in this diff Show More