Restructure solution layout by module
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user