// // Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later. // using System.Collections.Immutable; using FluentAssertions; using Microsoft.Extensions.Time.Testing; using StellaOps.Scanner.Emit.Pedigree; using Xunit; namespace StellaOps.Scanner.Emit.Tests.Pedigree; /// /// Unit tests for pedigree builder classes. /// Sprint: SPRINT_20260107_005_002 Task PD-011 /// [Trait("Category", "Unit")] public sealed class PedigreeBuilderTests { private static readonly DateTimeOffset FixedTime = new(2026, 1, 8, 12, 0, 0, TimeSpan.Zero); #region AncestorComponentBuilder Tests [Fact] public void AncestorBuilder_AddAncestor_CreatesComponent() { // Arrange var builder = new AncestorComponentBuilder(); // Act var ancestors = builder .AddAncestor("openssl", "1.1.1n") .Build(); // Assert ancestors.Should().HaveCount(1); ancestors[0].Name.Should().Be("openssl"); ancestors[0].Version.Should().Be("1.1.1n"); ancestors[0].Level.Should().Be(1); } [Fact] public void AncestorBuilder_AddGenericUpstream_CreatesPurl() { // Arrange var builder = new AncestorComponentBuilder(); // Act var ancestors = builder .AddGenericUpstream("openssl", "1.1.1n", "https://www.openssl.org") .Build(); // Assert ancestors[0].Purl.Should().Be("pkg:generic/openssl@1.1.1n"); ancestors[0].ProjectUrl.Should().Be("https://www.openssl.org"); } [Fact] public void AncestorBuilder_AddGitHubUpstream_CreatesGitHubPurl() { // Arrange var builder = new AncestorComponentBuilder(); // Act var ancestors = builder .AddGitHubUpstream("openssl", "openssl", "openssl-3.0.0") .Build(); // Assert ancestors[0].Purl.Should().Be("pkg:github/openssl/openssl@openssl-3.0.0"); ancestors[0].ProjectUrl.Should().Be("https://github.com/openssl/openssl"); } [Fact] public void AncestorBuilder_AddAncestryChain_SetsLevels() { // Arrange var builder = new AncestorComponentBuilder(); // Act var ancestors = builder .AddAncestryChain( ("parent", "2.0", "pkg:generic/parent@2.0"), ("grandparent", "1.0", "pkg:generic/grandparent@1.0")) .Build(); // Assert ancestors.Should().HaveCount(2); ancestors[0].Level.Should().Be(1); ancestors[0].Name.Should().Be("parent"); ancestors[1].Level.Should().Be(2); ancestors[1].Name.Should().Be("grandparent"); } #endregion #region VariantComponentBuilder Tests [Fact] public void VariantBuilder_AddDebianPackage_CreatesPurl() { // Arrange var builder = new VariantComponentBuilder(); // Act var variants = builder .AddDebianPackage("openssl", "1.1.1n-0+deb11u5", "bullseye", "amd64") .Build(); // Assert variants.Should().HaveCount(1); variants[0].Distribution.Should().Be("debian"); variants[0].Release.Should().Be("bullseye"); variants[0].Purl.Should().Contain("pkg:deb/debian/openssl"); variants[0].Purl.Should().Contain("distro=debian-bullseye"); variants[0].Purl.Should().Contain("arch=amd64"); } [Fact] public void VariantBuilder_AddRpmPackage_CreatesPurl() { // Arrange var builder = new VariantComponentBuilder(); // Act var variants = builder .AddRpmPackage("openssl", "1.1.1k-9.el9", "rhel", "9", "x86_64") .Build(); // Assert variants[0].Distribution.Should().Be("rhel"); variants[0].Purl.Should().Contain("pkg:rpm/rhel/openssl"); } [Fact] public void VariantBuilder_AddAlpinePackage_CreatesPurl() { // Arrange var builder = new VariantComponentBuilder(); // Act var variants = builder .AddAlpinePackage("openssl", "3.0.12-r4", "3.19") .Build(); // Assert variants[0].Distribution.Should().Be("alpine"); variants[0].Purl.Should().Contain("pkg:apk/alpine/openssl"); } [Fact] public void VariantBuilder_MultipleDistros_OrdersByDistribution() { // Arrange var builder = new VariantComponentBuilder(); // Act var variants = builder .AddDebianPackage("pkg", "1.0", "bookworm") .AddAlpinePackage("pkg", "1.0", "3.19") .AddRpmPackage("pkg", "1.0", "rhel", "9") .Build(); // Assert variants[0].Distribution.Should().Be("alpine"); variants[1].Distribution.Should().Be("debian"); variants[2].Distribution.Should().Be("rhel"); } #endregion #region CommitInfoBuilder Tests [Fact] public void CommitBuilder_AddCommit_CreatesCommitInfo() { // Arrange var builder = new CommitInfoBuilder(); // Act var commits = builder .AddCommit("abc123def456", "https://github.com/org/repo/commit/abc123", "Fix bug") .Build(); // Assert commits.Should().HaveCount(1); commits[0].Uid.Should().Be("abc123def456"); commits[0].Message.Should().Be("Fix bug"); } [Fact] public void CommitBuilder_AddGitHubCommit_GeneratesUrl() { // Arrange var builder = new CommitInfoBuilder(); // Act var commits = builder .AddGitHubCommit("openssl", "openssl", "abc123def") .Build(); // Assert commits[0].Url.Should().Be("https://github.com/openssl/openssl/commit/abc123def"); } [Fact] public void CommitBuilder_AddCommitWithCveExtraction_ExtractsCves() { // Arrange var builder = new CommitInfoBuilder(); // Act var commits = builder .AddCommitWithCveExtraction( "abc123", null, "Fix CVE-2024-1234 and CVE-2024-5678") .Build(); // Assert commits[0].ResolvesCves.Should().BeEquivalentTo(new[] { "CVE-2024-1234", "CVE-2024-5678" }); } [Fact] public void CommitBuilder_NormalizesShaTolowercase() { // Arrange var builder = new CommitInfoBuilder(); // Act var commits = builder .AddCommit("ABC123DEF456") .Build(); // Assert commits[0].Uid.Should().Be("abc123def456"); } [Fact] public void CommitBuilder_TruncatesLongMessage() { // Arrange var builder = new CommitInfoBuilder(); var longMessage = new string('x', 1000); // Act var commits = builder .AddCommit("abc123", message: longMessage) .Build(); // Assert commits[0].Message!.Length.Should().BeLessThan(550); commits[0].Message.Should().EndWith("..."); } #endregion #region PatchInfoBuilder Tests [Fact] public void PatchBuilder_AddBackport_CreatesPatchInfo() { // Arrange var builder = new PatchInfoBuilder(); // Act var patches = builder .AddBackport( diffUrl: "https://patch.url/fix.patch", resolvesCves: new[] { "CVE-2024-1234" }, source: "debian-security") .Build(); // Assert patches.Should().HaveCount(1); patches[0].Type.Should().Be(PatchType.Backport); patches[0].Resolves.Should().ContainSingle(r => r.Id == "CVE-2024-1234"); patches[0].Source.Should().Be("debian-security"); } [Fact] public void PatchBuilder_AddFromFeedserOrigin_MapsTypes() { // Arrange var builder = new PatchInfoBuilder(); // Act var patches = builder .AddFromFeedserOrigin("upstream") .AddFromFeedserOrigin("distro") .AddFromFeedserOrigin("vendor") .Build(); // Assert patches[0].Type.Should().Be(PatchType.CherryPick); patches[1].Type.Should().Be(PatchType.Backport); patches[2].Type.Should().Be(PatchType.Unofficial); } [Fact] public void PatchBuilder_DeterminesSourceName() { // Arrange var builder = new PatchInfoBuilder(); // Act var patches = builder .AddBackport(resolvesCves: new[] { "CVE-2024-1234", "GHSA-xxxx-yyyy-zzzz" }) .Build(); // Assert patches[0].Resolves.Should().Contain(r => r.Id == "CVE-2024-1234" && r.SourceName == "NVD"); patches[0].Resolves.Should().Contain(r => r.Id == "GHSA-XXXX-YYYY-ZZZZ" && r.SourceName == "GitHub"); } #endregion #region PedigreeNotesGenerator Tests [Fact] public void NotesGenerator_GeneratesBackportSummary() { // Arrange var timeProvider = new FakeTimeProvider(FixedTime); var generator = new PedigreeNotesGenerator(timeProvider); var data = new PedigreeData { Patches = new[] { new PatchInfo { Type = PatchType.Backport }, new PatchInfo { Type = PatchType.Backport } }.ToImmutableArray() }; // Act var notes = generator.GenerateNotes(data); // Assert notes.Should().Contain("2 backports"); } [Fact] public void NotesGenerator_IncludesConfidenceAndTier() { // Arrange var timeProvider = new FakeTimeProvider(FixedTime); var generator = new PedigreeNotesGenerator(timeProvider); var data = new PedigreeData { Ancestors = new[] { new AncestorComponent { Name = "test", Version = "1.0" } }.ToImmutableArray() }; // Act var notes = generator.GenerateNotes(data, confidencePercent: 95, feedserTier: 1); // Assert notes.Should().Contain("confidence 95%"); notes.Should().Contain("Tier 1 (exact match)"); } [Fact] public void NotesGenerator_GenerateSummaryLine_CreatesConciseSummary() { // Arrange var timeProvider = new FakeTimeProvider(FixedTime); var generator = new PedigreeNotesGenerator(timeProvider); var data = new PedigreeData { Patches = new[] { new PatchInfo { Type = PatchType.Backport } }.ToImmutableArray(), Ancestors = new[] { new AncestorComponent { Name = "openssl", Version = "1.1.1n" } }.ToImmutableArray() }; // Act var summary = generator.GenerateSummaryLine(data); // Assert summary.Should().Contain("1 backport"); summary.Should().Contain("from openssl 1.1.1n"); } #endregion }