Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -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>
""";
}
}

View File

@@ -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;
}
}
}

View File

@@ -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"]);
}
}

View File

@@ -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"
}
]

View File

@@ -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"
}
]

View File

@@ -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>