Files
git.stella-ops.org/src/__Libraries/StellaOps.Facet.Tests/FacetDriftDetectorTests.cs

628 lines
22 KiB
C#

// <copyright file="FacetDriftDetectorTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using Xunit;
namespace StellaOps.Facet.Tests;
/// <summary>
/// Tests for <see cref="FacetDriftDetector"/>.
/// </summary>
[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<string, FacetQuota> 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<FacetFileEntry>? 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<string, FacetQuota>.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<string, FacetQuota>.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<string, FacetQuota>.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<string, FacetQuota>.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<string, FacetQuota>.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<string, FacetQuota>.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<string, FacetQuota>.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<OperationCanceledException>(
() => _detector.DetectDriftAsync(baseline, current, cts.Token));
}
#endregion
}