partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

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

View File

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

View File

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

View File

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