using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Scanner.Storage.Models; using StellaOps.Scanner.Storage.Repositories; using StellaOps.Scanner.Storage.Services; using Xunit; namespace StellaOps.Scanner.Storage.Tests; /// /// Unit tests for . /// public sealed class ClassificationChangeTrackerTests { private readonly FakeClassificationHistoryRepository _repository; private readonly ClassificationChangeTracker _tracker; public ClassificationChangeTrackerTests() { _repository = new FakeClassificationHistoryRepository(); _tracker = new ClassificationChangeTracker( _repository, NullLogger.Instance, new FakeTimeProvider(DateTimeOffset.Parse("2025-12-17T00:00:00Z"))); } [Fact] public async Task TrackChangeAsync_ActualChange_InsertsToRepository() { var change = CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected); await _tracker.TrackChangeAsync(change); Assert.Single(_repository.InsertedChanges); Assert.Same(change, _repository.InsertedChanges[0]); } [Fact] public async Task TrackChangeAsync_NoOpChange_SkipsInsert() { var change = CreateChange(ClassificationStatus.Affected, ClassificationStatus.Affected); await _tracker.TrackChangeAsync(change); Assert.Empty(_repository.InsertedChanges); } [Fact] public async Task TrackChangesAsync_FiltersNoOpChanges() { var changes = new[] { CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected), CreateChange(ClassificationStatus.Affected, ClassificationStatus.Affected), // No-op CreateChange(ClassificationStatus.Affected, ClassificationStatus.Fixed), }; await _tracker.TrackChangesAsync(changes); Assert.Single(_repository.InsertedBatches); Assert.Equal(2, _repository.InsertedBatches[0].Count); } [Fact] public async Task TrackChangesAsync_EmptyAfterFilter_DoesNotInsert() { var changes = new[] { CreateChange(ClassificationStatus.Affected, ClassificationStatus.Affected), CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Unknown), }; await _tracker.TrackChangesAsync(changes); Assert.Empty(_repository.InsertedBatches); } [Fact] public void IsFnTransition_UnknownToAffected_ReturnsTrue() { var change = CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected); Assert.True(change.IsFnTransition); } [Fact] public void IsFnTransition_UnaffectedToAffected_ReturnsTrue() { var change = CreateChange(ClassificationStatus.Unaffected, ClassificationStatus.Affected); Assert.True(change.IsFnTransition); } [Fact] public void IsFnTransition_AffectedToFixed_ReturnsFalse() { var change = CreateChange(ClassificationStatus.Affected, ClassificationStatus.Fixed); Assert.False(change.IsFnTransition); } [Fact] public void IsFnTransition_NewToAffected_ReturnsFalse() { var change = CreateChange(ClassificationStatus.New, ClassificationStatus.Affected); Assert.False(change.IsFnTransition); } [Fact] public async Task ComputeDeltaAsync_NewFinding_RecordsAsNewStatus() { var tenantId = Guid.NewGuid(); var artifact = "sha256:abc123"; var prevExecId = Guid.NewGuid(); var currExecId = Guid.NewGuid(); _repository.SetExecutionChanges(tenantId, prevExecId, Array.Empty()); _repository.SetExecutionChanges(tenantId, currExecId, new[] { CreateChange(ClassificationStatus.New, ClassificationStatus.Affected, artifact, "CVE-2024-0001"), }); var delta = await _tracker.ComputeDeltaAsync(tenantId, artifact, prevExecId, currExecId); Assert.Single(delta); Assert.Equal(ClassificationStatus.New, delta[0].PreviousStatus); Assert.Equal(ClassificationStatus.Affected, delta[0].NewStatus); } [Fact] public async Task ComputeDeltaAsync_StatusChange_RecordsDelta() { var tenantId = Guid.NewGuid(); var artifact = "sha256:abc123"; var prevExecId = Guid.NewGuid(); var currExecId = Guid.NewGuid(); _repository.SetExecutionChanges(tenantId, prevExecId, new[] { CreateChange(ClassificationStatus.New, ClassificationStatus.Unknown, artifact, "CVE-2024-0001"), }); _repository.SetExecutionChanges(tenantId, currExecId, new[] { CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected, artifact, "CVE-2024-0001"), }); var delta = await _tracker.ComputeDeltaAsync(tenantId, artifact, prevExecId, currExecId); Assert.Single(delta); Assert.Equal(ClassificationStatus.Unknown, delta[0].PreviousStatus); Assert.Equal(ClassificationStatus.Affected, delta[0].NewStatus); } private static ClassificationChange CreateChange( ClassificationStatus previous, ClassificationStatus next, string artifact = "sha256:test", string vulnId = "CVE-2024-0001") => new() { ArtifactDigest = artifact, VulnId = vulnId, PackagePurl = "pkg:npm/test@1.0.0", TenantId = Guid.NewGuid(), ManifestId = Guid.NewGuid(), ExecutionId = Guid.NewGuid(), PreviousStatus = previous, NewStatus = next, Cause = DriftCause.FeedDelta, }; private sealed class FakeTimeProvider : TimeProvider { private DateTimeOffset _now; public FakeTimeProvider(DateTimeOffset now) => _now = now; public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan duration) => _now = _now.Add(duration); } private sealed class FakeClassificationHistoryRepository : IClassificationHistoryRepository { private readonly Dictionary<(Guid tenantId, Guid executionId), IReadOnlyList> _byExecution = new(); public List InsertedChanges { get; } = new(); public List> InsertedBatches { get; } = new(); public void SetExecutionChanges(Guid tenantId, Guid executionId, IReadOnlyList changes) => _byExecution[(tenantId, executionId)] = changes; public Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default) { InsertedChanges.Add(change); return Task.CompletedTask; } public Task InsertBatchAsync(IEnumerable changes, CancellationToken cancellationToken = default) { InsertedBatches.Add(changes.ToList()); return Task.CompletedTask; } public Task> GetByExecutionAsync( Guid tenantId, Guid executionId, CancellationToken cancellationToken = default) { return Task.FromResult(_byExecution.TryGetValue((tenantId, executionId), out var changes) ? changes : Array.Empty()); } public Task> GetChangesAsync(Guid tenantId, DateTimeOffset since, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetByArtifactAsync(string artifactDigest, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetByVulnIdAsync(string vulnId, Guid? tenantId = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetDriftStatsAsync(Guid tenantId, DateOnly fromDate, DateOnly toDate, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetDrift30dSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task RefreshDriftStatsAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); } }