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

View File

@@ -0,0 +1,43 @@
using StellaOps.Concelier.Connector.Common.Html;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class HtmlContentSanitizerTests
{
[Fact]
public void Sanitize_RemovesScriptAndDangerousAttributes()
{
var sanitizer = new HtmlContentSanitizer();
var input = "<div onclick=\"alert(1)\"><script>alert('bad')</script><a href='/foo' target='_blank'>link</a></div>";
var sanitized = sanitizer.Sanitize(input, new Uri("https://example.test/base/"));
Assert.DoesNotContain("script", sanitized, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("onclick", sanitized, StringComparison.OrdinalIgnoreCase);
Assert.Contains("https://example.test/foo", sanitized, StringComparison.Ordinal);
Assert.Contains("rel=\"noopener nofollow noreferrer\"", sanitized, StringComparison.Ordinal);
}
[Fact]
public void Sanitize_PreservesBasicFormatting()
{
var sanitizer = new HtmlContentSanitizer();
var input = "<p><strong>Hello</strong> <em>world</em></p>";
var sanitized = sanitizer.Sanitize(input);
Assert.Equal("<p><strong>Hello</strong> <em>world</em></p>", sanitized);
}
[Fact]
public void Sanitize_PreservesHeadingsAndLists()
{
var sanitizer = new HtmlContentSanitizer();
var input = "<section><h2>Affected Products</h2><ul><li>Example One</li></ul></section>";
var sanitized = sanitizer.Sanitize(input);
Assert.Contains("<h2>Affected Products</h2>", sanitized, StringComparison.Ordinal);
Assert.Contains("<ul><li>Example One</li></ul>", sanitized, StringComparison.Ordinal);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
<?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.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
</ItemGroup>
</Project>

View File

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