// // Copyright (c) StellaOps. Licensed under BUSL-1.1. // using System.Collections.Immutable; using FluentAssertions; using Microsoft.Extensions.Time.Testing; using Xunit; namespace StellaOps.Facet.Tests; /// /// Tests for . /// [Trait("Category", "Unit")] public sealed class FacetDriftDetectorTests { private readonly FakeTimeProvider _timeProvider; private readonly FacetDriftDetector _detector; public FacetDriftDetectorTests() { _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero)); _detector = new FacetDriftDetector(_timeProvider); } #region Helper Methods private static FacetSeal CreateBaseline( params FacetEntry[] facets) { return new FacetSeal { ImageDigest = "sha256:baseline123", CreatedAt = DateTimeOffset.UtcNow.AddDays(-1), Facets = [.. facets], CombinedMerkleRoot = "sha256:combined123" }; } private static FacetSeal CreateBaselineWithQuotas( ImmutableDictionary quotas, params FacetEntry[] facets) { return new FacetSeal { ImageDigest = "sha256:baseline123", CreatedAt = DateTimeOffset.UtcNow.AddDays(-1), Facets = [.. facets], Quotas = quotas, CombinedMerkleRoot = "sha256:combined123" }; } private static FacetSeal CreateCurrent( params FacetEntry[] facets) { return new FacetSeal { ImageDigest = "sha256:current456", CreatedAt = DateTimeOffset.UtcNow, Facets = [.. facets], CombinedMerkleRoot = "sha256:combined456" }; } private static FacetEntry CreateFacetEntry( string facetId, string merkleRoot, int fileCount, ImmutableArray? files = null) { return new FacetEntry { FacetId = facetId, Name = facetId, Category = FacetCategory.OsPackages, Selectors = ["/var/lib/dpkg/**"], MerkleRoot = merkleRoot, FileCount = fileCount, TotalBytes = fileCount * 1024, Files = files }; } private static FacetFileEntry CreateFile(string path, string digest, long size = 1024) { return new FacetFileEntry(path, digest, size, DateTimeOffset.UtcNow); } #endregion #region No Drift Tests [Fact] public async Task DetectDriftAsync_IdenticalSeals_ReturnsNoDrift() { // Arrange var files = ImmutableArray.Create( CreateFile("/etc/file1.conf", "sha256:aaa"), CreateFile("/etc/file2.conf", "sha256:bbb")); var facet = CreateFacetEntry("os-packages-dpkg", "sha256:root123", 2, files); var baseline = CreateBaseline(facet); var current = CreateCurrent(facet); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert report.Should().NotBeNull(); report.OverallVerdict.Should().Be(QuotaVerdict.Ok); report.TotalChangedFiles.Should().Be(0); report.FacetDrifts.Should().HaveCount(1); report.FacetDrifts[0].HasDrift.Should().BeFalse(); } [Fact] public async Task DetectDriftAsync_SameMerkleRoot_ReturnsNoDrift() { // Arrange - same root but files not provided = fast path var baseline = CreateBaseline( CreateFacetEntry("os-packages-dpkg", "sha256:sameroot", 10)); var current = CreateCurrent( CreateFacetEntry("os-packages-dpkg", "sha256:sameroot", 10)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert report.OverallVerdict.Should().Be(QuotaVerdict.Ok); report.FacetDrifts[0].DriftScore.Should().Be(0); } #endregion #region File Addition Tests [Fact] public async Task DetectDriftAsync_FilesAdded_ReportsAdditions() { // Arrange var baselineFiles = ImmutableArray.Create( CreateFile("/usr/bin/app1", "sha256:aaa")); var currentFiles = ImmutableArray.Create( CreateFile("/usr/bin/app1", "sha256:aaa"), CreateFile("/usr/bin/app2", "sha256:bbb")); var baseline = CreateBaseline( CreateFacetEntry("binaries-usr", "sha256:root1", 1, baselineFiles)); var current = CreateCurrent( CreateFacetEntry("binaries-usr", "sha256:root2", 2, currentFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert report.FacetDrifts.Should().HaveCount(1); var drift = report.FacetDrifts[0]; drift.Added.Should().HaveCount(1); drift.Added[0].Path.Should().Be("/usr/bin/app2"); drift.Removed.Should().BeEmpty(); drift.Modified.Should().BeEmpty(); drift.HasDrift.Should().BeTrue(); } #endregion #region File Removal Tests [Fact] public async Task DetectDriftAsync_FilesRemoved_ReportsRemovals() { // Arrange var baselineFiles = ImmutableArray.Create( CreateFile("/usr/bin/app1", "sha256:aaa"), CreateFile("/usr/bin/app2", "sha256:bbb")); var currentFiles = ImmutableArray.Create( CreateFile("/usr/bin/app1", "sha256:aaa")); var baseline = CreateBaseline( CreateFacetEntry("binaries-usr", "sha256:root1", 2, baselineFiles)); var current = CreateCurrent( CreateFacetEntry("binaries-usr", "sha256:root2", 1, currentFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert var drift = report.FacetDrifts[0]; drift.Removed.Should().HaveCount(1); drift.Removed[0].Path.Should().Be("/usr/bin/app2"); drift.Added.Should().BeEmpty(); drift.Modified.Should().BeEmpty(); } #endregion #region File Modification Tests [Fact] public async Task DetectDriftAsync_FilesModified_ReportsModifications() { // Arrange var baselineFiles = ImmutableArray.Create( CreateFile("/etc/config.yaml", "sha256:oldhash", 512)); var currentFiles = ImmutableArray.Create( CreateFile("/etc/config.yaml", "sha256:newhash", 1024)); var baseline = CreateBaseline( CreateFacetEntry("config-files", "sha256:root1", 1, baselineFiles)); var current = CreateCurrent( CreateFacetEntry("config-files", "sha256:root2", 1, currentFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert var drift = report.FacetDrifts[0]; drift.Modified.Should().HaveCount(1); drift.Modified[0].Path.Should().Be("/etc/config.yaml"); drift.Modified[0].PreviousDigest.Should().Be("sha256:oldhash"); drift.Modified[0].CurrentDigest.Should().Be("sha256:newhash"); drift.Modified[0].PreviousSizeBytes.Should().Be(512); drift.Modified[0].CurrentSizeBytes.Should().Be(1024); drift.Added.Should().BeEmpty(); drift.Removed.Should().BeEmpty(); } #endregion #region Mixed Changes Tests [Fact] public async Task DetectDriftAsync_MixedChanges_ReportsAllTypes() { // Arrange var baselineFiles = ImmutableArray.Create( CreateFile("/usr/bin/keep", "sha256:keep"), CreateFile("/usr/bin/modify", "sha256:old"), CreateFile("/usr/bin/remove", "sha256:gone")); var currentFiles = ImmutableArray.Create( CreateFile("/usr/bin/keep", "sha256:keep"), CreateFile("/usr/bin/modify", "sha256:new"), CreateFile("/usr/bin/add", "sha256:added")); var baseline = CreateBaseline( CreateFacetEntry("binaries", "sha256:root1", 3, baselineFiles)); var current = CreateCurrent( CreateFacetEntry("binaries", "sha256:root2", 3, currentFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert var drift = report.FacetDrifts[0]; drift.Added.Should().HaveCount(1); drift.Removed.Should().HaveCount(1); drift.Modified.Should().HaveCount(1); drift.TotalChanges.Should().Be(3); } #endregion #region Quota Enforcement Tests [Fact] public async Task DetectDriftAsync_WithinQuota_ReturnsOk() { // Arrange - 1 change out of 10 = 10% churn, quota is 15% var baselineFiles = Enumerable.Range(1, 10) .Select(i => CreateFile($"/file{i}", $"sha256:hash{i}")) .ToImmutableArray(); var currentFiles = baselineFiles .Take(9) .Append(CreateFile("/file10", "sha256:changed")) .ToImmutableArray(); var quotas = ImmutableDictionary.Empty .Add("test-facet", new FacetQuota { MaxChurnPercent = 15, MaxChangedFiles = 5 }); var baseline = CreateBaselineWithQuotas(quotas, CreateFacetEntry("test-facet", "sha256:root1", 10, baselineFiles)); var current = CreateCurrent( CreateFacetEntry("test-facet", "sha256:root2", 10, currentFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert report.OverallVerdict.Should().Be(QuotaVerdict.Ok); } [Fact] public async Task DetectDriftAsync_ExceedsChurnPercent_ReturnsWarning() { // Arrange - 3 changes out of 10 = 30% churn, quota is 10% var baselineFiles = Enumerable.Range(1, 10) .Select(i => CreateFile($"/file{i}", $"sha256:hash{i}")) .ToImmutableArray(); var currentFiles = baselineFiles .Take(7) .Concat(Enumerable.Range(11, 3).Select(i => CreateFile($"/file{i}", $"sha256:new{i}"))) .ToImmutableArray(); var quotas = ImmutableDictionary.Empty .Add("test-facet", new FacetQuota { MaxChurnPercent = 10, MaxChangedFiles = 100, Action = QuotaExceededAction.Warn }); var baseline = CreateBaselineWithQuotas(quotas, CreateFacetEntry("test-facet", "sha256:root1", 10, baselineFiles)); var current = CreateCurrent( CreateFacetEntry("test-facet", "sha256:root2", 10, currentFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert report.OverallVerdict.Should().Be(QuotaVerdict.Warning); } [Fact] public async Task DetectDriftAsync_ExceedsMaxFiles_WithBlockAction_ReturnsBlocked() { // Arrange - 6 changes, quota is max 5 files with block action var baselineFiles = Enumerable.Range(1, 100) .Select(i => CreateFile($"/file{i}", $"sha256:hash{i}")) .ToImmutableArray(); var currentFiles = baselineFiles .Take(94) .Concat(Enumerable.Range(101, 6).Select(i => CreateFile($"/file{i}", $"sha256:new{i}"))) .ToImmutableArray(); var quotas = ImmutableDictionary.Empty .Add("binaries", new FacetQuota { MaxChurnPercent = 100, MaxChangedFiles = 5, Action = QuotaExceededAction.Block }); var baseline = CreateBaselineWithQuotas(quotas, CreateFacetEntry("binaries", "sha256:root1", 100, baselineFiles)); var current = CreateCurrent( CreateFacetEntry("binaries", "sha256:root2", 100, currentFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert report.OverallVerdict.Should().Be(QuotaVerdict.Blocked); report.FacetDrifts[0].QuotaVerdict.Should().Be(QuotaVerdict.Blocked); } [Fact] public async Task DetectDriftAsync_ExceedsQuota_WithRequireVex_ReturnsRequiresVex() { // Arrange var baselineFiles = ImmutableArray.Create( CreateFile("/deps/package.json", "sha256:old")); var currentFiles = ImmutableArray.Create( CreateFile("/deps/package.json", "sha256:new"), CreateFile("/deps/package-lock.json", "sha256:lock")); var quotas = ImmutableDictionary.Empty .Add("lang-deps", new FacetQuota { MaxChurnPercent = 50, MaxChangedFiles = 1, Action = QuotaExceededAction.RequireVex }); var baseline = CreateBaselineWithQuotas(quotas, CreateFacetEntry("lang-deps", "sha256:root1", 1, baselineFiles)); var current = CreateCurrent( CreateFacetEntry("lang-deps", "sha256:root2", 2, currentFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert report.OverallVerdict.Should().Be(QuotaVerdict.RequiresVex); } #endregion #region Allowlist Tests [Fact] public async Task DetectDriftAsync_AllowlistedFiles_AreExcludedFromDrift() { // Arrange - changes to allowlisted paths should be ignored var baselineFiles = ImmutableArray.Create( CreateFile("/var/lib/dpkg/status", "sha256:old"), CreateFile("/usr/bin/app", "sha256:app")); var currentFiles = ImmutableArray.Create( CreateFile("/var/lib/dpkg/status", "sha256:new"), // Allowlisted CreateFile("/usr/bin/app", "sha256:app")); var quotas = ImmutableDictionary.Empty .Add("os-packages", new FacetQuota { MaxChurnPercent = 0, MaxChangedFiles = 0, Action = QuotaExceededAction.Block, AllowlistGlobs = ["/var/lib/dpkg/**"] }); var baseline = CreateBaselineWithQuotas(quotas, CreateFacetEntry("os-packages", "sha256:root1", 2, baselineFiles)); var current = CreateCurrent( CreateFacetEntry("os-packages", "sha256:root2", 2, currentFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert report.OverallVerdict.Should().Be(QuotaVerdict.Ok); report.FacetDrifts[0].Modified.Should().BeEmpty(); } #endregion #region Multi-Facet Tests [Fact] public async Task DetectDriftAsync_MultipleFacets_ReturnsWorstVerdict() { // Arrange - one facet OK, one blocked var okFiles = ImmutableArray.Create(CreateFile("/ok/file", "sha256:same")); var blockFiles = ImmutableArray.Create( CreateFile("/block/file1", "sha256:old1"), CreateFile("/block/file2", "sha256:old2")); var blockCurrentFiles = ImmutableArray.Create( CreateFile("/block/file1", "sha256:new1"), CreateFile("/block/file2", "sha256:new2")); var quotas = ImmutableDictionary.Empty .Add("ok-facet", FacetQuota.Default) .Add("block-facet", new FacetQuota { MaxChurnPercent = 0, Action = QuotaExceededAction.Block }); var baseline = CreateBaselineWithQuotas(quotas, CreateFacetEntry("ok-facet", "sha256:ok1", 1, okFiles), CreateFacetEntry("block-facet", "sha256:block1", 2, blockFiles)); var current = CreateCurrent( CreateFacetEntry("ok-facet", "sha256:ok1", 1, okFiles), CreateFacetEntry("block-facet", "sha256:block2", 2, blockCurrentFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert report.OverallVerdict.Should().Be(QuotaVerdict.Blocked); report.FacetDrifts.Should().HaveCount(2); report.FacetDrifts.First(d => d.FacetId == "ok-facet").QuotaVerdict.Should().Be(QuotaVerdict.Ok); report.FacetDrifts.First(d => d.FacetId == "block-facet").QuotaVerdict.Should().Be(QuotaVerdict.Blocked); } [Fact] public async Task DetectDriftAsync_NewFacetAppears_ReportsAsWarning() { // Arrange var baselineFiles = ImmutableArray.Create(CreateFile("/old/file", "sha256:old")); var newFacetFiles = ImmutableArray.Create(CreateFile("/new/file", "sha256:new")); var baseline = CreateBaseline( CreateFacetEntry("existing-facet", "sha256:root1", 1, baselineFiles)); var current = CreateCurrent( CreateFacetEntry("existing-facet", "sha256:root1", 1, baselineFiles), CreateFacetEntry("new-facet", "sha256:root2", 1, newFacetFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert report.FacetDrifts.Should().HaveCount(2); var newDrift = report.FacetDrifts.First(d => d.FacetId == "new-facet"); newDrift.QuotaVerdict.Should().Be(QuotaVerdict.Warning); newDrift.Added.Should().HaveCount(1); newDrift.BaselineFileCount.Should().Be(0); } [Fact] public async Task DetectDriftAsync_FacetRemoved_ReportsAsWarningOrBlock() { // Arrange var removedFacetFiles = ImmutableArray.Create( CreateFile("/removed/file1", "sha256:gone1"), CreateFile("/removed/file2", "sha256:gone2")); var quotas = ImmutableDictionary.Empty .Add("removed-facet", new FacetQuota { Action = QuotaExceededAction.Block }); var baseline = CreateBaselineWithQuotas(quotas, CreateFacetEntry("removed-facet", "sha256:root1", 2, removedFacetFiles)); var current = CreateCurrent(); // No facets // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert report.FacetDrifts.Should().HaveCount(1); var drift = report.FacetDrifts[0]; drift.FacetId.Should().Be("removed-facet"); drift.Removed.Should().HaveCount(2); drift.Added.Should().BeEmpty(); drift.QuotaVerdict.Should().Be(QuotaVerdict.Blocked); } #endregion #region Drift Score Tests [Fact] public async Task DetectDriftAsync_CalculatesDriftScore_BasedOnChanges() { // Arrange - 2 additions, 1 removal, 1 modification out of 10 files // Weighted: 2 + 1 + 0.5 = 3.5 / 10 * 100 = 35% var baselineFiles = Enumerable.Range(1, 10) .Select(i => CreateFile($"/file{i}", $"sha256:hash{i}")) .ToImmutableArray(); var currentFiles = baselineFiles .Skip(1) // Remove file1 .Take(8) .Append(CreateFile("/file10", "sha256:modified")) // Modify file10 .Append(CreateFile("/file11", "sha256:new1")) // Add 2 files .Append(CreateFile("/file12", "sha256:new2")) .ToImmutableArray(); var baseline = CreateBaseline( CreateFacetEntry("test", "sha256:root1", 10, baselineFiles)); var current = CreateCurrent( CreateFacetEntry("test", "sha256:root2", 11, currentFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert var drift = report.FacetDrifts[0]; drift.DriftScore.Should().BeGreaterThan(0); drift.DriftScore.Should().BeLessThanOrEqualTo(100); drift.ChurnPercent.Should().BeGreaterThan(0); } #endregion #region Edge Cases [Fact] public async Task DetectDriftAsync_EmptyBaseline_AllFilesAreAdditions() { // Arrange var currentFiles = ImmutableArray.Create( CreateFile("/new/file1", "sha256:new1"), CreateFile("/new/file2", "sha256:new2")); var baseline = CreateBaseline( CreateFacetEntry("empty-facet", "sha256:empty", 0, [])); var current = CreateCurrent( CreateFacetEntry("empty-facet", "sha256:root", 2, currentFiles)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert var drift = report.FacetDrifts[0]; drift.Added.Should().HaveCount(2); drift.ChurnPercent.Should().Be(100m); // All new = 100% churn } [Fact] public async Task DetectDriftAsync_NullFilesInBaseline_FallsBackToRootComparison() { // Arrange - no file details, different roots var baseline = CreateBaseline( CreateFacetEntry("no-files", "sha256:root1", 10, null)); var current = CreateCurrent( CreateFacetEntry("no-files", "sha256:root2", 10, null)); // Act var report = await _detector.DetectDriftAsync(baseline, current, TestContext.Current.CancellationToken); // Assert var drift = report.FacetDrifts[0]; drift.DriftScore.Should().Be(100m); // Max drift when can't compute details } [Fact] public async Task DetectDriftAsync_Cancellation_ThrowsOperationCanceled() { // Arrange var baseline = CreateBaseline( CreateFacetEntry("test", "sha256:root1", 10)); var current = CreateCurrent( CreateFacetEntry("test", "sha256:root2", 10)); var cts = new CancellationTokenSource(); cts.Cancel(); // Act & Assert await Assert.ThrowsAsync( () => _detector.DetectDriftAsync(baseline, current, cts.Token)); } #endregion }