Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
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.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Connector.Acsc;
|
||||
using StellaOps.Concelier.Connector.Acsc.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Acsc.Tests.Acsc;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class AcscConnectorFetchTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly Uri BaseEndpoint = new("https://origin.example/");
|
||||
private static readonly Uri RelayEndpoint = new("https://relay.example/");
|
||||
private static readonly Uri AlertsDirectUri = new(BaseEndpoint, "/feeds/alerts/rss");
|
||||
private static readonly Uri AlertsRelayUri = new(RelayEndpoint, "/feeds/alerts/rss");
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
|
||||
public AcscConnectorFetchTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_DirectSuccessAdvancesCursor()
|
||||
{
|
||||
await using var provider = await BuildProviderAsync(preferRelay: false);
|
||||
|
||||
var connector = provider.GetRequiredService<AcscConnector>();
|
||||
SeedRssResponse(AlertsDirectUri, "direct", DateTimeOffset.Parse("2025-10-10T02:15:00Z"), DateTimeOffset.Parse("2025-10-11T05:30:00Z"));
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_handler.AssertNoPendingResponses();
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
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;
|
||||
Assert.Single(pendingDocuments);
|
||||
|
||||
var documentStore = provider.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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_DirectFailureFallsBackToRelay()
|
||||
{
|
||||
await using var provider = await BuildProviderAsync(preferRelay: false);
|
||||
|
||||
var connector = provider.GetRequiredService<AcscConnector>();
|
||||
_handler.AddException(HttpMethod.Get, AlertsDirectUri, new HttpRequestException("HTTP/2 reset"));
|
||||
SeedRssResponse(AlertsRelayUri, "relay", DateTimeOffset.Parse("2025-10-09T10:00:00Z"), DateTimeOffset.Parse("2025-10-11T00:00:00Z"));
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_handler.AssertNoPendingResponses();
|
||||
|
||||
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
|
||||
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;
|
||||
Assert.Single(pendingDocuments);
|
||||
|
||||
var documentStore = provider.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.True(metadata.TryGetValue("acsc.fetch.mode", out var mode));
|
||||
Assert.Equal("relay", mode);
|
||||
|
||||
Assert.Collection(_handler.Requests,
|
||||
request =>
|
||||
{
|
||||
Assert.Equal(HttpMethod.Get, request.Method);
|
||||
Assert.Equal(AlertsDirectUri, request.Uri);
|
||||
},
|
||||
request =>
|
||||
{
|
||||
Assert.Equal(HttpMethod.Get, request.Method);
|
||||
Assert.Equal(AlertsRelayUri, request.Uri);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task InitializeAsync() => await Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private async Task<ServiceProvider> BuildProviderAsync(bool preferRelay)
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
_handler.Clear();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddAcscConnector(options =>
|
||||
{
|
||||
options.BaseEndpoint = BaseEndpoint;
|
||||
options.RelayEndpoint = RelayEndpoint;
|
||||
options.EnableRelayFallback = true;
|
||||
options.PreferRelayByDefault = preferRelay;
|
||||
options.ForceRelay = false;
|
||||
options.RequestTimeout = TimeSpan.FromSeconds(10);
|
||||
options.Feeds.Clear();
|
||||
options.Feeds.Add(new AcscFeedOptions
|
||||
{
|
||||
Slug = "alerts",
|
||||
RelativePath = "/feeds/alerts/rss",
|
||||
Enabled = true,
|
||||
});
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(AcscOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
|
||||
});
|
||||
services.Configure<SourceHttpClientOptions>(AcscOptions.HttpClientName, options =>
|
||||
{
|
||||
options.MaxAttempts = 1;
|
||||
options.BaseDelay = TimeSpan.Zero;
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedRssResponse(Uri uri, string mode, DateTimeOffset first, DateTimeOffset second)
|
||||
{
|
||||
var payload = CreateRssPayload(first, second);
|
||||
_handler.AddResponse(HttpMethod.Get, uri, _ =>
|
||||
{
|
||||
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>
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
using System.Linq;
|
||||
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 MongoDB.Bson;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Connector.Acsc;
|
||||
using StellaOps.Concelier.Connector.Acsc.Configuration;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Http;
|
||||
using StellaOps.Concelier.Connector.Common.Testing;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||
using StellaOps.Concelier.Storage.Mongo.Dtos;
|
||||
using StellaOps.Concelier.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Acsc.Tests.Acsc;
|
||||
|
||||
[Collection("mongo-fixture")]
|
||||
public sealed class AcscConnectorParseTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly Uri BaseEndpoint = new("https://origin.example/");
|
||||
|
||||
private readonly MongoIntegrationFixture _fixture;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly CannedHttpMessageHandler _handler;
|
||||
|
||||
public AcscConnectorParseTests(MongoIntegrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
|
||||
_handler = new CannedHttpMessageHandler();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParseAsync_PersistsDtoAndAdvancesCursor()
|
||||
{
|
||||
await using var provider = await BuildProviderAsync();
|
||||
var connector = provider.GetRequiredService<AcscConnector>();
|
||||
|
||||
var feedUri = new Uri(BaseEndpoint, "/feeds/alerts/rss");
|
||||
SeedRssResponse(feedUri);
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
_handler.AssertNoPendingResponses();
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var document = await documentStore.FindBySourceAndUriAsync(AcscConnectorPlugin.SourceName, feedUri.ToString(), CancellationToken.None);
|
||||
Assert.NotNull(document);
|
||||
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
|
||||
var refreshed = await documentStore.FindAsync(document!.Id, CancellationToken.None);
|
||||
Assert.NotNull(refreshed);
|
||||
Assert.Equal(DocumentStatuses.PendingMap, refreshed!.Status);
|
||||
|
||||
var dtoStore = provider.GetRequiredService<IDtoStore>();
|
||||
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);
|
||||
Assert.Equal("alerts", payload.GetValue("feedSlug").AsString);
|
||||
Assert.Single(payload.GetValue("entries").AsBsonArray);
|
||||
|
||||
var stateRepository = provider.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));
|
||||
|
||||
await connector.MapAsync(provider, CancellationToken.None);
|
||||
|
||||
var advisoriesStore = provider.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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapAsync_MultiEntryFeedProducesExpectedSnapshot()
|
||||
{
|
||||
await using var provider = await BuildProviderAsync(options =>
|
||||
{
|
||||
options.Feeds.Clear();
|
||||
options.Feeds.Add(new AcscFeedOptions
|
||||
{
|
||||
Slug = "multi",
|
||||
RelativePath = "/feeds/multi/rss",
|
||||
Enabled = true,
|
||||
});
|
||||
});
|
||||
var connector = provider.GetRequiredService<AcscConnector>();
|
||||
|
||||
var feedUri = new Uri(BaseEndpoint, "/feeds/multi/rss");
|
||||
SeedMultiEntryResponse(feedUri);
|
||||
|
||||
await connector.FetchAsync(provider, CancellationToken.None);
|
||||
await connector.ParseAsync(provider, CancellationToken.None);
|
||||
|
||||
var documentStore = provider.GetRequiredService<IDocumentStore>();
|
||||
var dtoStore = provider.GetRequiredService<IDtoStore>();
|
||||
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.Equal("Critical", fields.GetValue("severity").AsString);
|
||||
Assert.Equal("ExampleCo Router X, ExampleCo Router Y", fields.GetValue("systemsAffected").AsString);
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
private async Task<ServiceProvider> BuildProviderAsync(Action<AcscOptions>? configure = null)
|
||||
{
|
||||
await _fixture.Client.DropDatabaseAsync(_fixture.Database.DatabaseNamespace.DatabaseName);
|
||||
_handler.Clear();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
|
||||
services.AddMongoStorage(options =>
|
||||
{
|
||||
options.ConnectionString = _fixture.Runner.ConnectionString;
|
||||
options.DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName;
|
||||
options.CommandTimeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
services.AddSourceCommon();
|
||||
services.AddAcscConnector(options =>
|
||||
{
|
||||
options.BaseEndpoint = BaseEndpoint;
|
||||
options.RelayEndpoint = null;
|
||||
options.PreferRelayByDefault = false;
|
||||
options.ForceRelay = false;
|
||||
options.EnableRelayFallback = false;
|
||||
options.RequestTimeout = TimeSpan.FromSeconds(10);
|
||||
options.Feeds.Clear();
|
||||
options.Feeds.Add(new AcscFeedOptions
|
||||
{
|
||||
Slug = "alerts",
|
||||
RelativePath = "/feeds/alerts/rss",
|
||||
Enabled = true,
|
||||
});
|
||||
configure?.Invoke(options);
|
||||
});
|
||||
|
||||
services.Configure<HttpClientFactoryOptions>(AcscOptions.HttpClientName, builderOptions =>
|
||||
{
|
||||
builderOptions.HttpMessageHandlerBuilderActions.Add(builder => builder.PrimaryHandler = _handler);
|
||||
});
|
||||
|
||||
services.Configure<SourceHttpClientOptions>(AcscOptions.HttpClientName, options =>
|
||||
{
|
||||
options.MaxAttempts = 1;
|
||||
options.BaseDelay = TimeSpan.Zero;
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<MongoBootstrapper>();
|
||||
await bootstrapper.InitializeAsync(CancellationToken.None);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private void SeedRssResponse(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>
|
||||
</rss>
|
||||
""";
|
||||
|
||||
_handler.AddResponse(HttpMethod.Get, uri, () =>
|
||||
{
|
||||
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);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private void SeedMultiEntryResponse(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 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>
|
||||
""";
|
||||
|
||||
_handler.AddResponse(HttpMethod.Get, uri, () =>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
[
|
||||
{
|
||||
"advisoryKey": "acsc/multi/https-origin-example-advisories-info-bulletin",
|
||||
"affectedPackages": [],
|
||||
"aliases": [
|
||||
"ACSC-2025-011",
|
||||
"Bulletin",
|
||||
"https://origin.example/advisories/info-bulletin"
|
||||
],
|
||||
"credits": [],
|
||||
"cvssMetrics": [],
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": null,
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "document",
|
||||
"value": "https://origin.example/feeds/multi/rss",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages",
|
||||
"aliases",
|
||||
"references",
|
||||
"summary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "feed",
|
||||
"value": "multi",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"summary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "mapping",
|
||||
"value": "https://origin.example/advisories/info-bulletin",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages",
|
||||
"aliases",
|
||||
"references",
|
||||
"summary"
|
||||
]
|
||||
}
|
||||
],
|
||||
"published": "2025-10-12T02:30:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "acsc",
|
||||
"kind": "reference",
|
||||
"value": "https://origin.example/advisories/info-bulletin",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "multi",
|
||||
"summary": "Information bulletin",
|
||||
"url": "https://origin.example/advisories/info-bulletin"
|
||||
}
|
||||
],
|
||||
"severity": null,
|
||||
"summary": "Serial number: ACSC-2025-011\n\nAdvisory type: Bulletin\n\nGeneral guidance bulletin.",
|
||||
"title": "Information bulletin"
|
||||
},
|
||||
{
|
||||
"advisoryKey": "acsc/multi/https-origin-example-advisories-router-critical",
|
||||
"affectedPackages": [
|
||||
{
|
||||
"type": "vendor",
|
||||
"identifier": "ExampleCo Router X",
|
||||
"platform": null,
|
||||
"versionRanges": [],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "affected",
|
||||
"value": "ExampleCo Router X",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vendor",
|
||||
"identifier": "ExampleCo Router Y",
|
||||
"platform": null,
|
||||
"versionRanges": [],
|
||||
"normalizedVersions": [],
|
||||
"statuses": [],
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "affected",
|
||||
"value": "ExampleCo Router Y",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"ACSC-2025-010",
|
||||
"CVE-2025-0001",
|
||||
"https://origin.example/advisories/router-critical"
|
||||
],
|
||||
"credits": [],
|
||||
"cvssMetrics": [],
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": null,
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "document",
|
||||
"value": "https://origin.example/feeds/multi/rss",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages",
|
||||
"aliases",
|
||||
"references",
|
||||
"summary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "feed",
|
||||
"value": "multi",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"summary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "mapping",
|
||||
"value": "https://origin.example/advisories/router-critical",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages",
|
||||
"aliases",
|
||||
"references",
|
||||
"summary"
|
||||
]
|
||||
}
|
||||
],
|
||||
"published": "2025-10-12T04:45:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "acsc",
|
||||
"kind": "reference",
|
||||
"value": "https://origin.example/advisories/router-critical",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "multi",
|
||||
"summary": "Critical router vulnerability",
|
||||
"url": "https://origin.example/advisories/router-critical"
|
||||
},
|
||||
{
|
||||
"kind": "reference",
|
||||
"provenance": {
|
||||
"source": "acsc",
|
||||
"kind": "reference",
|
||||
"value": "https://vendor.example/router/patch",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": "vendor patch",
|
||||
"url": "https://vendor.example/router/patch"
|
||||
}
|
||||
],
|
||||
"severity": "critical",
|
||||
"summary": "Serial number: ACSC-2025-010\n\nSeverity: Critical\n\nSystems affected: ExampleCo Router X, ExampleCo Router Y\n\nRemote code execution on ExampleCo routers. See vendor patch.\n\nCVE references: CVE-2025-0001",
|
||||
"title": "Critical router vulnerability"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
[
|
||||
{
|
||||
"advisoryKey": "acsc/alerts/https-origin-example-advisories-example",
|
||||
"affectedPackages": [],
|
||||
"aliases": [
|
||||
"ACSC-2025-001",
|
||||
"Alert",
|
||||
"https://origin.example/advisories/example"
|
||||
],
|
||||
"credits": [],
|
||||
"cvssMetrics": [],
|
||||
"exploitKnown": false,
|
||||
"language": "en",
|
||||
"modified": null,
|
||||
"provenance": [
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "document",
|
||||
"value": "https://origin.example/feeds/alerts/rss",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages",
|
||||
"aliases",
|
||||
"references",
|
||||
"summary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "feed",
|
||||
"value": "alerts",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"summary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "acsc",
|
||||
"kind": "mapping",
|
||||
"value": "https://origin.example/advisories/example",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": [
|
||||
"affectedpackages",
|
||||
"aliases",
|
||||
"references",
|
||||
"summary"
|
||||
]
|
||||
}
|
||||
],
|
||||
"published": "2025-10-12T03:00:00+00:00",
|
||||
"references": [
|
||||
{
|
||||
"kind": "advisory",
|
||||
"provenance": {
|
||||
"source": "acsc",
|
||||
"kind": "reference",
|
||||
"value": "https://origin.example/advisories/example",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": "alerts",
|
||||
"summary": "ACSC-2025-001 Example Advisory",
|
||||
"url": "https://origin.example/advisories/example"
|
||||
},
|
||||
{
|
||||
"kind": "reference",
|
||||
"provenance": {
|
||||
"source": "acsc",
|
||||
"kind": "reference",
|
||||
"value": "https://vendor.example/patch",
|
||||
"decisionReason": null,
|
||||
"recordedAt": "2025-10-12T00:00:00+00:00",
|
||||
"fieldMask": []
|
||||
},
|
||||
"sourceTag": null,
|
||||
"summary": "Vendor patch",
|
||||
"url": "https://vendor.example/patch"
|
||||
}
|
||||
],
|
||||
"severity": null,
|
||||
"summary": "Serial number: ACSC-2025-001\n\nAdvisory type: Alert\n\nFirst paragraph describing issue.\n\nSecond paragraph with Vendor patch.",
|
||||
"title": "ACSC-2025-001 Example Advisory"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Acsc/StellaOps.Concelier.Connector.Acsc.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Acsc/Fixtures/**" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user