sprints and audit work

This commit is contained in:
StellaOps Bot
2026-01-07 09:36:16 +02:00
parent 05833e0af2
commit ab364c6032
377 changed files with 64534 additions and 1627 deletions

View File

@@ -0,0 +1,215 @@
using StellaOps.Unknowns.Core.Hints;
using StellaOps.Unknowns.Core.Models;
using Xunit;
using FluentAssertions;
namespace StellaOps.Unknowns.Core.Tests.Hints;
/// <summary>
/// Tests for hint combination logic and confidence aggregation.
/// </summary>
public sealed class HintCombinationTests
{
private readonly ProvenanceHintBuilder _builder = new(TimeProvider.System);
[Fact]
public void CombineHints_EmptyList_ReturnsZeroConfidence()
{
// Act
var (hypothesis, confidence) = _builder.CombineHints([]);
// Assert
hypothesis.Should().Be("No provenance hints available");
confidence.Should().Be(0.0);
}
[Fact]
public void CombineHints_SingleHighConfidenceHint_ReturnsHypothesisAndConfidence()
{
// Arrange
var hints = new[]
{
CreateBuildIdHint("openssl", 0.95)
};
// Act
var (hypothesis, confidence) = _builder.CombineHints(hints);
// Assert
hypothesis.Should().Contain("openssl");
confidence.Should().Be(0.95);
}
[Fact]
public void CombineHints_MultipleAgreeingHints_BoostsConfidence()
{
// Arrange - all hints point to same package
var hints = new[]
{
CreateBuildIdHint("openssl", 0.85),
CreateImportHint("openssl", 0.80),
CreateVersionHint("openssl", 0.70)
};
// Act
var (hypothesis, confidence) = _builder.CombineHints(hints);
// Assert
confidence.Should().BeGreaterThan(0.85); // Boosted from multiple agreeing hints
hypothesis.Should().Contain("confirmed by");
hypothesis.Should().Contain("3 evidence sources");
}
[Fact]
public void CombineHints_MultipleDisagreeingHints_UsesBestSingleHint()
{
// Arrange - hints point to different packages
var hints = new[]
{
CreateBuildIdHint("openssl", 0.95),
CreateImportHint("curl", 0.80),
CreateVersionHint("wget", 0.70)
};
// Act
var (hypothesis, confidence) = _builder.CombineHints(hints);
// Assert
confidence.Should().Be(0.95); // Highest single hint
hypothesis.Should().Contain("openssl"); // Best match
hypothesis.Should().NotContain("confirmed by"); // No agreement
}
[Fact]
public void CombineHints_TwoAgreeingHighConfidence_CombinesConfidence()
{
// Arrange
var hints = new[]
{
CreateBuildIdHint("curl", 0.90),
CreateVersionHint("curl", 0.75)
};
// Act
var (hypothesis, confidence) = _builder.CombineHints(hints);
// Assert
confidence.Should().BeGreaterThan(0.90);
confidence.Should().BeLessThan(1.0); // Capped at 0.99
hypothesis.Should().Contain("confirmed by");
hypothesis.Should().Contain("2 evidence sources");
}
[Fact]
public void CombineHints_OneLowConfidenceOneHigh_UsesHighConfidenceOnly()
{
// Arrange
var hints = new[]
{
CreateBuildIdHint("openssl", 0.95),
CreateVersionHint("openssl", 0.25) // Below 0.5 threshold
};
// Act
var (hypothesis, confidence) = _builder.CombineHints(hints);
// Assert
confidence.Should().Be(0.95); // Only high-confidence hint used
hypothesis.Should().NotContain("confirmed by"); // Low confidence ignored
}
[Fact]
public void CombineHints_ThreeAgreeingHints_DoesNotExceed099()
{
// Arrange - many agreeing high-confidence hints
var hints = new[]
{
CreateBuildIdHint("nginx", 0.95),
CreateImportHint("nginx", 0.92),
CreateVersionHint("nginx", 0.88),
CreateCorpusHint("nginx", 0.85)
};
// Act
var (hypothesis, confidence) = _builder.CombineHints(hints);
// Assert
confidence.Should().BeLessThanOrEqualTo(0.99);
hypothesis.Should().Contain("confirmed by");
hypothesis.Should().Contain("4 evidence sources");
}
[Fact]
public void CombineHints_MixedConfidencesSamePackage_CountsOnlyHighConfidence()
{
// Arrange
var hints = new[]
{
CreateBuildIdHint("bash", 0.90), // High
CreateImportHint("bash", 0.60), // Medium
CreateVersionHint("bash", 0.30) // Low (excluded)
};
// Act
var (hypothesis, confidence) = _builder.CombineHints(hints);
// Assert
hypothesis.Should().Contain("confirmed by");
hypothesis.Should().Contain("2 evidence sources"); // Only high+medium
}
// Helper methods to create test hints
private ProvenanceHint CreateBuildIdHint(string package, double confidence)
{
var match = new BuildIdMatchResult
{
Package = package,
Version = "1.0.0",
Distro = "debian"
};
return _builder.BuildFromBuildId("test-build-id", "sha1", match);
}
private ProvenanceHint CreateImportHint(string package, double similarity)
{
var matches = new[]
{
new FingerprintMatch
{
Package = package,
Version = "1.0.0",
Similarity = similarity,
Source = "test-corpus"
}
};
return _builder.BuildFromImportFingerprint("fp-test", new[] { "lib1.so" }, matches);
}
private ProvenanceHint CreateVersionHint(string package, double confidence)
{
var versionStrings = new[]
{
new ExtractedVersionString
{
Value = $"{package} 1.0.0",
Location = ".rodata",
Confidence = confidence
}
};
return _builder.BuildFromVersionStrings(versionStrings);
}
private ProvenanceHint CreateCorpusHint(string package, double similarity)
{
return _builder.BuildFromCorpusMatch(
"test-corpus",
$"{package}/1.0.0",
"hash",
similarity,
null);
}
}

