628 lines
22 KiB
C#
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
|
|
}
|