feat: add Reachability Center and Why Drawer components with tests
Some checks failed
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
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented ReachabilityCenterComponent for displaying asset reachability status with summary and filtering options.
- Added ReachabilityWhyDrawerComponent to show detailed reachability evidence and call paths.
- Created unit tests for both components to ensure functionality and correctness.
- Updated accessibility test results for the new components.
This commit is contained in:
master
2025-12-12 18:50:35 +02:00
parent efaf3cb789
commit 3f3473ee3a
320 changed files with 10635 additions and 3677 deletions

View File

@@ -1,187 +1,165 @@
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;
using StellaOps.Concelier.Testing;
using Xunit;
using System.Collections.Generic;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.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;
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);
[Collection(ConcelierFixtureCollection.Name)]
public sealed class AcscConnectorFetchTests
{
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 ConcelierPostgresFixture _fixture;
public AcscConnectorFetchTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task FetchAsync_DirectSuccessAdvancesCursor()
{
await using var harness = await BuildHarnessAsync(preferRelay: false);
var connector = harness.ServiceProvider.GetRequiredService<AcscConnector>();
SeedRssResponse(
harness.Handler,
AlertsDirectUri,
"direct",
DateTimeOffset.Parse("2025-10-10T02:15:00Z"),
DateTimeOffset.Parse("2025-10-11T05:30:00Z"));
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
harness.Handler.AssertNoPendingResponses();
var stateRepository = harness.ServiceProvider.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);
var pendingDocuments = state.Cursor.GetValue("pendingDocuments").AsBsonArray;
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);
}
[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);
[Fact]
public async Task FetchAsync_DirectFailureFallsBackToRelay()
{
await using var harness = await BuildHarnessAsync(preferRelay: false);
var connector = harness.ServiceProvider.GetRequiredService<AcscConnector>();
harness.Handler.AddException(HttpMethod.Get, AlertsDirectUri, new HttpRequestException("HTTP/2 reset"));
SeedRssResponse(
harness.Handler,
AlertsRelayUri,
"relay",
DateTimeOffset.Parse("2025-10-09T10:00:00Z"),
DateTimeOffset.Parse("2025-10-11T00:00:00Z"));
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
harness.Handler.AssertNoPendingResponses();
var stateRepository = harness.ServiceProvider.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);
var pendingDocuments = state.Cursor.GetValue("pendingDocuments").AsBsonArray;
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.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);
Assert.True(metadata.TryGetValue("acsc.fetch.mode", out var mode));
Assert.Equal("relay", mode);
Assert.Collection(harness.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"),
Assert.Equal(AlertsRelayUri, request.Uri);
});
}
private async Task<ConnectorTestHarness> BuildHarnessAsync(bool preferRelay)
{
var initialTime = new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero);
var harness = new ConnectorTestHarness(_fixture, initialTime, AcscOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
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<SourceHttpClientOptions>(AcscOptions.HttpClientName, options =>
{
options.MaxAttempts = 1;
options.BaseDelay = TimeSpan.Zero;
});
});
return harness;
}
private static void SeedRssResponse(CannedHttpMessageHandler handler, 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\"");

View File

@@ -1,88 +1,78 @@
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;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Testing;
using Xunit;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.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;
using StellaOps.Concelier.Storage.Advisories;
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/");
[Collection(ConcelierFixtureCollection.Name)]
public sealed class AcscConnectorParseTests
{
private static readonly Uri BaseEndpoint = new("https://origin.example/");
private readonly ConcelierPostgresFixture _fixture;
public AcscConnectorParseTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
}
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);
[Fact]
public async Task ParseAsync_PersistsDtoAndAdvancesCursor()
{
await using var harness = await BuildHarnessAsync();
var connector = harness.ServiceProvider.GetRequiredService<AcscConnector>();
var feedUri = new Uri(BaseEndpoint, "/feeds/alerts/rss");
SeedRssResponse(harness.Handler, feedUri);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
harness.Handler.AssertNoPendingResponses();
var documentStore = harness.ServiceProvider.GetRequiredService<IDocumentStore>();
var document = await documentStore.FindBySourceAndUriAsync(AcscConnectorPlugin.SourceName, feedUri.ToString(), CancellationToken.None);
Assert.NotNull(document);
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
var refreshed = await documentStore.FindAsync(document!.Id, CancellationToken.None);
Assert.NotNull(refreshed);
Assert.Equal(DocumentStatuses.PendingMap, refreshed!.Status);
var dtoStore = harness.ServiceProvider.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);
Assert.Equal("alerts", payload.GetValue("feedSlug").AsString);
Assert.Single(payload.GetValue("entries").AsBsonArray);
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));
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)
@@ -101,46 +91,46 @@ public sealed class AcscConnectorParseTests : IAsyncLifetime
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);
[Fact]
public async Task MapAsync_MultiEntryFeedProducesExpectedSnapshot()
{
await using var harness = await BuildHarnessAsync(options =>
{
options.Feeds.Clear();
options.Feeds.Add(new AcscFeedOptions
{
Slug = "multi",
RelativePath = "/feeds/multi/rss",
Enabled = true,
});
});
var connector = harness.ServiceProvider.GetRequiredService<AcscConnector>();
var feedUri = new Uri(BaseEndpoint, "/feeds/multi/rss");
SeedMultiEntryResponse(harness.Handler, feedUri);
await connector.FetchAsync(harness.ServiceProvider, CancellationToken.None);
await connector.ParseAsync(harness.ServiceProvider, CancellationToken.None);
var documentStore = harness.ServiceProvider.GetRequiredService<IDocumentStore>();
var dtoStore = harness.ServiceProvider.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);
Assert.Equal("Critical", fields.GetValue("severity").AsString);
Assert.Equal("ExampleCo Router X, ExampleCo Router Y", fields.GetValue("systemsAffected").AsString);
await connector.MapAsync(harness.ServiceProvider, CancellationToken.None);
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)
@@ -155,67 +145,44 @@ public sealed class AcscConnectorParseTests : IAsyncLifetime
Assert.Equal("critical", ordered.First(a => a.Severity is not null).Severity, StringComparer.OrdinalIgnoreCase);
}
public Task InitializeAsync() => Task.CompletedTask;
private async Task<ConnectorTestHarness> BuildHarnessAsync(Action<AcscOptions>? configure = null)
{
var initialTime = new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero);
var harness = new ConnectorTestHarness(_fixture, initialTime, AcscOptions.HttpClientName);
await harness.EnsureServiceProviderAsync(services =>
{
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<SourceHttpClientOptions>(AcscOptions.HttpClientName, options =>
{
options.MaxAttempts = 1;
options.BaseDelay = TimeSpan.Zero;
});
});
return harness;
}
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/">
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>
@@ -233,26 +200,26 @@ public sealed class AcscConnectorParseTests : IAsyncLifetime
]]></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"),
</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/">
return response;
});
}
private static void SeedMultiEntryResponse(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 Advisories</title>
<link>https://origin.example/feeds/advisories</link>
@@ -282,14 +249,14 @@ public sealed class AcscConnectorParseTests : IAsyncLifetime
]]></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"),
</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);