View File

@@ -0,0 +1,281 @@
using StellaOps.Unknowns.Core.Hints;
using StellaOps.Unknowns.Core.Models;
using Xunit;
using FluentAssertions;
namespace StellaOps.Unknowns.Core.Tests.Hints;
/// <summary>
/// Tests for ProvenanceHintBuilder - all hint building scenarios.
/// </summary>
public sealed class ProvenanceHintBuilderTests
{
private readonly ProvenanceHintBuilder _builder = new(TimeProvider.System);
[Fact]
public void BuildFromBuildId_WithMatch_CreatesVeryHighConfidenceHint()
{
// Arrange
var match = new BuildIdMatchResult
{
Package = "openssl",
Version = "1.1.1k",
Distro = "debian",
CatalogSource = "debian-security"
};
// Act
var hint = _builder.BuildFromBuildId("abc123", "sha1", match);
// Assert
hint.Type.Should().Be(ProvenanceHintType.BuildIdMatch);
hint.Confidence.Should().Be(0.95);
hint.ConfidenceLevel.Should().Be(HintConfidence.VeryHigh);
hint.Hypothesis.Should().Contain("openssl");
hint.Hypothesis.Should().Contain("1.1.1k");
hint.Hypothesis.Should().Contain("debian");
hint.Evidence.BuildId.Should().NotBeNull();
hint.Evidence.BuildId!.BuildId.Should().Be("abc123");
hint.Evidence.BuildId.MatchedPackage.Should().Be("openssl");
hint.SuggestedActions.Should().HaveCountGreaterOrEqualTo(1);
hint.SuggestedActions[0].Action.Should().Be("verify_build_id");
hint.HintId.Should().StartWith("hint:sha256:");
}
[Fact]
public void BuildFromBuildId_WithoutMatch_CreatesLowConfidenceHint()
{
// Act
var hint = _builder.BuildFromBuildId("unknown123", "sha1", null);
// Assert
hint.Confidence.Should().Be(0.2);
hint.ConfidenceLevel.Should().Be(HintConfidence.VeryLow);
hint.Hypothesis.Should().Contain("no catalog match");
hint.Evidence.BuildId!.MatchedPackage.Should().BeNull();
hint.SuggestedActions.Should().Contain(a => a.Action == "expand_catalog");
}
[Fact]
public void BuildFromImportFingerprint_WithMatch_IncludesMatchedPackage()
{
// Arrange
var matches = new[]
{
new FingerprintMatch
{
Package = "libc6",
Version = "2.31",
Similarity = 0.92,
Source = "debian-corpus"
}
};
var imports = new[] { "libc.so.6", "libpthread.so.0" };
// Act
var hint = _builder.BuildFromImportFingerprint("fp-abc", imports, matches);
// Assert
hint.Type.Should().Be(ProvenanceHintType.ImportTableFingerprint);
hint.Confidence.Should().Be(0.92);
hint.ConfidenceLevel.Should().Be(HintConfidence.VeryHigh);
hint.Hypothesis.Should().Contain("libc6");
hint.Hypothesis.Should().Contain("2.31");
hint.Evidence.ImportFingerprint.Should().NotBeNull();
hint.Evidence.ImportFingerprint!.ImportedLibraries.Should().HaveCount(2);
hint.Evidence.ImportFingerprint.MatchedFingerprints.Should().HaveCount(1);
}
[Fact]
public void BuildFromImportFingerprint_WithoutMatch_CreatesMediumConfidenceHint()
{
// Arrange
var imports = new[] { "unknown.so.1" };
// Act
var hint = _builder.BuildFromImportFingerprint("fp-xyz", imports, null);
// Assert
hint.Confidence.Should().Be(0.3);
hint.ConfidenceLevel.Should().Be(HintConfidence.Low);
hint.Hypothesis.Should().Contain("fp-xyz");
hint.Evidence.ImportFingerprint!.MatchedFingerprints.Should().BeNull();
}
[Fact]
public void BuildFromSectionLayout_WithMatch_IncludesSimilarity()
{
// Arrange
var sections = new[]
{
new SectionInfo { Name = ".text", Type = "PROGBITS", Size = 0x1000 },
new SectionInfo { Name = ".data", Type = "PROGBITS", Size = 0x200 }
};
var matches = new[]
{
new LayoutMatch { Package = "bash", Similarity = 0.88 }
};
// Act
var hint = _builder.BuildFromSectionLayout(sections, matches);
// Assert
hint.Type.Should().Be(ProvenanceHintType.SectionLayout);
hint.Confidence.Should().Be(0.88);
hint.ConfidenceLevel.Should().Be(HintConfidence.VeryHigh);
hint.Hypothesis.Should().Contain("bash");
hint.Evidence.SectionLayout.Should().NotBeNull();
hint.Evidence.SectionLayout!.Sections.Should().HaveCount(2);
hint.Evidence.SectionLayout.LayoutHash.Should().NotBeNullOrEmpty();
}
[Fact]
public void BuildFromDistroPattern_IncludesDistroAndRelease()
{
// Act
var hint = _builder.BuildFromDistroPattern("debian", "bullseye", "rpath", "/usr/lib/x86_64-linux-gnu");
// Assert
hint.Type.Should().Be(ProvenanceHintType.DistroPattern);
hint.Confidence.Should().Be(0.7);
hint.ConfidenceLevel.Should().Be(HintConfidence.High);
hint.Hypothesis.Should().Contain("debian");
hint.Hypothesis.Should().Contain("bullseye");
hint.Evidence.DistroPattern.Should().NotBeNull();
hint.Evidence.DistroPattern!.Distro.Should().Be("debian");
hint.Evidence.DistroPattern.Release.Should().Be("bullseye");
hint.SuggestedActions[0].Link.Should().NotBeNull();
}
[Fact]
public void BuildFromVersionStrings_WithMultipleStrings_SelectsBestGuess()
{
// Arrange
var versionStrings = new[]
{
new ExtractedVersionString { Value = "1.2.3", Location = ".rodata", Confidence = 0.8 },
new ExtractedVersionString { Value = "1.2", Location = ".comment", Confidence = 0.5 }
};
// Act
var hint = _builder.BuildFromVersionStrings(versionStrings);
// Assert
hint.Type.Should().Be(ProvenanceHintType.VersionString);
hint.Confidence.Should().Be(0.8);
hint.ConfidenceLevel.Should().Be(HintConfidence.High);
hint.Hypothesis.Should().Contain("1.2.3");
hint.Evidence.VersionString.Should().NotBeNull();
hint.Evidence.VersionString!.BestGuess.Should().Be("1.2.3");
hint.Evidence.VersionString.VersionStrings.Should().HaveCount(2);
}
[Fact]
public void BuildFromCorpusMatch_HighSimilarity_CreatesVeryHighConfidence()
{
// Act
var hint = _builder.BuildFromCorpusMatch(
"debian-packages",
"curl/7.68.0",
"hash",
0.95,
new Dictionary<string, string> { ["arch"] = "amd64" });
// Assert
hint.Type.Should().Be(ProvenanceHintType.CorpusMatch);
hint.Confidence.Should().Be(0.95);
hint.ConfidenceLevel.Should().Be(HintConfidence.VeryHigh);
hint.Hypothesis.Should().Contain("High confidence match");
hint.Hypothesis.Should().Contain("curl/7.68.0");
hint.Evidence.CorpusMatch.Should().NotBeNull();
hint.Evidence.CorpusMatch!.CorpusName.Should().Be("debian-packages");
hint.Evidence.CorpusMatch.Metadata.Should().ContainKey("arch");
}
[Fact]
public void CombineHints_NoHints_ReturnsZeroConfidence()
{
// Act
var (hypothesis, confidence) = _builder.CombineHints([]);
// Assert
hypothesis.Should().Contain("No provenance hints");
confidence.Should().Be(0.0);
}
[Fact]
public void CombineHints_SingleHint_ReturnsBestHypothesis()
{
// Arrange
var hints = new[]
{
_builder.BuildFromBuildId("abc123", "sha1", new BuildIdMatchResult
{
Package = "openssl",
Version = "1.1.1k",
Distro = "debian"
})
};
// Act
var (hypothesis, confidence) = _builder.CombineHints(hints);
// Assert
hypothesis.Should().Contain("openssl");
confidence.Should().Be(0.95);
}
[Fact]
public void CombineHints_MultipleAgreeingHints_BoostsConfidence()
{
// Arrange
var buildIdMatch = new BuildIdMatchResult
{
Package = "openssl",
Version = "1.1.1k",
Distro = "debian"
};
var hints = new[]
{
_builder.BuildFromBuildId("abc123", "sha1", buildIdMatch),
_builder.BuildFromDistroPattern("debian", "bullseye", "rpath", "/usr/lib"),
_builder.BuildFromVersionStrings(new[]
{
new ExtractedVersionString { Value = "1.1.1k", Location = ".rodata", Confidence = 0.7 }
})
};
// Act
var (hypothesis, confidence) = _builder.CombineHints(hints);
// Assert
confidence.Should().BeGreaterThan(0.95); // Boosted from multiple agreeing hints
hypothesis.Should().Contain("confirmed by");
hypothesis.Should().Contain("evidence sources");
}
[Fact]
public void HintId_IsContentAddressed_DeterministicForSameInput()
{
// Arrange & Act
var hint1 = _builder.BuildFromBuildId("abc123", "sha1", null);
var hint2 = _builder.BuildFromBuildId("abc123", "sha1", null);
// Assert
hint1.HintId.Should().Be(hint2.HintId);
}
[Fact]
public void HintId_IsDifferent_ForDifferentInput()
{
// Arrange & Act
var hint1 = _builder.BuildFromBuildId("abc123", "sha1", null);
var hint2 = _builder.BuildFromBuildId("xyz789", "sha1", null);
// Assert
hint1.HintId.Should().NotBe(hint2.HintId);
}
}

