work work hard work
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ClassificationChangeTracker" />.
|
||||
/// </summary>
|
||||
public sealed class ClassificationChangeTrackerTests
|
||||
{
|
||||
private readonly FakeClassificationHistoryRepository _repository;
|
||||
private readonly ClassificationChangeTracker _tracker;
|
||||
|
||||
public ClassificationChangeTrackerTests()
|
||||
{
|
||||
_repository = new FakeClassificationHistoryRepository();
|
||||
_tracker = new ClassificationChangeTracker(
|
||||
_repository,
|
||||
NullLogger<ClassificationChangeTracker>.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<ClassificationChange>());
|
||||
_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<ClassificationChange>> _byExecution = new();
|
||||
|
||||
public List<ClassificationChange> InsertedChanges { get; } = new();
|
||||
public List<List<ClassificationChange>> InsertedBatches { get; } = new();
|
||||
|
||||
public void SetExecutionChanges(Guid tenantId, Guid executionId, IReadOnlyList<ClassificationChange> changes)
|
||||
=> _byExecution[(tenantId, executionId)] = changes;
|
||||
|
||||
public Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default)
|
||||
{
|
||||
InsertedChanges.Add(change);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InsertBatchAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
InsertedBatches.Add(changes.ToList());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetByExecutionAsync(
|
||||
Guid tenantId,
|
||||
Guid executionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_byExecution.TryGetValue((tenantId, executionId), out var changes)
|
||||
? changes
|
||||
: Array.Empty<ClassificationChange>());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetChangesAsync(Guid tenantId, DateTimeOffset since, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetByArtifactAsync(string artifactDigest, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetByVulnIdAsync(string vulnId, Guid? tenantId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<FnDriftStats>> GetDriftStatsAsync(Guid tenantId, DateOnly fromDate, DateOnly toDate, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<FnDrift30dSummary?> GetDrift30dSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task RefreshDriftStatsAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user