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
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:
@@ -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>
|
||||
""";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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))!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}";
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user