View File

@@ -0,0 +1,299 @@
using System.Text.Json;
using StellaOps.Unknowns.Core.Hints;
using StellaOps.Unknowns.Core.Models;
using Xunit;
using FluentAssertions;
using System.Text.Json.Serialization;
namespace StellaOps.Unknowns.Core.Tests.Hints;
/// <summary>
/// Golden fixture tests for provenance hint serialization.
/// Ensures stable JSON output for cross-service compatibility.
/// </summary>
public sealed class ProvenanceHintSerializationTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly ProvenanceHintBuilder _builder = new(new FrozenTimeProvider());
[Fact]
public void BuildIdHint_Serialization_ProducesExpectedJson()
{
// Arrange
var match = new BuildIdMatchResult
{
Package = "openssl",
Version = "1.1.1k",
Distro = "debian",
CatalogSource = "debian-security"
};
var hint = _builder.BuildFromBuildId("abc123def456", "sha1", match);
// Act
var json = JsonSerializer.Serialize(hint, JsonOptions);
var deserialized = JsonSerializer.Deserialize<ProvenanceHint>(json, JsonOptions);
// Assert - round-trip
deserialized.Should().NotBeNull();
deserialized!.Type.Should().Be(ProvenanceHintType.BuildIdMatch);
deserialized.Confidence.Should().Be(0.95);
deserialized.ConfidenceLevel.Should().Be(HintConfidence.VeryHigh);
deserialized.Evidence.BuildId.Should().NotBeNull();
deserialized.Evidence.BuildId!.BuildId.Should().Be("abc123def456");
deserialized.Evidence.BuildId.MatchedPackage.Should().Be("openssl");
// Assert - stable keys
json.Should().Contain("\"hint_id\":");
json.Should().Contain("\"type\":");
json.Should().Contain("\"confidence\":");
json.Should().Contain("\"confidence_level\":");
json.Should().Contain("\"hypothesis\":");
json.Should().Contain("\"evidence\":");
json.Should().Contain("\"suggested_actions\":");
json.Should().Contain("\"generated_at\":");
json.Should().Contain("\"source\":");
}
[Fact]
public void ImportFingerprintHint_Serialization_RoundTripsCorrectly()
{
// Arrange
var matches = new[]
{
new FingerprintMatch
{
Package = "libc6",
Version = "2.31-13",
Similarity = 0.92,
Source = "debian-corpus"
}
};
var imports = new[] { "libc.so.6", "libpthread.so.0", "libdl.so.2" };
var hint = _builder.BuildFromImportFingerprint("fp-abc123", imports, matches);
// Act
var json = JsonSerializer.Serialize(hint, JsonOptions);
var deserialized = JsonSerializer.Deserialize<ProvenanceHint>(json, JsonOptions);
// Assert
deserialized.Should().NotBeNull();
deserialized!.Evidence.ImportFingerprint.Should().NotBeNull();
deserialized.Evidence.ImportFingerprint!.Fingerprint.Should().Be("fp-abc123");
deserialized.Evidence.ImportFingerprint.ImportedLibraries.Should().HaveCount(3);
deserialized.Evidence.ImportFingerprint.MatchedFingerprints.Should().HaveCount(1);
deserialized.Evidence.ImportFingerprint.MatchedFingerprints![0].Package.Should().Be("libc6");
deserialized.Evidence.ImportFingerprint.MatchedFingerprints[0].Similarity.Should().Be(0.92);
}
[Fact]
public void SectionLayoutHint_Serialization_PreservesAllSections()
{
// Arrange
var sections = new[]
{
new SectionInfo { Name = ".text", Type = "PROGBITS", Size = 0x1000, Flags = "AX" },
new SectionInfo { Name = ".data", Type = "PROGBITS", Size = 0x200, Flags = "WA" },
new SectionInfo { Name = ".bss", Type = "NOBITS", Size = 0x100, Flags = "WA" }
};
var matches = new[]
{
new LayoutMatch { Package = "bash", Similarity = 0.88 }
};
var hint = _builder.BuildFromSectionLayout(sections, matches);
// Act
var json = JsonSerializer.Serialize(hint, JsonOptions);
var deserialized = JsonSerializer.Deserialize<ProvenanceHint>(json, JsonOptions);
// Assert
deserialized.Should().NotBeNull();
deserialized!.Evidence.SectionLayout.Should().NotBeNull();
deserialized.Evidence.SectionLayout!.Sections.Should().HaveCount(3);
deserialized.Evidence.SectionLayout.Sections[0].Name.Should().Be(".text");
deserialized.Evidence.SectionLayout.Sections[0].Size.Should().Be(0x1000);
deserialized.Evidence.SectionLayout.LayoutHash.Should().NotBeNullOrEmpty();
deserialized.Evidence.SectionLayout.MatchedLayouts.Should().HaveCount(1);
}
[Fact]
public void DistroPatternHint_Serialization_IncludesAllFields()
{
// Arrange
var hint = _builder.BuildFromDistroPattern(
"debian",
"bullseye",
"rpath",
"/usr/lib/x86_64-linux-gnu");
// Act
var json = JsonSerializer.Serialize(hint, JsonOptions);
var deserialized = JsonSerializer.Deserialize<ProvenanceHint>(json, JsonOptions);
// Assert
deserialized.Should().NotBeNull();
deserialized!.Evidence.DistroPattern.Should().NotBeNull();
deserialized.Evidence.DistroPattern!.Distro.Should().Be("debian");
deserialized.Evidence.DistroPattern.Release.Should().Be("bullseye");
deserialized.Evidence.DistroPattern.PatternType.Should().Be("rpath");
deserialized.Evidence.DistroPattern.MatchedPattern.Should().Be("/usr/lib/x86_64-linux-gnu");
}
[Fact]
public void VersionStringHint_Serialization_PreservesAllVersionStrings()
{
// Arrange
var versionStrings = new[]
{
new ExtractedVersionString { Value = "1.2.3", Location = ".rodata", Confidence = 0.8 },
new ExtractedVersionString { Value = "1.2", Location = ".comment", Confidence = 0.5 },
new ExtractedVersionString { Value = "v1.2.3-stable", Location = ".data", Confidence = 0.7 }
};
var hint = _builder.BuildFromVersionStrings(versionStrings);
// Act
var json = JsonSerializer.Serialize(hint, JsonOptions);
var deserialized = JsonSerializer.Deserialize<ProvenanceHint>(json, JsonOptions);
// Assert
deserialized.Should().NotBeNull();
deserialized!.Evidence.VersionString.Should().NotBeNull();
deserialized.Evidence.VersionString!.VersionStrings.Should().HaveCount(3);
deserialized.Evidence.VersionString.BestGuess.Should().Be("1.2.3"); // Highest confidence
}
[Fact]
public void CorpusMatchHint_Serialization_IncludesMetadata()
{
// Arrange
var metadata = new Dictionary<string, string>
{
["arch"] = "amd64",
["build_date"] = "2024-01-15",
["compiler"] = "gcc-11.2.0"
};
var hint = _builder.BuildFromCorpusMatch(
"debian-packages",
"curl/7.68.0",
"hash",
0.95,
metadata);
// Act
var json = JsonSerializer.Serialize(hint, JsonOptions);
var deserialized = JsonSerializer.Deserialize<ProvenanceHint>(json, JsonOptions);
// Assert
deserialized.Should().NotBeNull();
deserialized!.Evidence.CorpusMatch.Should().NotBeNull();
deserialized.Evidence.CorpusMatch!.CorpusName.Should().Be("debian-packages");
deserialized.Evidence.CorpusMatch.MatchedEntry.Should().Be("curl/7.68.0");
deserialized.Evidence.CorpusMatch.Similarity.Should().Be(0.95);
deserialized.Evidence.CorpusMatch.Metadata.Should().NotBeNull();
deserialized.Evidence.CorpusMatch.Metadata!["arch"].Should().Be("amd64");
}
[Fact]
public void SuggestedActions_Serialization_PreservesOrder()
{
// Arrange
var match = new BuildIdMatchResult
{
Package = "test",
Version = "1.0",
Distro = "debian"
};
var hint = _builder.BuildFromBuildId("test-id", "sha1", match);
// Act
var json = JsonSerializer.Serialize(hint, JsonOptions);
var deserialized = JsonSerializer.Deserialize<ProvenanceHint>(json, JsonOptions);
// Assert
deserialized.Should().NotBeNull();
deserialized!.SuggestedActions.Should().HaveCountGreaterOrEqualTo(1);
deserialized.SuggestedActions[0].Action.Should().NotBeNullOrEmpty();
deserialized.SuggestedActions[0].Priority.Should().BeGreaterThan(0);
deserialized.SuggestedActions[0].Effort.Should().NotBeNullOrEmpty();
deserialized.SuggestedActions[0].Description.Should().NotBeNullOrEmpty();
}
[Fact]
public void HintId_IsDeterministic_ForSameInput()
{
// Arrange & Act
var hint1 = _builder.BuildFromBuildId("same-id", "sha1", null);
var hint2 = _builder.BuildFromBuildId("same-id", "sha1", null);
var json1 = JsonSerializer.Serialize(hint1, JsonOptions);
var json2 = JsonSerializer.Serialize(hint2, JsonOptions);
// Assert
hint1.HintId.Should().Be(hint2.HintId);
json1.Should().Contain(hint1.HintId);
json2.Should().Contain(hint2.HintId);
}
[Fact]
public void GeneratedAt_UsesFixedTimestamp_InTests()
{
// Arrange
var hint = _builder.BuildFromBuildId("test", "sha1", null);
// Act
var json = JsonSerializer.Serialize(hint, JsonOptions);
// Assert
hint.GeneratedAt.Should().Be(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
json.Should().Contain("\"generated_at\":\"2025-01-01T00:00:00+00:00\"");
}
[Fact]
public void CompleteHint_JsonOutput_IsValid()
{
// Arrange
var match = new BuildIdMatchResult
{
Package = "nginx",
Version = "1.18.0-6",
Distro = "debian",
CatalogSource = "debian-security",
AdvisoryLink = "https://security.debian.org/nginx"
};
var hint = _builder.BuildFromBuildId("deadbeef0123456789abcdef", "sha256", match);
// Act
var json = JsonSerializer.Serialize(hint, JsonOptions);
// Assert - JSON is parseable
var parsed = JsonDocument.Parse(json);
parsed.RootElement.GetProperty("hint_id").GetString().Should().StartWith("hint:sha256:");
parsed.RootElement.GetProperty("type").GetString().Should().NotBeNullOrEmpty();
parsed.RootElement.GetProperty("confidence").GetDouble().Should().BeInRange(0, 1);
parsed.RootElement.GetProperty("evidence").GetProperty("build_id").GetProperty("catalog_source")
.GetString().Should().Be("debian-security");
}
/// <summary>
/// Frozen time provider for deterministic test timestamps.
/// </summary>
private sealed class FrozenTimeProvider : TimeProvider
{
private static readonly DateTimeOffset FrozenTime = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => FrozenTime;
}
}