partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,520 @@
|
||||
// <copyright file="AstraConnectorIntegrationTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Connector.Astra.Configuration;
|
||||
using StellaOps.Concelier.Connector.Astra.Internal;
|
||||
using StellaOps.Concelier.Connector.Common;
|
||||
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||
using StellaOps.Concelier.Documents;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Storage;
|
||||
using StellaOps.Concelier.Storage.Advisories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Astra.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for Astra Linux connector with OVAL parsing.
|
||||
/// Sprint: SPRINT_20260208_034_Concelier_astra_linux_oval_feed_connector
|
||||
/// </summary>
|
||||
public sealed class AstraConnectorIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public void OvalParser_IntegratedWithConnector_ParsesCompleteOval()
|
||||
{
|
||||
// Arrange
|
||||
var parser = new OvalParser(NullLogger<OvalParser>.Instance);
|
||||
var ovalXml = CreateCompleteAstraOvalFeed();
|
||||
|
||||
// Act
|
||||
var definitions = parser.Parse(ovalXml);
|
||||
|
||||
// Assert
|
||||
definitions.Should().HaveCount(3);
|
||||
definitions[0].DefinitionId.Should().Be("oval:ru.astra:def:20240001");
|
||||
definitions[0].Title.Should().Be("OpenSSL vulnerability in Astra Linux");
|
||||
definitions[0].CveIds.Should().Contain("CVE-2024-0727");
|
||||
definitions[0].Severity.Should().Be("High");
|
||||
definitions[0].AffectedPackages.Should().HaveCount(1);
|
||||
definitions[0].AffectedPackages[0].PackageName.Should().Be("openssl");
|
||||
definitions[0].AffectedPackages[0].FixedVersion.Should().Be("1.1.1w-0+deb11u1+astra3");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public void MapToAdvisory_ViaReflection_ProducesValidAdvisory()
|
||||
{
|
||||
// Arrange - Use reflection to call private MapToAdvisory method
|
||||
var connector = CreateConnector();
|
||||
var definition = CreateTestDefinition();
|
||||
var recordedAt = DateTimeOffset.Parse("2024-06-15T12:00:00Z");
|
||||
|
||||
// Use reflection to access private method
|
||||
var mapMethod = typeof(AstraConnector)
|
||||
.GetMethod("MapToAdvisory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
mapMethod.Should().NotBeNull("MapToAdvisory method should exist");
|
||||
|
||||
// Act
|
||||
var advisory = (Advisory)mapMethod!.Invoke(connector, new object[] { definition, recordedAt })!;
|
||||
|
||||
// Assert
|
||||
advisory.Should().NotBeNull();
|
||||
advisory.AdvisoryKey.Should().Be("CVE-2024-12345");
|
||||
advisory.Title.Should().Be("Test Vulnerability");
|
||||
advisory.Description.Should().Be("A test vulnerability description");
|
||||
advisory.Severity.Should().NotBeNull();
|
||||
advisory.Language.Should().Be("ru");
|
||||
advisory.Published.Should().NotBeNull();
|
||||
advisory.AffectedPackages.Should().HaveCount(1);
|
||||
advisory.Provenance.Should().HaveCount(1);
|
||||
advisory.Provenance[0].Source.Should().Be("distro-astra");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public void MapToAdvisory_WithMultipleCves_FirstCveIsKey()
|
||||
{
|
||||
// Arrange
|
||||
var connector = CreateConnector();
|
||||
var definition = new AstraVulnerabilityDefinitionBuilder()
|
||||
.WithDefinitionId("oval:ru.astra:def:20240099")
|
||||
.WithCves("CVE-2024-11111", "CVE-2024-22222", "CVE-2024-33333")
|
||||
.Build();
|
||||
var recordedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var mapMethod = typeof(AstraConnector)
|
||||
.GetMethod("MapToAdvisory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
|
||||
// Act
|
||||
var advisory = (Advisory)mapMethod.Invoke(connector, new object[] { definition, recordedAt })!;
|
||||
|
||||
// Assert
|
||||
advisory.AdvisoryKey.Should().Be("CVE-2024-11111");
|
||||
advisory.Aliases.Should().HaveCount(2);
|
||||
advisory.Aliases.Should().Contain("CVE-2024-22222");
|
||||
advisory.Aliases.Should().Contain("CVE-2024-33333");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public void MapToAdvisory_WithNoCves_UsesDefinitionId()
|
||||
{
|
||||
// Arrange
|
||||
var connector = CreateConnector();
|
||||
var definition = new AstraVulnerabilityDefinitionBuilder()
|
||||
.WithDefinitionId("oval:ru.astra:def:20240100")
|
||||
.WithTitle("No CVE Advisory")
|
||||
.WithCves() // Empty CVE list
|
||||
.Build();
|
||||
var recordedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var mapMethod = typeof(AstraConnector)
|
||||
.GetMethod("MapToAdvisory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
|
||||
// Act
|
||||
var advisory = (Advisory)mapMethod.Invoke(connector, new object[] { definition, recordedAt })!;
|
||||
|
||||
// Assert
|
||||
advisory.AdvisoryKey.Should().Be("oval:ru.astra:def:20240100");
|
||||
advisory.Aliases.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public void MapToAdvisory_AffectedPackages_UseDebPackageType()
|
||||
{
|
||||
// Arrange
|
||||
var connector = CreateConnector();
|
||||
var definition = new AstraVulnerabilityDefinitionBuilder()
|
||||
.WithPackage("openssl", fixedVersion: "1.1.1w-0+deb11u1+astra3")
|
||||
.WithPackage("curl", fixedVersion: "7.74.0-1.3+deb11u8")
|
||||
.Build();
|
||||
var recordedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var mapMethod = typeof(AstraConnector)
|
||||
.GetMethod("MapToAdvisory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
|
||||
// Act
|
||||
var advisory = (Advisory)mapMethod.Invoke(connector, new object[] { definition, recordedAt })!;
|
||||
|
||||
// Assert
|
||||
advisory.AffectedPackages.Should().HaveCount(2);
|
||||
foreach (var pkg in advisory.AffectedPackages)
|
||||
{
|
||||
pkg.Type.Should().Be(AffectedPackageTypes.Deb);
|
||||
pkg.Platform.Should().Be("astra-linux");
|
||||
pkg.VersionRanges.Should().HaveCount(1);
|
||||
pkg.VersionRanges[0].RangeKind.Should().Be("evr");
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public void MapToAdvisory_VersionRange_CorrectExpression()
|
||||
{
|
||||
// Arrange
|
||||
var connector = CreateConnector();
|
||||
var definition = new AstraVulnerabilityDefinitionBuilder()
|
||||
.WithPackage("test-pkg", minVersion: "1.0.0", maxVersion: "1.0.5", fixedVersion: "1.0.6")
|
||||
.Build();
|
||||
var recordedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var mapMethod = typeof(AstraConnector)
|
||||
.GetMethod("MapToAdvisory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
|
||||
// Act
|
||||
var advisory = (Advisory)mapMethod.Invoke(connector, new object[] { definition, recordedAt })!;
|
||||
|
||||
// Assert
|
||||
advisory.AffectedPackages.Should().HaveCount(1);
|
||||
var range = advisory.AffectedPackages[0].VersionRanges[0];
|
||||
range.IntroducedVersion.Should().Be("1.0.0");
|
||||
range.FixedVersion.Should().Be("1.0.6");
|
||||
range.LastAffectedVersion.Should().Be("1.0.5");
|
||||
range.RangeExpression.Should().Be(">=1.0.0, <1.0.6");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public void EndToEnd_ParseAndMap_ProducesConsistentAdvisories()
|
||||
{
|
||||
// Arrange
|
||||
var parser = new OvalParser(NullLogger<OvalParser>.Instance);
|
||||
var connector = CreateConnector();
|
||||
var ovalXml = CreateSingleDefinitionOval();
|
||||
var recordedAt = DateTimeOffset.Parse("2024-06-15T12:00:00Z");
|
||||
|
||||
var mapMethod = typeof(AstraConnector)
|
||||
.GetMethod("MapToAdvisory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
|
||||
// Act
|
||||
var definitions = parser.Parse(ovalXml);
|
||||
var advisories = definitions
|
||||
.Select(d => (Advisory)mapMethod.Invoke(connector, new object[] { d, recordedAt })!)
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
advisories.Should().HaveCount(1);
|
||||
var advisory = advisories[0];
|
||||
advisory.AdvisoryKey.Should().Be("CVE-2024-12345");
|
||||
advisory.Title.Should().Be("Test OpenSSL Vulnerability");
|
||||
advisory.AffectedPackages.Should().HaveCount(1);
|
||||
advisory.AffectedPackages[0].Identifier.Should().Be("openssl");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public void EndToEnd_DeterministicOutput_SameInputProducesSameResult()
|
||||
{
|
||||
// Arrange
|
||||
var parser = new OvalParser(NullLogger<OvalParser>.Instance);
|
||||
var connector = CreateConnector();
|
||||
var ovalXml = CreateSingleDefinitionOval();
|
||||
var recordedAt = DateTimeOffset.Parse("2024-06-15T12:00:00Z");
|
||||
|
||||
var mapMethod = typeof(AstraConnector)
|
||||
.GetMethod("MapToAdvisory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
||||
|
||||
// Act - Run twice
|
||||
var definitions1 = parser.Parse(ovalXml);
|
||||
var advisory1 = (Advisory)mapMethod.Invoke(connector, new object[] { definitions1[0], recordedAt })!;
|
||||
|
||||
var definitions2 = parser.Parse(ovalXml);
|
||||
var advisory2 = (Advisory)mapMethod.Invoke(connector, new object[] { definitions2[0], recordedAt })!;
|
||||
|
||||
// Assert - Results should be identical
|
||||
advisory1.AdvisoryKey.Should().Be(advisory2.AdvisoryKey);
|
||||
advisory1.Title.Should().Be(advisory2.Title);
|
||||
advisory1.Description.Should().Be(advisory2.Description);
|
||||
advisory1.AffectedPackages.Should().HaveCount(advisory2.AffectedPackages.Length);
|
||||
advisory1.AffectedPackages[0].Identifier.Should().Be(advisory2.AffectedPackages[0].Identifier);
|
||||
}
|
||||
|
||||
#region Test Fixtures
|
||||
|
||||
private static AstraConnector CreateConnector()
|
||||
{
|
||||
var options = new AstraOptions
|
||||
{
|
||||
BulletinBaseUri = new Uri("https://astra.ru/en/support/security-bulletins/"),
|
||||
OvalRepositoryUri = new Uri("https://download.astralinux.ru/astra/stable/oval/"),
|
||||
RequestTimeout = TimeSpan.FromSeconds(120),
|
||||
RequestDelay = TimeSpan.FromMilliseconds(500),
|
||||
FailureBackoff = TimeSpan.FromMinutes(15),
|
||||
MaxDefinitionsPerFetch = 100,
|
||||
InitialBackfill = TimeSpan.FromDays(365),
|
||||
ResumeOverlap = TimeSpan.FromDays(7),
|
||||
UserAgent = "StellaOps.Concelier.Astra/0.1 (+https://stella-ops.org)"
|
||||
};
|
||||
|
||||
var documentStore = new Mock<IDocumentStore>(MockBehavior.Strict).Object;
|
||||
var dtoStore = new Mock<IDtoStore>(MockBehavior.Strict).Object;
|
||||
var advisoryStore = new Mock<IAdvisoryStore>(MockBehavior.Strict).Object;
|
||||
var stateRepository = new Mock<ISourceStateRepository>(MockBehavior.Strict).Object;
|
||||
|
||||
return new AstraConnector(
|
||||
null!,
|
||||
null!,
|
||||
documentStore,
|
||||
dtoStore,
|
||||
advisoryStore,
|
||||
stateRepository,
|
||||
Options.Create(options),
|
||||
TimeProvider.System,
|
||||
NullLogger<AstraConnector>.Instance);
|
||||
}
|
||||
|
||||
private static AstraVulnerabilityDefinition CreateTestDefinition()
|
||||
{
|
||||
return new AstraVulnerabilityDefinitionBuilder()
|
||||
.WithDefinitionId("oval:ru.astra:def:20240001")
|
||||
.WithTitle("Test Vulnerability")
|
||||
.WithDescription("A test vulnerability description")
|
||||
.WithCves("CVE-2024-12345")
|
||||
.WithSeverity("High")
|
||||
.WithPublishedDate(DateTimeOffset.Parse("2024-01-15T00:00:00Z"))
|
||||
.WithPackage("openssl", fixedVersion: "1.1.1w-0+deb11u1+astra3")
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static string CreateCompleteAstraOvalFeed()
|
||||
{
|
||||
return @"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<oval_definitions xmlns=""http://oval.mitre.org/XMLSchema/oval-definitions-5""
|
||||
xmlns:linux=""http://oval.mitre.org/XMLSchema/oval-definitions-5#linux"">
|
||||
<definitions>
|
||||
<definition id=""oval:ru.astra:def:20240001"" class=""vulnerability"">
|
||||
<metadata>
|
||||
<title>OpenSSL vulnerability in Astra Linux</title>
|
||||
<description>A buffer overflow in OpenSSL affects Astra Linux.</description>
|
||||
<reference ref_id=""CVE-2024-0727"" source=""CVE""/>
|
||||
<advisory>
|
||||
<severity>High</severity>
|
||||
<issued date=""2024-01-20""/>
|
||||
</advisory>
|
||||
</metadata>
|
||||
<criteria>
|
||||
<criterion test_ref=""test:openssl:1""/>
|
||||
</criteria>
|
||||
</definition>
|
||||
<definition id=""oval:ru.astra:def:20240002"" class=""vulnerability"">
|
||||
<metadata>
|
||||
<title>Curl vulnerability in Astra Linux</title>
|
||||
<description>A heap-based buffer overflow in curl.</description>
|
||||
<reference ref_id=""CVE-2024-2398"" source=""CVE""/>
|
||||
<advisory>
|
||||
<severity>Medium</severity>
|
||||
<issued date=""2024-02-15""/>
|
||||
</advisory>
|
||||
</metadata>
|
||||
<criteria>
|
||||
<criterion test_ref=""test:curl:1""/>
|
||||
</criteria>
|
||||
</definition>
|
||||
<definition id=""oval:ru.astra:def:20240003"" class=""vulnerability"">
|
||||
<metadata>
|
||||
<title>Kernel vulnerability in Astra Linux</title>
|
||||
<description>A privilege escalation in the Linux kernel.</description>
|
||||
<reference ref_id=""CVE-2024-1086"" source=""CVE""/>
|
||||
<advisory>
|
||||
<severity>Critical</severity>
|
||||
<issued date=""2024-03-01""/>
|
||||
</advisory>
|
||||
</metadata>
|
||||
<criteria>
|
||||
<criterion test_ref=""test:kernel:1""/>
|
||||
</criteria>
|
||||
</definition>
|
||||
</definitions>
|
||||
<tests>
|
||||
<linux:dpkginfo_test id=""test:openssl:1"" check=""at least one"">
|
||||
<linux:object object_ref=""obj:openssl:1""/>
|
||||
<linux:state state_ref=""state:openssl:1""/>
|
||||
</linux:dpkginfo_test>
|
||||
<linux:dpkginfo_test id=""test:curl:1"" check=""at least one"">
|
||||
<linux:object object_ref=""obj:curl:1""/>
|
||||
<linux:state state_ref=""state:curl:1""/>
|
||||
</linux:dpkginfo_test>
|
||||
<linux:dpkginfo_test id=""test:kernel:1"" check=""at least one"">
|
||||
<linux:object object_ref=""obj:kernel:1""/>
|
||||
<linux:state state_ref=""state:kernel:1""/>
|
||||
</linux:dpkginfo_test>
|
||||
</tests>
|
||||
<objects>
|
||||
<linux:dpkginfo_object id=""obj:openssl:1"">
|
||||
<linux:name>openssl</linux:name>
|
||||
</linux:dpkginfo_object>
|
||||
<linux:dpkginfo_object id=""obj:curl:1"">
|
||||
<linux:name>curl</linux:name>
|
||||
</linux:dpkginfo_object>
|
||||
<linux:dpkginfo_object id=""obj:kernel:1"">
|
||||
<linux:name>linux-image-astra</linux:name>
|
||||
</linux:dpkginfo_object>
|
||||
</objects>
|
||||
<states>
|
||||
<linux:dpkginfo_state id=""state:openssl:1"">
|
||||
<linux:evr datatype=""evr_string"" operation=""less than"">1.1.1w-0+deb11u1+astra3</linux:evr>
|
||||
</linux:dpkginfo_state>
|
||||
<linux:dpkginfo_state id=""state:curl:1"">
|
||||
<linux:evr datatype=""evr_string"" operation=""less than"">7.74.0-1.3+deb11u8</linux:evr>
|
||||
</linux:dpkginfo_state>
|
||||
<linux:dpkginfo_state id=""state:kernel:1"">
|
||||
<linux:evr datatype=""evr_string"" operation=""less than"">5.10.0-28+astra1</linux:evr>
|
||||
</linux:dpkginfo_state>
|
||||
</states>
|
||||
</oval_definitions>";
|
||||
}
|
||||
|
||||
private static string CreateSingleDefinitionOval()
|
||||
{
|
||||
return @"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<oval_definitions xmlns=""http://oval.mitre.org/XMLSchema/oval-definitions-5""
|
||||
xmlns:linux=""http://oval.mitre.org/XMLSchema/oval-definitions-5#linux"">
|
||||
<definitions>
|
||||
<definition id=""oval:ru.astra:def:20240050"" class=""vulnerability"">
|
||||
<metadata>
|
||||
<title>Test OpenSSL Vulnerability</title>
|
||||
<description>Test vulnerability for integration testing.</description>
|
||||
<reference ref_id=""CVE-2024-12345"" source=""CVE""/>
|
||||
<advisory>
|
||||
<severity>High</severity>
|
||||
<issued date=""2024-06-01""/>
|
||||
</advisory>
|
||||
</metadata>
|
||||
<criteria>
|
||||
<criterion test_ref=""test:1""/>
|
||||
</criteria>
|
||||
</definition>
|
||||
</definitions>
|
||||
<tests>
|
||||
<linux:dpkginfo_test id=""test:1"" check=""at least one"">
|
||||
<linux:object object_ref=""obj:1""/>
|
||||
<linux:state state_ref=""state:1""/>
|
||||
</linux:dpkginfo_test>
|
||||
</tests>
|
||||
<objects>
|
||||
<linux:dpkginfo_object id=""obj:1"">
|
||||
<linux:name>openssl</linux:name>
|
||||
</linux:dpkginfo_object>
|
||||
</objects>
|
||||
<states>
|
||||
<linux:dpkginfo_state id=""state:1"">
|
||||
<linux:evr datatype=""evr_string"" operation=""less than"">1.1.1w-0+deb11u1</linux:evr>
|
||||
</linux:dpkginfo_state>
|
||||
</states>
|
||||
</oval_definitions>";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Builder
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating test AstraVulnerabilityDefinition instances.
|
||||
/// </summary>
|
||||
private sealed class AstraVulnerabilityDefinitionBuilder
|
||||
{
|
||||
private string _definitionId = "oval:ru.astra:def:20240001";
|
||||
private string _title = "Test Vulnerability";
|
||||
private string? _description;
|
||||
private string[] _cveIds = new[] { "CVE-2024-12345" };
|
||||
private string? _severity;
|
||||
private DateTimeOffset? _publishedDate;
|
||||
private readonly List<AstraAffectedPackage> _packages = new();
|
||||
|
||||
public AstraVulnerabilityDefinitionBuilder WithDefinitionId(string id)
|
||||
{
|
||||
_definitionId = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AstraVulnerabilityDefinitionBuilder WithTitle(string title)
|
||||
{
|
||||
_title = title;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AstraVulnerabilityDefinitionBuilder WithDescription(string description)
|
||||
{
|
||||
_description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AstraVulnerabilityDefinitionBuilder WithCves(params string[] cves)
|
||||
{
|
||||
_cveIds = cves;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AstraVulnerabilityDefinitionBuilder WithSeverity(string severity)
|
||||
{
|
||||
_severity = severity;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AstraVulnerabilityDefinitionBuilder WithPublishedDate(DateTimeOffset date)
|
||||
{
|
||||
_publishedDate = date;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AstraVulnerabilityDefinitionBuilder WithPackage(
|
||||
string packageName,
|
||||
string? minVersion = null,
|
||||
string? maxVersion = null,
|
||||
string? fixedVersion = null)
|
||||
{
|
||||
_packages.Add(new AstraAffectedPackage
|
||||
{
|
||||
PackageName = packageName,
|
||||
MinVersion = minVersion,
|
||||
MaxVersion = maxVersion,
|
||||
FixedVersion = fixedVersion
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public AstraVulnerabilityDefinition Build()
|
||||
{
|
||||
return new AstraVulnerabilityDefinition
|
||||
{
|
||||
DefinitionId = _definitionId,
|
||||
Title = _title,
|
||||
Description = _description,
|
||||
CveIds = _cveIds,
|
||||
Severity = _severity,
|
||||
PublishedDate = _publishedDate,
|
||||
AffectedPackages = _packages.Count > 0 ? _packages.ToArray() : Array.Empty<AstraAffectedPackage>()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
// Make internal types accessible for testing
|
||||
internal sealed record AstraVulnerabilityDefinition
|
||||
{
|
||||
public required string DefinitionId { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required string[] CveIds { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public DateTimeOffset? PublishedDate { get; init; }
|
||||
public required AstraAffectedPackage[] AffectedPackages { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record AstraAffectedPackage
|
||||
{
|
||||
public required string PackageName { get; init; }
|
||||
public string? MinVersion { get; init; }
|
||||
public string? MaxVersion { get; init; }
|
||||
public string? FixedVersion { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// <copyright file="OvalParserTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Concelier.Connector.Astra.Internal;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Astra.Tests.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for OVAL XML parser.
|
||||
/// Sprint: SPRINT_20260208_034_Concelier_astra_linux_oval_feed_connector
|
||||
/// </summary>
|
||||
public sealed class OvalParserTests
|
||||
{
|
||||
private readonly OvalParser _parser;
|
||||
|
||||
public OvalParserTests()
|
||||
{
|
||||
_parser = new OvalParser(NullLogger<OvalParser>.Instance);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Parse_EmptyDocument_ReturnsEmptyList()
|
||||
{
|
||||
var xml = @"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<oval_definitions xmlns=""http://oval.mitre.org/XMLSchema/oval-definitions-5"">
|
||||
<definitions/>
|
||||
</oval_definitions>";
|
||||
|
||||
var result = _parser.Parse(xml);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Parse_SingleDefinition_ExtractsCorrectly()
|
||||
{
|
||||
var xml = CreateSingleDefinitionOval(
|
||||
definitionId: "oval:ru.astra:def:20240001",
|
||||
title: "Test Vulnerability",
|
||||
description: "A test vulnerability description",
|
||||
cveId: "CVE-2024-12345",
|
||||
severity: "High",
|
||||
publishedDate: "2024-01-15");
|
||||
|
||||
var result = _parser.Parse(xml);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
var definition = result[0];
|
||||
definition.DefinitionId.Should().Be("oval:ru.astra:def:20240001");
|
||||
definition.Title.Should().Be("Test Vulnerability");
|
||||
definition.Description.Should().Be("A test vulnerability description");
|
||||
definition.CveIds.Should().ContainSingle().Which.Should().Be("CVE-2024-12345");
|
||||
definition.Severity.Should().Be("High");
|
||||
definition.PublishedDate.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Parse_MultipleCveIds_ExtractsAll()
|
||||
{
|
||||
var xml = CreateMultipleCveOval(
|
||||
definitionId: "oval:ru.astra:def:20240002",
|
||||
cveIds: new[] { "CVE-2024-11111", "CVE-2024-22222", "CVE-2024-33333" });
|
||||
|
||||
var result = _parser.Parse(xml);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].CveIds.Should().HaveCount(3);
|
||||
result[0].CveIds.Should().Contain("CVE-2024-11111");
|
||||
result[0].CveIds.Should().Contain("CVE-2024-22222");
|
||||
result[0].CveIds.Should().Contain("CVE-2024-33333");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Parse_WithAffectedPackage_ExtractsPackageInfo()
|
||||
{
|
||||
var xml = CreateOvalWithPackage(
|
||||
definitionId: "oval:ru.astra:def:20240003",
|
||||
packageName: "openssl",
|
||||
fixedVersion: "1.1.1k-1+deb11u1+astra3");
|
||||
|
||||
var result = _parser.Parse(xml);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].AffectedPackages.Should().HaveCount(1);
|
||||
var pkg = result[0].AffectedPackages[0];
|
||||
pkg.PackageName.Should().Be("openssl");
|
||||
pkg.FixedVersion.Should().Be("1.1.1k-1+deb11u1+astra3");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Parse_MultipleDefinitions_ParsesAll()
|
||||
{
|
||||
var xml = CreateMultipleDefinitionsOval(3);
|
||||
|
||||
var result = _parser.Parse(xml);
|
||||
|
||||
result.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Parse_InvalidXml_ThrowsOvalParseException()
|
||||
{
|
||||
var xml = "not valid xml";
|
||||
|
||||
var act = () => _parser.Parse(xml);
|
||||
|
||||
act.Should().Throw<OvalParseException>()
|
||||
.WithMessage("*Failed to parse OVAL XML*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Parse_MissingRootElement_ThrowsOvalParseException()
|
||||
{
|
||||
var xml = @"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<wrong_root xmlns=""http://wrong.namespace"">
|
||||
</wrong_root>";
|
||||
|
||||
var act = () => _parser.Parse(xml);
|
||||
|
||||
act.Should().Throw<OvalParseException>()
|
||||
.WithMessage("*Invalid OVAL document*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Parse_DefinitionWithoutId_SkipsDefinition()
|
||||
{
|
||||
var xml = @"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<oval_definitions xmlns=""http://oval.mitre.org/XMLSchema/oval-definitions-5"">
|
||||
<definitions>
|
||||
<definition class=""vulnerability"">
|
||||
<metadata>
|
||||
<title>No ID Definition</title>
|
||||
</metadata>
|
||||
</definition>
|
||||
</definitions>
|
||||
</oval_definitions>";
|
||||
|
||||
var result = _parser.Parse(xml);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Parse_WithVersionRange_ExtractsMinAndMax()
|
||||
{
|
||||
var xml = CreateOvalWithVersionRange(
|
||||
packageName: "curl",
|
||||
minVersion: "7.74.0",
|
||||
maxVersion: "7.74.0-1.3+deb11u7");
|
||||
|
||||
var result = _parser.Parse(xml);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].AffectedPackages.Should().HaveCount(1);
|
||||
var pkg = result[0].AffectedPackages[0];
|
||||
pkg.PackageName.Should().Be("curl");
|
||||
pkg.MinVersion.Should().Be("7.74.0");
|
||||
pkg.MaxVersion.Should().Be("7.74.0-1.3+deb11u7");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Parse_Deterministic_SameInputProducesSameOutput()
|
||||
{
|
||||
var xml = CreateSingleDefinitionOval(
|
||||
definitionId: "oval:ru.astra:def:20240100",
|
||||
title: "Determinism Test",
|
||||
description: "Testing deterministic parsing",
|
||||
cveId: "CVE-2024-99999",
|
||||
severity: "Medium",
|
||||
publishedDate: "2024-06-15");
|
||||
|
||||
var result1 = _parser.Parse(xml);
|
||||
var result2 = _parser.Parse(xml);
|
||||
|
||||
result1.Should().HaveCount(1);
|
||||
result2.Should().HaveCount(1);
|
||||
result1[0].DefinitionId.Should().Be(result2[0].DefinitionId);
|
||||
result1[0].Title.Should().Be(result2[0].Title);
|
||||
result1[0].CveIds.Should().BeEquivalentTo(result2[0].CveIds);
|
||||
}
|
||||
|
||||
#region Test Fixtures
|
||||
|
||||
private static string CreateSingleDefinitionOval(
|
||||
string definitionId,
|
||||
string title,
|
||||
string description,
|
||||
string cveId,
|
||||
string severity,
|
||||
string publishedDate)
|
||||
{
|
||||
return $@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<oval_definitions xmlns=""http://oval.mitre.org/XMLSchema/oval-definitions-5""
|
||||
xmlns:linux=""http://oval.mitre.org/XMLSchema/oval-definitions-5#linux"">
|
||||
<definitions>
|
||||
<definition id=""{definitionId}"" class=""vulnerability"">
|
||||
<metadata>
|
||||
<title>{title}</title>
|
||||
<description>{description}</description>
|
||||
<reference ref_id=""{cveId}"" source=""CVE"" ref_url=""https://nvd.nist.gov/vuln/detail/{cveId}""/>
|
||||
<advisory>
|
||||
<severity>{severity}</severity>
|
||||
<issued date=""{publishedDate}""/>
|
||||
</advisory>
|
||||
</metadata>
|
||||
</definition>
|
||||
</definitions>
|
||||
</oval_definitions>";
|
||||
}
|
||||
|
||||
private static string CreateMultipleCveOval(string definitionId, string[] cveIds)
|
||||
{
|
||||
var references = string.Join("\n ",
|
||||
cveIds.Select(cve => $@"<reference ref_id=""{cve}"" source=""CVE""/>"));
|
||||
|
||||
return $@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<oval_definitions xmlns=""http://oval.mitre.org/XMLSchema/oval-definitions-5"">
|
||||
<definitions>
|
||||
<definition id=""{definitionId}"" class=""vulnerability"">
|
||||
<metadata>
|
||||
<title>Multiple CVE Test</title>
|
||||
{references}
|
||||
</metadata>
|
||||
</definition>
|
||||
</definitions>
|
||||
</oval_definitions>";
|
||||
}
|
||||
|
||||
private static string CreateOvalWithPackage(
|
||||
string definitionId,
|
||||
string packageName,
|
||||
string fixedVersion)
|
||||
{
|
||||
return $@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<oval_definitions xmlns=""http://oval.mitre.org/XMLSchema/oval-definitions-5""
|
||||
xmlns:linux=""http://oval.mitre.org/XMLSchema/oval-definitions-5#linux"">
|
||||
<definitions>
|
||||
<definition id=""{definitionId}"" class=""vulnerability"">
|
||||
<metadata>
|
||||
<title>Package Test</title>
|
||||
<reference ref_id=""CVE-2024-00001"" source=""CVE""/>
|
||||
</metadata>
|
||||
<criteria>
|
||||
<criterion test_ref=""test:1""/>
|
||||
</criteria>
|
||||
</definition>
|
||||
</definitions>
|
||||
<tests>
|
||||
<linux:dpkginfo_test id=""test:1"" check=""at least one"">
|
||||
<linux:object object_ref=""obj:1""/>
|
||||
<linux:state state_ref=""state:1""/>
|
||||
</linux:dpkginfo_test>
|
||||
</tests>
|
||||
<objects>
|
||||
<linux:dpkginfo_object id=""obj:1"">
|
||||
<linux:name>{packageName}</linux:name>
|
||||
</linux:dpkginfo_object>
|
||||
</objects>
|
||||
<states>
|
||||
<linux:dpkginfo_state id=""state:1"">
|
||||
<linux:evr datatype=""evr_string"" operation=""less than"">{fixedVersion}</linux:evr>
|
||||
</linux:dpkginfo_state>
|
||||
</states>
|
||||
</oval_definitions>";
|
||||
}
|
||||
|
||||
private static string CreateOvalWithVersionRange(
|
||||
string packageName,
|
||||
string minVersion,
|
||||
string maxVersion)
|
||||
{
|
||||
return $@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<oval_definitions xmlns=""http://oval.mitre.org/XMLSchema/oval-definitions-5""
|
||||
xmlns:linux=""http://oval.mitre.org/XMLSchema/oval-definitions-5#linux"">
|
||||
<definitions>
|
||||
<definition id=""oval:ru.astra:def:20240010"" class=""vulnerability"">
|
||||
<metadata>
|
||||
<title>Version Range Test</title>
|
||||
<reference ref_id=""CVE-2024-00002"" source=""CVE""/>
|
||||
</metadata>
|
||||
<criteria>
|
||||
<criterion test_ref=""test:range:1""/>
|
||||
</criteria>
|
||||
</definition>
|
||||
</definitions>
|
||||
<tests>
|
||||
<linux:dpkginfo_test id=""test:range:1"" check=""at least one"">
|
||||
<linux:object object_ref=""obj:range:1""/>
|
||||
<linux:state state_ref=""state:range:1""/>
|
||||
</linux:dpkginfo_test>
|
||||
</tests>
|
||||
<objects>
|
||||
<linux:dpkginfo_object id=""obj:range:1"">
|
||||
<linux:name>{packageName}</linux:name>
|
||||
</linux:dpkginfo_object>
|
||||
</objects>
|
||||
<states>
|
||||
<linux:dpkginfo_state id=""state:range:1"">
|
||||
<linux:evr datatype=""evr_string"" operation=""greater than or equal"">{minVersion}</linux:evr>
|
||||
<linux:evr datatype=""evr_string"" operation=""less than or equal"">{maxVersion}</linux:evr>
|
||||
</linux:dpkginfo_state>
|
||||
</states>
|
||||
</oval_definitions>";
|
||||
}
|
||||
|
||||
private static string CreateMultipleDefinitionsOval(int count)
|
||||
{
|
||||
var definitions = string.Join("\n ",
|
||||
Enumerable.Range(1, count).Select(i => $@"<definition id=""oval:ru.astra:def:2024000{i}"" class=""vulnerability"">
|
||||
<metadata>
|
||||
<title>Definition {i}</title>
|
||||
<reference ref_id=""CVE-2024-1000{i}"" source=""CVE""/>
|
||||
</metadata>
|
||||
</definition>"));
|
||||
|
||||
return $@"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<oval_definitions xmlns=""http://oval.mitre.org/XMLSchema/oval-definitions-5"">
|
||||
<definitions>
|
||||
{definitions}
|
||||
</definitions>
|
||||
</oval_definitions>";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
// <copyright file="FeedSnapshotPinningServiceTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Core.Federation;
|
||||
using StellaOps.Concelier.Federation.Export;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Federation;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for FeedSnapshotPinningService.
|
||||
/// Sprint: SPRINT_20260208_035_Concelier_feed_snapshot_coordinator
|
||||
/// </summary>
|
||||
public sealed class FeedSnapshotPinningServiceTests
|
||||
{
|
||||
private readonly Mock<IFeedSnapshotRepository> _snapshotRepositoryMock;
|
||||
private readonly Mock<ISyncLedgerRepository> _syncLedgerRepositoryMock;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly FeedSnapshotPinningService _service;
|
||||
private readonly FederationOptions _options;
|
||||
|
||||
public FeedSnapshotPinningServiceTests()
|
||||
{
|
||||
_snapshotRepositoryMock = new Mock<IFeedSnapshotRepository>(MockBehavior.Strict);
|
||||
_syncLedgerRepositoryMock = new Mock<ISyncLedgerRepository>(MockBehavior.Strict);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 6, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_options = new FederationOptions
|
||||
{
|
||||
SiteId = "test-site-01"
|
||||
};
|
||||
|
||||
_service = new FeedSnapshotPinningService(
|
||||
_snapshotRepositoryMock.Object,
|
||||
_syncLedgerRepositoryMock.Object,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<FeedSnapshotPinningService>.Instance);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PinSnapshotAsync_Success_ReturnsSuccessResult()
|
||||
{
|
||||
// Arrange
|
||||
var snapshotId = "snapshot-2024-001";
|
||||
var sourceId = Guid.NewGuid();
|
||||
var checksum = "sha256:abc123";
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.IsCursorConflictAsync("test-site-01", snapshotId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.GetLatestAsync("test-site-01", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SyncLedgerEntity?)null);
|
||||
|
||||
_snapshotRepositoryMock
|
||||
.Setup(x => x.InsertAsync(It.IsAny<FeedSnapshotEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FeedSnapshotEntity e, CancellationToken _) => e);
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.AdvanceCursorAsync(
|
||||
"test-site-01",
|
||||
snapshotId,
|
||||
checksum,
|
||||
0,
|
||||
It.IsAny<DateTimeOffset>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _service.PinSnapshotAsync(snapshotId, sourceId, checksum);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.SiteId.Should().Be("test-site-01");
|
||||
result.PreviousSnapshotId.Should().BeNull();
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PinSnapshotAsync_WithConflict_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var snapshotId = "snapshot-2024-002";
|
||||
var sourceId = Guid.NewGuid();
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.IsCursorConflictAsync("test-site-01", snapshotId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
// Act
|
||||
var result = await _service.PinSnapshotAsync(snapshotId, sourceId, null);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("conflict");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PinSnapshotAsync_WithPreviousSnapshot_ReturnsPreviousId()
|
||||
{
|
||||
// Arrange
|
||||
var snapshotId = "snapshot-2024-003";
|
||||
var previousSnapshotId = "snapshot-2024-002";
|
||||
var sourceId = Guid.NewGuid();
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.IsCursorConflictAsync("test-site-01", snapshotId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.GetLatestAsync("test-site-01", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SyncLedgerEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SiteId = "test-site-01",
|
||||
Cursor = previousSnapshotId,
|
||||
BundleHash = "sha256:prev"
|
||||
});
|
||||
|
||||
_snapshotRepositoryMock
|
||||
.Setup(x => x.InsertAsync(It.IsAny<FeedSnapshotEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FeedSnapshotEntity e, CancellationToken _) => e);
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.AdvanceCursorAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<DateTimeOffset>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _service.PinSnapshotAsync(snapshotId, sourceId, null);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.PreviousSnapshotId.Should().Be(previousSnapshotId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RollbackSnapshotAsync_WithPreviousSnapshot_RollsBack()
|
||||
{
|
||||
// Arrange
|
||||
var snapshotId = "snapshot-2024-003";
|
||||
var previousSnapshotId = "snapshot-2024-002";
|
||||
var sourceId = Guid.NewGuid();
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.GetHistoryAsync("test-site-01", 2, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<SyncLedgerEntity>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SiteId = "test-site-01",
|
||||
Cursor = snapshotId,
|
||||
BundleHash = "sha256:current"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SiteId = "test-site-01",
|
||||
Cursor = previousSnapshotId,
|
||||
BundleHash = "sha256:prev"
|
||||
}
|
||||
});
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.AdvanceCursorAsync(
|
||||
"test-site-01",
|
||||
previousSnapshotId,
|
||||
"sha256:prev",
|
||||
0,
|
||||
It.IsAny<DateTimeOffset>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _service.RollbackSnapshotAsync(snapshotId, sourceId, "Ingestion failed");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.RolledBackToSnapshotId.Should().Be(previousSnapshotId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RollbackSnapshotAsync_NoPreviousSnapshot_ReturnsNullRolledBackTo()
|
||||
{
|
||||
// Arrange
|
||||
var snapshotId = "snapshot-2024-001";
|
||||
var sourceId = Guid.NewGuid();
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.GetHistoryAsync("test-site-01", 2, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<SyncLedgerEntity>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SiteId = "test-site-01",
|
||||
Cursor = snapshotId,
|
||||
BundleHash = "sha256:current"
|
||||
}
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.RollbackSnapshotAsync(snapshotId, sourceId, "First snapshot failed");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.RolledBackToSnapshotId.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetPinnedSnapshotAsync_WithSnapshot_ReturnsInfo()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
var snapshotId = "snapshot-2024-001";
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.GetLatestAsync("test-site-01", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SyncLedgerEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SiteId = "test-site-01",
|
||||
Cursor = snapshotId,
|
||||
BundleHash = "sha256:abc"
|
||||
});
|
||||
|
||||
_snapshotRepositoryMock
|
||||
.Setup(x => x.GetBySourceAndIdAsync(sourceId, snapshotId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FeedSnapshotEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SourceId = sourceId,
|
||||
SnapshotId = snapshotId,
|
||||
Checksum = "sha256:abc",
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.GetPinnedSnapshotAsync(sourceId);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.SnapshotId.Should().Be(snapshotId);
|
||||
result.SourceId.Should().Be(sourceId);
|
||||
result.IsActive.Should().BeTrue();
|
||||
result.SiteId.Should().Be("test-site-01");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetPinnedSnapshotAsync_NoSnapshot_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.GetLatestAsync("test-site-01", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SyncLedgerEntity?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetPinnedSnapshotAsync(sourceId);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CanApplySnapshotAsync_NoConflict_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var snapshotId = "snapshot-2024-001";
|
||||
var sourceId = Guid.NewGuid();
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.IsCursorConflictAsync("test-site-01", snapshotId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.CanApplySnapshotAsync(snapshotId, sourceId);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CanApplySnapshotAsync_WithConflict_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var snapshotId = "snapshot-2024-001";
|
||||
var sourceId = Guid.NewGuid();
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.IsCursorConflictAsync("test-site-01", snapshotId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
// Act
|
||||
var result = await _service.CanApplySnapshotAsync(snapshotId, sourceId);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TryAcquirePinningLockAsync_ReturnsDisposable()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var lockHandle = await _service.TryAcquirePinningLockAsync(sourceId, TimeSpan.FromSeconds(30));
|
||||
|
||||
// Assert
|
||||
lockHandle.Should().NotBeNull();
|
||||
await lockHandle!.DisposeAsync(); // Should not throw
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_NullSnapshotRepository_Throws()
|
||||
{
|
||||
// Act
|
||||
var act = () => new FeedSnapshotPinningService(
|
||||
null!,
|
||||
_syncLedgerRepositoryMock.Object,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<FeedSnapshotPinningService>.Instance);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>().WithParameterName("snapshotRepository");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_NullSyncLedgerRepository_Throws()
|
||||
{
|
||||
// Act
|
||||
var act = () => new FeedSnapshotPinningService(
|
||||
_snapshotRepositoryMock.Object,
|
||||
null!,
|
||||
Options.Create(_options),
|
||||
_timeProvider,
|
||||
NullLogger<FeedSnapshotPinningService>.Instance);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>().WithParameterName("syncLedgerRepository");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PinSnapshotAsync_DeterministicOutput_SameInputsSameResult()
|
||||
{
|
||||
// Arrange
|
||||
var snapshotId = "determinism-test-001";
|
||||
var sourceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var checksum = "sha256:deterministic";
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.IsCursorConflictAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.GetLatestAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((SyncLedgerEntity?)null);
|
||||
|
||||
_snapshotRepositoryMock
|
||||
.Setup(x => x.InsertAsync(It.IsAny<FeedSnapshotEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FeedSnapshotEntity e, CancellationToken _) => e);
|
||||
|
||||
_syncLedgerRepositoryMock
|
||||
.Setup(x => x.AdvanceCursorAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<DateTimeOffset>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result1 = await _service.PinSnapshotAsync(snapshotId, sourceId, checksum);
|
||||
var result2 = await _service.PinSnapshotAsync(snapshotId, sourceId, checksum);
|
||||
|
||||
// Assert
|
||||
result1.Success.Should().Be(result2.Success);
|
||||
result1.SiteId.Should().Be(result2.SiteId);
|
||||
result1.PinnedAt.Should().Be(result2.PinnedAt); // Same time provider
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
// <copyright file="SnapshotIngestionOrchestratorTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Core.Federation;
|
||||
using StellaOps.Replay.Core.FeedSnapshot;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Tests.Federation;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SnapshotIngestionOrchestrator.
|
||||
/// Sprint: SPRINT_20260208_035_Concelier_feed_snapshot_coordinator
|
||||
/// Task: T2 - Wire API/CLI/UI integration
|
||||
/// </summary>
|
||||
public sealed class SnapshotIngestionOrchestratorTests
|
||||
{
|
||||
private readonly Mock<IFeedSnapshotCoordinator> _coordinatorMock;
|
||||
private readonly Mock<IFeedSnapshotPinningService> _pinningServiceMock;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly SnapshotIngestionOrchestrator _orchestrator;
|
||||
|
||||
public SnapshotIngestionOrchestratorTests()
|
||||
{
|
||||
_coordinatorMock = new Mock<IFeedSnapshotCoordinator>(MockBehavior.Strict);
|
||||
_pinningServiceMock = new Mock<IFeedSnapshotPinningService>(MockBehavior.Strict);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 6, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_orchestrator = new SnapshotIngestionOrchestrator(
|
||||
_coordinatorMock.Object,
|
||||
_pinningServiceMock.Object,
|
||||
_timeProvider,
|
||||
NullLogger<SnapshotIngestionOrchestrator>.Instance);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ImportWithRollbackAsync_Success_ReturnsSuccessResult()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
var bundle = CreateTestBundle("snapshot-001");
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
SetupSuccessfulImportScenario(sourceId, bundle);
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.ImportWithRollbackAsync(stream, null, sourceId);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Bundle.Should().Be(bundle);
|
||||
result.SnapshotId.Should().Be("snapshot-001");
|
||||
result.WasRolledBack.Should().BeFalse();
|
||||
result.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ImportWithRollbackAsync_LockAcquisitionFails_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.TryAcquirePinningLockAsync(sourceId, It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IAsyncDisposable?)null);
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.ImportWithRollbackAsync(stream, null, sourceId);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("lock");
|
||||
result.WasRolledBack.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ImportWithRollbackAsync_ConflictDetected_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
var lockMock = new Mock<IAsyncDisposable>();
|
||||
lockMock.Setup(x => x.DisposeAsync()).Returns(ValueTask.CompletedTask);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.TryAcquirePinningLockAsync(sourceId, It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lockMock.Object);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.CanApplySnapshotAsync(It.IsAny<string>(), sourceId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.ImportWithRollbackAsync(stream, null, sourceId);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("conflict");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ImportWithRollbackAsync_PinningFails_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
var lockMock = new Mock<IAsyncDisposable>();
|
||||
lockMock.Setup(x => x.DisposeAsync()).Returns(ValueTask.CompletedTask);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.TryAcquirePinningLockAsync(sourceId, It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lockMock.Object);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.CanApplySnapshotAsync(It.IsAny<string>(), sourceId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.PinSnapshotAsync(It.IsAny<string>(), sourceId, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SnapshotPinResult(
|
||||
Success: false,
|
||||
SnapshotId: null,
|
||||
SiteId: "test-site",
|
||||
PinnedAt: _timeProvider.GetUtcNow(),
|
||||
PreviousSnapshotId: null,
|
||||
Error: "Pinning failed"));
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.ImportWithRollbackAsync(stream, null, sourceId);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("pin");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ImportWithRollbackAsync_ImportFails_RollsBackAndReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
var lockMock = new Mock<IAsyncDisposable>();
|
||||
lockMock.Setup(x => x.DisposeAsync()).Returns(ValueTask.CompletedTask);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.TryAcquirePinningLockAsync(sourceId, It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lockMock.Object);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.CanApplySnapshotAsync(It.IsAny<string>(), sourceId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.PinSnapshotAsync(It.IsAny<string>(), sourceId, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SnapshotPinResult(
|
||||
Success: true,
|
||||
SnapshotId: "temp-snapshot",
|
||||
SiteId: "test-site",
|
||||
PinnedAt: _timeProvider.GetUtcNow(),
|
||||
PreviousSnapshotId: "prev-snapshot",
|
||||
Error: null));
|
||||
|
||||
_coordinatorMock
|
||||
.Setup(x => x.ImportBundleAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Import failed: invalid bundle format"));
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.RollbackSnapshotAsync(It.IsAny<string>(), sourceId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SnapshotRollbackResult(
|
||||
Success: true,
|
||||
RolledBackToSnapshotId: "prev-snapshot",
|
||||
RolledBackAt: _timeProvider.GetUtcNow(),
|
||||
Error: null));
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.ImportWithRollbackAsync(stream, null, sourceId);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.WasRolledBack.Should().BeTrue();
|
||||
result.RolledBackToSnapshotId.Should().Be("prev-snapshot");
|
||||
result.Error.Should().Contain("invalid bundle format");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateWithPinningAsync_Success_ReturnsSuccessResult()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
var bundle = CreateTestBundle("snapshot-002");
|
||||
|
||||
var lockMock = new Mock<IAsyncDisposable>();
|
||||
lockMock.Setup(x => x.DisposeAsync()).Returns(ValueTask.CompletedTask);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.TryAcquirePinningLockAsync(sourceId, It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lockMock.Object);
|
||||
|
||||
_coordinatorMock
|
||||
.Setup(x => x.CreateSnapshotAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(bundle);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.PinSnapshotAsync("snapshot-002", sourceId, bundle.CompositeDigest, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SnapshotPinResult(
|
||||
Success: true,
|
||||
SnapshotId: "snapshot-002",
|
||||
SiteId: "test-site",
|
||||
PinnedAt: _timeProvider.GetUtcNow(),
|
||||
PreviousSnapshotId: null,
|
||||
Error: null));
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.CreateWithPinningAsync(sourceId, "test-label");
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Bundle.Should().Be(bundle);
|
||||
result.SnapshotId.Should().Be("snapshot-002");
|
||||
result.WasRolledBack.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateWithPinningAsync_LockAcquisitionFails_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.TryAcquirePinningLockAsync(sourceId, It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((IAsyncDisposable?)null);
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.CreateWithPinningAsync(sourceId);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("lock");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateWithPinningAsync_CreateFails_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var sourceId = Guid.NewGuid();
|
||||
|
||||
var lockMock = new Mock<IAsyncDisposable>();
|
||||
lockMock.Setup(x => x.DisposeAsync()).Returns(ValueTask.CompletedTask);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.TryAcquirePinningLockAsync(sourceId, It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lockMock.Object);
|
||||
|
||||
_coordinatorMock
|
||||
.Setup(x => x.CreateSnapshotAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Snapshot creation failed"));
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.CreateWithPinningAsync(sourceId);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Snapshot creation failed");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_NullCoordinator_Throws()
|
||||
{
|
||||
// Act
|
||||
var act = () => new SnapshotIngestionOrchestrator(
|
||||
null!,
|
||||
_pinningServiceMock.Object,
|
||||
_timeProvider,
|
||||
NullLogger<SnapshotIngestionOrchestrator>.Instance);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>().WithParameterName("coordinator");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_NullPinningService_Throws()
|
||||
{
|
||||
// Act
|
||||
var act = () => new SnapshotIngestionOrchestrator(
|
||||
_coordinatorMock.Object,
|
||||
null!,
|
||||
_timeProvider,
|
||||
NullLogger<SnapshotIngestionOrchestrator>.Instance);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>().WithParameterName("pinningService");
|
||||
}
|
||||
|
||||
private void SetupSuccessfulImportScenario(Guid sourceId, FeedSnapshotBundle bundle)
|
||||
{
|
||||
var lockMock = new Mock<IAsyncDisposable>();
|
||||
lockMock.Setup(x => x.DisposeAsync()).Returns(ValueTask.CompletedTask);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.TryAcquirePinningLockAsync(sourceId, It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(lockMock.Object);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.CanApplySnapshotAsync(It.IsAny<string>(), sourceId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
_pinningServiceMock
|
||||
.Setup(x => x.PinSnapshotAsync(It.IsAny<string>(), sourceId, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new SnapshotPinResult(
|
||||
Success: true,
|
||||
SnapshotId: bundle.SnapshotId,
|
||||
SiteId: "test-site",
|
||||
PinnedAt: _timeProvider.GetUtcNow(),
|
||||
PreviousSnapshotId: null,
|
||||
Error: null));
|
||||
|
||||
_coordinatorMock
|
||||
.Setup(x => x.ImportBundleAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(bundle);
|
||||
}
|
||||
|
||||
private FeedSnapshotBundle CreateTestBundle(string snapshotId)
|
||||
{
|
||||
return new FeedSnapshotBundle(
|
||||
SnapshotId: snapshotId,
|
||||
CompositeDigest: $"sha256:{Guid.NewGuid():N}",
|
||||
CreatedAt: _timeProvider.GetUtcNow(),
|
||||
Label: "test-bundle",
|
||||
Sources: new[]
|
||||
{
|
||||
new FeedSourceSnapshot(
|
||||
SourceId: "nvd",
|
||||
Digest: $"sha256:{Guid.NewGuid():N}",
|
||||
ItemCount: 100,
|
||||
CapturedAt: _timeProvider.GetUtcNow())
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user