Resolve Concelier/Excititor merge conflicts
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Connectors;
|
||||
|
||||
public sealed class UbuntuCsafConnectorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FetchAsync_IngestsNewDocument_UpdatesStateAndUsesEtag()
|
||||
{
|
||||
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
|
||||
var indexUri = new Uri(baseUri, "index.json");
|
||||
var catalogUri = new Uri(baseUri, "stable/catalog.json");
|
||||
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0001.json");
|
||||
|
||||
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0001", "2025-10-18T00:00:00Z");
|
||||
var documentPayload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
|
||||
var documentSha = ComputeSha256(documentPayload);
|
||||
|
||||
var indexJson = manifest.IndexJson;
|
||||
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", documentSha, StringComparison.Ordinal);
|
||||
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, documentPayload, expectedEtag: "etag-123");
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var httpFactory = new SingleClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
|
||||
|
||||
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
var connector = new UbuntuCsafConnector(
|
||||
loader,
|
||||
httpFactory,
|
||||
stateRepository,
|
||||
new[] { optionsValidator },
|
||||
NullLogger<UbuntuCsafConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var settings = new VexConnectorSettings(ImmutableDictionary<string, string>.Empty);
|
||||
await connector.ValidateAsync(settings, CancellationToken.None);
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(doc);
|
||||
}
|
||||
|
||||
documents.Should().HaveCount(1);
|
||||
sink.Documents.Should().HaveCount(1);
|
||||
var stored = sink.Documents.Single();
|
||||
stored.Digest.Should().Be($"sha256:{documentSha}");
|
||||
stored.Metadata.TryGetValue("ubuntu.etag", out var storedEtag).Should().BeTrue();
|
||||
storedEtag.Should().Be("etag-123");
|
||||
|
||||
stateRepository.CurrentState.Should().NotBeNull();
|
||||
stateRepository.CurrentState!.DocumentDigests.Should().Contain($"sha256:{documentSha}");
|
||||
stateRepository.CurrentState.DocumentDigests.Should().Contain($"etag:{advisoryUri}|etag-123");
|
||||
stateRepository.CurrentState.LastUpdated.Should().Be(DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
|
||||
|
||||
handler.DocumentRequestCount.Should().Be(1);
|
||||
|
||||
// Second run: Expect connector to send If-None-Match and skip download via 304.
|
||||
sink.Documents.Clear();
|
||||
documents.Clear();
|
||||
|
||||
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(doc);
|
||||
}
|
||||
|
||||
documents.Should().BeEmpty();
|
||||
sink.Documents.Should().BeEmpty();
|
||||
handler.DocumentRequestCount.Should().Be(2);
|
||||
handler.SeenIfNoneMatch.Should().Contain("\"etag-123\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FetchAsync_SkipsWhenChecksumMismatch()
|
||||
{
|
||||
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
|
||||
var indexUri = new Uri(baseUri, "index.json");
|
||||
var catalogUri = new Uri(baseUri, "stable/catalog.json");
|
||||
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0002.json");
|
||||
|
||||
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0002", "2025-10-18T00:00:00Z");
|
||||
var indexJson = manifest.IndexJson;
|
||||
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", new string('a', 64), StringComparison.Ordinal);
|
||||
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, Encoding.UTF8.GetBytes("{\"document\":\"payload\"}"), expectedEtag: "etag-999");
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
var httpFactory = new SingleClientFactory(httpClient);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
|
||||
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
|
||||
var stateRepository = new InMemoryConnectorStateRepository();
|
||||
|
||||
var connector = new UbuntuCsafConnector(
|
||||
loader,
|
||||
httpFactory,
|
||||
stateRepository,
|
||||
new[] { optionsValidator },
|
||||
NullLogger<UbuntuCsafConnector>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
await connector.ValidateAsync(new VexConnectorSettings(ImmutableDictionary<string, string>.Empty), CancellationToken.None);
|
||||
|
||||
var sink = new InMemoryRawSink();
|
||||
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
var documents = new List<VexRawDocument>();
|
||||
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
|
||||
{
|
||||
documents.Add(doc);
|
||||
}
|
||||
|
||||
documents.Should().BeEmpty();
|
||||
sink.Documents.Should().BeEmpty();
|
||||
stateRepository.CurrentState.Should().NotBeNull();
|
||||
stateRepository.CurrentState!.DocumentDigests.Should().BeEmpty();
|
||||
handler.DocumentRequestCount.Should().Be(1);
|
||||
}
|
||||
|
||||
private static (string IndexJson, string CatalogJson) CreateTestManifest(Uri advisoryUri, string advisoryId, string timestamp)
|
||||
{
|
||||
var indexJson = $$"""
|
||||
{
|
||||
"generated": "2025-10-18T00:00:00Z",
|
||||
"channels": [
|
||||
{
|
||||
"name": "stable",
|
||||
"catalogUrl": "{{advisoryUri.GetLeftPart(UriPartial.Authority)}}/security/csaf/stable/catalog.json",
|
||||
"sha256": "ignore"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var catalogJson = $$"""
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"id": "{{advisoryId}}",
|
||||
"type": "csaf",
|
||||
"url": "{{advisoryUri}}",
|
||||
"last_modified": "{{timestamp}}",
|
||||
"hashes": {
|
||||
"sha256": "{{SHA256}}"
|
||||
},
|
||||
"etag": "\"etag-123\"",
|
||||
"title": "{{advisoryId}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
return (indexJson, catalogJson);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
SHA256.HashData(payload, buffer);
|
||||
return Convert.ToHexString(buffer).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed class SingleClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class UbuntuTestHttpHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Uri _indexUri;
|
||||
private readonly string _indexPayload;
|
||||
private readonly Uri _catalogUri;
|
||||
private readonly string _catalogPayload;
|
||||
private readonly Uri _documentUri;
|
||||
private readonly byte[] _documentPayload;
|
||||
private readonly string _expectedEtag;
|
||||
|
||||
public int DocumentRequestCount { get; private set; }
|
||||
public List<string> SeenIfNoneMatch { get; } = new();
|
||||
|
||||
public UbuntuTestHttpHandler(Uri indexUri, string indexPayload, Uri catalogUri, string catalogPayload, Uri documentUri, byte[] documentPayload, string expectedEtag)
|
||||
{
|
||||
_indexUri = indexUri;
|
||||
_indexPayload = indexPayload;
|
||||
_catalogUri = catalogUri;
|
||||
_catalogPayload = catalogPayload;
|
||||
_documentUri = documentUri;
|
||||
_documentPayload = documentPayload;
|
||||
_expectedEtag = expectedEtag;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.RequestUri == _indexUri)
|
||||
{
|
||||
return Task.FromResult(CreateJsonResponse(_indexPayload));
|
||||
}
|
||||
|
||||
if (request.RequestUri == _catalogUri)
|
||||
{
|
||||
return Task.FromResult(CreateJsonResponse(_catalogPayload));
|
||||
}
|
||||
|
||||
if (request.RequestUri == _documentUri)
|
||||
{
|
||||
DocumentRequestCount++;
|
||||
if (request.Headers.IfNoneMatch is { Count: > 0 })
|
||||
{
|
||||
var header = request.Headers.IfNoneMatch.First().ToString();
|
||||
SeenIfNoneMatch.Add(header);
|
||||
if (header.Trim('"') == _expectedEtag || header == $"\"{_expectedEtag}\"")
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotModified));
|
||||
}
|
||||
}
|
||||
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(_documentPayload),
|
||||
};
|
||||
response.Headers.ETag = new EntityTagHeaderValue($"\"{_expectedEtag}\"");
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
Content = new StringContent($"No response configured for {request.RequestUri}"),
|
||||
});
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateJsonResponse(string payload)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
|
||||
{
|
||||
public VexConnectorState? CurrentState { get; private set; }
|
||||
|
||||
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(CurrentState);
|
||||
|
||||
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken)
|
||||
{
|
||||
CurrentState = state;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryRawSink : IVexRawDocumentSink
|
||||
{
|
||||
public List<VexRawDocument> Documents { get; } = new();
|
||||
|
||||
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Documents.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
|
||||
{
|
||||
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
|
||||
}
|
||||
|
||||
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||
{
|
||||
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using Xunit;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Metadata;
|
||||
|
||||
public sealed class UbuntuCatalogLoaderTests
|
||||
{
|
||||
private const string SampleIndex = """
|
||||
{
|
||||
"generated": "2025-10-10T00:00:00Z",
|
||||
"channels": [
|
||||
{
|
||||
"name": "stable",
|
||||
"catalogUrl": "https://ubuntu.com/security/csaf/stable/catalog.json",
|
||||
"sha256": "abc",
|
||||
"lastUpdated": "2025-10-09T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"name": "esm",
|
||||
"catalogUrl": "https://ubuntu.com/security/csaf/esm/catalog.json",
|
||||
"sha256": "def",
|
||||
"lastUpdated": "2025-10-08T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_FetchesAndCachesIndex()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
|
||||
{
|
||||
[new Uri("https://ubuntu.com/security/csaf/index.json")] = CreateResponse(SampleIndex),
|
||||
});
|
||||
var client = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new UbuntuCatalogLoader(factory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, new AdjustableTimeProvider());
|
||||
|
||||
var options = new UbuntuConnectorOptions
|
||||
{
|
||||
IndexUri = new Uri("https://ubuntu.com/security/csaf/index.json"),
|
||||
OfflineSnapshotPath = "/snapshots/ubuntu-index.json",
|
||||
};
|
||||
|
||||
var result = await loader.LoadAsync(options, CancellationToken.None);
|
||||
result.Metadata.Channels.Should().HaveCount(1);
|
||||
result.Metadata.Channels[0].Name.Should().Be("stable");
|
||||
fileSystem.FileExists(options.OfflineSnapshotPath!).Should().BeTrue();
|
||||
result.FromCache.Should().BeFalse();
|
||||
|
||||
handler.ResetInvocationCount();
|
||||
var cached = await loader.LoadAsync(options, CancellationToken.None);
|
||||
cached.FromCache.Should().BeTrue();
|
||||
handler.InvocationCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_UsesOfflineSnapshotWhenPreferred()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>());
|
||||
var client = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.AddFile("/snapshots/ubuntu-index.json", new MockFileData($"{{\"metadata\":{SampleIndex},\"fetchedAt\":\"2025-10-10T00:00:00Z\"}}"));
|
||||
var loader = new UbuntuCatalogLoader(factory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, new AdjustableTimeProvider());
|
||||
|
||||
var options = new UbuntuConnectorOptions
|
||||
{
|
||||
IndexUri = new Uri("https://ubuntu.com/security/csaf/index.json"),
|
||||
OfflineSnapshotPath = "/snapshots/ubuntu-index.json",
|
||||
PreferOfflineSnapshot = true,
|
||||
Channels = { "stable" }
|
||||
};
|
||||
|
||||
var result = await loader.LoadAsync(options, CancellationToken.None);
|
||||
result.FromOfflineSnapshot.Should().BeTrue();
|
||||
result.Metadata.Channels.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ThrowsWhenNoChannelsMatch()
|
||||
{
|
||||
var handler = new TestHttpMessageHandler(new Dictionary<Uri, HttpResponseMessage>
|
||||
{
|
||||
[new Uri("https://ubuntu.com/security/csaf/index.json")] = CreateResponse(SampleIndex),
|
||||
});
|
||||
var client = new HttpClient(handler);
|
||||
var factory = new SingleClientHttpClientFactory(client);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var fileSystem = new MockFileSystem();
|
||||
var loader = new UbuntuCatalogLoader(factory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, new AdjustableTimeProvider());
|
||||
|
||||
var options = new UbuntuConnectorOptions
|
||||
{
|
||||
IndexUri = new Uri("https://ubuntu.com/security/csaf/index.json"),
|
||||
};
|
||||
options.Channels.Clear();
|
||||
options.Channels.Add("nonexistent");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.LoadAsync(options, CancellationToken.None));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateResponse(string payload)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SingleClientHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class AdjustableTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<Uri, HttpResponseMessage> _responses;
|
||||
|
||||
public TestHttpMessageHandler(Dictionary<Uri, HttpResponseMessage> responses)
|
||||
{
|
||||
_responses = responses;
|
||||
}
|
||||
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
public void ResetInvocationCount() => InvocationCount = 0;
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
InvocationCount++;
|
||||
if (request.RequestUri is not null && _responses.TryGetValue(request.RequestUri, out var response))
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new HttpResponseMessage(response.StatusCode)
|
||||
{
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("unexpected request"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Excititor.Connectors.Ubuntu.CSAF\StellaOps.Excititor.Connectors.Ubuntu.CSAF.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user