save work

This commit is contained in:
StellaOps Bot
2025-12-19 07:28:23 +02:00
parent 6410a6d082
commit 2eafe98d44
97 changed files with 5040 additions and 1443 deletions

View File

@@ -2,7 +2,7 @@
// FidelityMetricsIntegrationTests.cs
// Sprint: SPRINT_3403_0001_0001_fidelity_metrics
// Task: FID-3403-013
// Description: Integration tests for fidelity metrics in determinism harness
// Description: Integration tests for fidelity metrics in determinism reports
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Worker.Determinism;
@@ -16,13 +16,12 @@ public sealed class FidelityMetricsIntegrationTests
[Fact]
public void DeterminismReport_WithFidelityMetrics_IncludesAllThreeTiers()
{
// Arrange & Act
var fidelity = CreateTestFidelityMetrics(
bitwiseFidelity: 0.98,
semanticFidelity: 0.99,
policyFidelity: 1.0);
var report = new DeterminismReport(
var report = new global::StellaOps.Scanner.Worker.Determinism.DeterminismReport(
Version: "1.0.0",
Release: "test-release",
Platform: "linux-amd64",
@@ -35,9 +34,8 @@ public sealed class FidelityMetricsIntegrationTests
Images: [],
Fidelity: fidelity);
// Assert
Assert.NotNull(report.Fidelity);
Assert.Equal(0.98, report.Fidelity.BitwiseFidelity);
Assert.Equal(0.98, report.Fidelity!.BitwiseFidelity);
Assert.Equal(0.99, report.Fidelity.SemanticFidelity);
Assert.Equal(1.0, report.Fidelity.PolicyFidelity);
}
@@ -45,13 +43,12 @@ public sealed class FidelityMetricsIntegrationTests
[Fact]
public void DeterminismImageReport_WithFidelityMetrics_TracksPerImage()
{
// Arrange
var imageFidelity = CreateTestFidelityMetrics(
bitwiseFidelity: 0.95,
semanticFidelity: 0.98,
policyFidelity: 1.0);
var imageReport = new DeterminismImageReport(
var imageReport = new global::StellaOps.Scanner.Worker.Determinism.DeterminismImageReport(
Image: "sha256:image123",
Runs: 5,
Identical: 4,
@@ -60,120 +57,40 @@ public sealed class FidelityMetricsIntegrationTests
RunsDetail: [],
Fidelity: imageFidelity);
// Assert
Assert.NotNull(imageReport.Fidelity);
Assert.Equal(0.95, imageReport.Fidelity.BitwiseFidelity);
Assert.Equal(0.95, imageReport.Fidelity!.BitwiseFidelity);
Assert.Equal(5, imageReport.Fidelity.TotalReplays);
}
[Fact]
public void FidelityMetricsService_ComputesAllThreeTiers()
public void FidelityMetricsService_Calculate_ComputesAllThreeTiers()
{
// Arrange
var service = new FidelityMetricsService(
new BitwiseFidelityCalculator(),
new SemanticFidelityCalculator(),
new PolicyFidelityCalculator());
var service = new FidelityMetricsService();
var baseline = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass");
var replay = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass");
// Act
var metrics = service.Compute(baseline, new[] { replay });
// Assert
Assert.Equal(1, metrics.TotalReplays);
Assert.True(metrics.BitwiseFidelity >= 0.0 && metrics.BitwiseFidelity <= 1.0);
Assert.True(metrics.SemanticFidelity >= 0.0 && metrics.SemanticFidelity <= 1.0);
Assert.True(metrics.PolicyFidelity >= 0.0 && metrics.PolicyFidelity <= 1.0);
}
[Fact]
public void FidelityMetrics_SemanticEquivalent_ButBitwiseDifferent()
{
// Arrange - same semantic content, different formatting/ordering
var service = new FidelityMetricsService(
new BitwiseFidelityCalculator(),
new SemanticFidelityCalculator(),
new PolicyFidelityCalculator());
var baseline = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "HIGH", "pass");
var replay = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass"); // case difference
// Act
var metrics = service.Compute(baseline, new[] { replay });
// Assert
// Bitwise should be < 1.0 (different bytes)
// Semantic should be 1.0 (same meaning)
// Policy should be 1.0 (same decision)
Assert.True(metrics.SemanticFidelity >= metrics.BitwiseFidelity);
Assert.Equal(1.0, metrics.PolicyFidelity);
}
[Fact]
public void FidelityMetrics_PolicyDifference_ReflectedInPF()
{
// Arrange
var service = new FidelityMetricsService(
new BitwiseFidelityCalculator(),
new SemanticFidelityCalculator(),
new PolicyFidelityCalculator());
var baseline = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass");
var replay = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "fail"); // policy differs
// Act
var metrics = service.Compute(baseline, new[] { replay });
// Assert
Assert.True(metrics.PolicyFidelity < 1.0);
}
[Fact]
public void FidelityMetrics_MultipleReplays_AveragesCorrectly()
{
// Arrange
var service = new FidelityMetricsService(
new BitwiseFidelityCalculator(),
new SemanticFidelityCalculator(),
new PolicyFidelityCalculator());
var baseline = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass");
var replays = new[]
var baselineHashes = new Dictionary<string, string>
{
CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass"), // identical
CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass"), // identical
CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "fail"), // policy diff
["sbom.json"] = "sha256:baseline",
};
var replayHashes = new List<IReadOnlyDictionary<string, string>>
{
new Dictionary<string, string> { ["sbom.json"] = "sha256:baseline" }
};
// Act
var metrics = service.Compute(baseline, replays);
var baselineFindings = CreateNormalizedFindings();
var replayFindings = new List<NormalizedFindings> { CreateNormalizedFindings() };
// Assert
Assert.Equal(3, metrics.TotalReplays);
// 2 out of 3 have matching policy
Assert.True(metrics.PolicyFidelity >= 0.6 && metrics.PolicyFidelity <= 0.7);
}
var baselineDecision = CreatePolicyDecision();
var replayDecisions = new List<PolicyDecision> { CreatePolicyDecision() };
[Fact]
public void FidelityMetrics_IncludesMismatchDiagnostics()
{
// Arrange
var service = new FidelityMetricsService(
new BitwiseFidelityCalculator(),
new SemanticFidelityCalculator(),
new PolicyFidelityCalculator());
var metrics = service.Calculate(
baselineHashes, replayHashes,
baselineFindings, replayFindings,
baselineDecision, replayDecisions);
var baseline = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "high", "pass");
var replay = CreateTestScanResult("pkg:npm/lodash@4.17.21", "CVE-2021-23337", "critical", "fail"); // semantic + policy diff
// Act
var metrics = service.Compute(baseline, new[] { replay });
// Assert
Assert.NotNull(metrics.Mismatches);
Assert.NotEmpty(metrics.Mismatches);
Assert.Equal(1, metrics.TotalReplays);
Assert.Equal(1.0, metrics.BitwiseFidelity);
Assert.Equal(1.0, metrics.SemanticFidelity);
Assert.Equal(1.0, metrics.PolicyFidelity);
}
private static FidelityMetrics CreateTestFidelityMetrics(
@@ -195,38 +112,22 @@ public sealed class FidelityMetricsIntegrationTests
};
}
private static TestScanResult CreateTestScanResult(
string purl,
string cve,
string severity,
string policyDecision)
private static NormalizedFindings CreateNormalizedFindings() => new()
{
return new TestScanResult
Packages = new List<NormalizedPackage>
{
Packages = new[] { new TestPackage { Purl = purl } },
Findings = new[] { new TestFinding { Cve = cve, Severity = severity } },
PolicyDecision = policyDecision,
PolicyReasonCodes = policyDecision == "pass" ? Array.Empty<string>() : new[] { "severity_exceeded" }
};
}
new("pkg:npm/test@1.0.0", "1.0.0")
},
Cves = new HashSet<string> { "CVE-2024-0001" },
SeverityCounts = new Dictionary<string, int> { ["MEDIUM"] = 1 },
Verdicts = new Dictionary<string, string> { ["overall"] = "pass" }
};
// Test support types
private sealed record TestScanResult
private static PolicyDecision CreatePolicyDecision() => new()
{
public required IReadOnlyList<TestPackage> Packages { get; init; }
public required IReadOnlyList<TestFinding> Findings { get; init; }
public required string PolicyDecision { get; init; }
public required IReadOnlyList<string> PolicyReasonCodes { get; init; }
}
private sealed record TestPackage
{
public required string Purl { get; init; }
}
private sealed record TestFinding
{
public required string Cve { get; init; }
public required string Severity { get; init; }
}
Passed = true,
ReasonCodes = new List<string> { "CLEAN" },
ViolationCount = 0,
BlockLevel = "none"
};
}

View File

@@ -0,0 +1,101 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.Worker.Processing;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests.Epss;
public sealed class EpssEnrichmentJobTests
{
[Fact]
public async Task EnrichAsync_EmitsPriorityChangedSignalWhenBandChanges()
{
var modelDate = new DateOnly(2027, 1, 16);
var changes = new List<EpssChangeRecord>
{
new()
{
CveId = "CVE-2024-0001",
Flags = EpssChangeFlags.BigJumpUp,
PreviousScore = 0.20,
NewScore = 0.70,
NewPercentile = 0.995,
PreviousBand = EpssPriorityBand.Medium,
ModelDate = modelDate
}
};
var epssRepository = new Mock<IEpssRepository>(MockBehavior.Strict);
epssRepository
.Setup(r => r.GetChangesAsync(modelDate, null, 100000, It.IsAny<CancellationToken>()))
.ReturnsAsync(changes);
var epssProvider = new Mock<IEpssProvider>(MockBehavior.Strict);
epssProvider
.Setup(p => p.GetLatestModelDateAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(modelDate);
epssProvider
.Setup(p => p.GetCurrentBatchAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EpssBatchResult
{
ModelDate = modelDate,
Found = new[]
{
EpssEvidence.CreateWithTimestamp(
"CVE-2024-0001",
score: 0.70,
percentile: 0.995,
modelDate: modelDate,
capturedAt: DateTimeOffset.Parse("2027-01-16T00:07:00Z"),
source: "test",
fromCache: false)
},
NotFound = Array.Empty<string>(),
PartiallyFromCache = false,
LookupTimeMs = 1
});
var published = new List<(string cve, string oldBand, string newBand)>();
var publisher = new Mock<IEpssSignalPublisher>(MockBehavior.Strict);
publisher
.Setup(p => p.PublishPriorityChangedAsync(
It.IsAny<Guid>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<double>(),
It.IsAny<DateOnly>(),
It.IsAny<CancellationToken>()))
.Callback<Guid, string, string, string, double, DateOnly, CancellationToken>((_, cve, oldBand, newBand, _, _, _) =>
published.Add((cve, oldBand, newBand)))
.ReturnsAsync(new EpssSignalPublishResult { Success = true, MessageId = "ok" });
var job = new EpssEnrichmentJob(
epssRepository.Object,
epssProvider.Object,
publisher.Object,
Microsoft.Extensions.Options.Options.Create(new EpssEnrichmentOptions
{
Enabled = true,
BatchSize = 100,
FlagsToProcess = EpssChangeFlags.None,
HighPercentile = 0.99,
CriticalPercentile = 0.995,
MediumPercentile = 0.90,
}),
TimeProvider.System,
NullLogger<EpssEnrichmentJob>.Instance);
await job.EnrichAsync();
Assert.Single(published);
Assert.Equal("CVE-2024-0001", published[0].cve);
Assert.Equal(EpssPriorityBand.Medium.ToString(), published[0].oldBand);
Assert.Equal(EpssPriorityBand.Critical.ToString(), published[0].newBand);
}
}

View File

@@ -0,0 +1,189 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Postgres;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.Worker.Processing;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests.Epss;
[Collection("scanner-worker-postgres")]
public sealed class EpssSignalFlowIntegrationTests : IAsyncLifetime
{
private readonly ScannerWorkerPostgresFixture _fixture;
private ScannerDataSource _dataSource = null!;
public EpssSignalFlowIntegrationTests(ScannerWorkerPostgresFixture fixture)
{
_fixture = fixture;
}
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
var options = new ScannerStorageOptions
{
Postgres = new StellaOps.Infrastructure.Postgres.Options.PostgresOptions
{
ConnectionString = _fixture.ConnectionString,
SchemaName = _fixture.SchemaName
}
};
_dataSource = new ScannerDataSource(Microsoft.Extensions.Options.Options.Create(options), NullLogger<ScannerDataSource>.Instance);
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
await connection.OpenAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = $"""
CREATE TABLE IF NOT EXISTS {_fixture.SchemaName}.vuln_instance_triage (
instance_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
cve_id TEXT NOT NULL
);
""";
await cmd.ExecuteNonQueryAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task GenerateSignalsAsync_WritesSignalsPerObservedTenant()
{
var epssRepository = new PostgresEpssRepository(_dataSource);
var signalRepository = new PostgresEpssSignalRepository(_dataSource);
var observedCveRepository = new PostgresObservedCveRepository(_dataSource);
var day1 = new DateOnly(2027, 1, 15);
var run1 = await epssRepository.BeginImportAsync(day1, "bundle://day1.csv.gz", DateTimeOffset.Parse("2027-01-15T00:05:00Z"), "sha256:day1");
var write1 = await epssRepository.WriteSnapshotAsync(
run1.ImportRunId,
day1,
DateTimeOffset.Parse("2027-01-15T00:06:00Z"),
ToAsync(new[]
{
new EpssScoreRow("CVE-2024-0001", 0.40, 0.90),
new EpssScoreRow("CVE-2024-0002", 0.60, 0.96)
}));
await epssRepository.MarkImportSucceededAsync(run1.ImportRunId, write1.RowCount, "sha256:decompressed1", "v2027.01.15", day1);
var day2 = new DateOnly(2027, 1, 16);
var run2 = await epssRepository.BeginImportAsync(day2, "bundle://day2.csv.gz", DateTimeOffset.Parse("2027-01-16T00:05:00Z"), "sha256:day2");
var write2 = await epssRepository.WriteSnapshotAsync(
run2.ImportRunId,
day2,
DateTimeOffset.Parse("2027-01-16T00:06:00Z"),
ToAsync(new[]
{
new EpssScoreRow("CVE-2024-0001", 0.55, 0.95),
new EpssScoreRow("CVE-2024-0002", 0.45, 0.94),
new EpssScoreRow("CVE-2024-0003", 0.70, 0.97)
}));
await epssRepository.MarkImportSucceededAsync(run2.ImportRunId, write2.RowCount, "sha256:decompressed2", "v2027.01.16", day2);
var tenantA = Guid.Parse("aaaaaaaa-1111-1111-1111-111111111111");
var tenantB = Guid.Parse("bbbbbbbb-2222-2222-2222-222222222222");
await InsertTriageRowAsync(tenantA, Guid.Parse("00000000-0000-0000-0000-000000000001"), "CVE-2024-0001");
await InsertTriageRowAsync(tenantA, Guid.Parse("00000000-0000-0000-0000-000000000002"), "CVE-2024-0003");
await InsertTriageRowAsync(tenantB, Guid.Parse("00000000-0000-0000-0000-000000000003"), "CVE-2024-0002");
var provider = new FixedEpssProvider(day2);
var publisher = new RecordingEpssSignalPublisher();
var job = new EpssSignalJob(
epssRepository,
signalRepository,
observedCveRepository,
publisher,
provider,
Microsoft.Extensions.Options.Options.Create(new EpssSignalOptions
{
Enabled = true,
BatchSize = 500
}),
TimeProvider.System,
NullLogger<EpssSignalJob>.Instance);
await job.GenerateSignalsAsync();
var tenantASignals = await signalRepository.GetByTenantAsync(tenantA, day2, day2);
Assert.Equal(2, tenantASignals.Count);
Assert.Contains(tenantASignals, s => s.CveId == "CVE-2024-0001");
Assert.Contains(tenantASignals, s => s.CveId == "CVE-2024-0003");
var tenantBSignals = await signalRepository.GetByTenantAsync(tenantB, day2, day2);
Assert.Single(tenantBSignals);
Assert.Equal("CVE-2024-0002", tenantBSignals[0].CveId);
Assert.Equal(3, publisher.Published.Count);
Assert.All(publisher.Published, s => Assert.Equal(day2, s.ModelDate));
}
private async Task InsertTriageRowAsync(Guid tenantId, Guid instanceId, string cveId)
{
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
await connection.OpenAsync();
await using var cmd = connection.CreateCommand();
cmd.CommandText = $"""
INSERT INTO {_fixture.SchemaName}.vuln_instance_triage (instance_id, tenant_id, cve_id)
VALUES (@InstanceId, @TenantId, @CveId)
ON CONFLICT (instance_id) DO NOTHING;
""";
cmd.Parameters.AddWithValue("InstanceId", instanceId);
cmd.Parameters.AddWithValue("TenantId", tenantId);
cmd.Parameters.AddWithValue("CveId", cveId);
await cmd.ExecuteNonQueryAsync();
}
private static async IAsyncEnumerable<EpssScoreRow> ToAsync(IEnumerable<EpssScoreRow> rows)
{
foreach (var row in rows)
{
yield return row;
await Task.Yield();
}
}
private sealed class FixedEpssProvider : IEpssProvider
{
private readonly DateOnly? _latestModelDate;
public FixedEpssProvider(DateOnly? latestModelDate)
{
_latestModelDate = latestModelDate;
}
public Task<EpssEvidence?> GetCurrentAsync(string cveId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<EpssBatchResult> GetCurrentBatchAsync(IEnumerable<string> cveIds, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<EpssEvidence?> GetAsOfDateAsync(string cveId, DateOnly asOfDate, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<IReadOnlyList<EpssEvidence>> GetHistoryAsync(string cveId, DateOnly startDate, DateOnly endDate, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default) => Task.FromResult(_latestModelDate);
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default) => Task.FromResult(true);
}
private sealed class RecordingEpssSignalPublisher : IEpssSignalPublisher
{
public List<EpssSignal> Published { get; } = new();
public Task<EpssSignalPublishResult> PublishAsync(EpssSignal signal, CancellationToken cancellationToken = default)
{
Published.Add(signal);
return Task.FromResult(new EpssSignalPublishResult { Success = true, MessageId = "recorded" });
}
public Task<int> PublishBatchAsync(IEnumerable<EpssSignal> signals, CancellationToken cancellationToken = default)
{
Published.AddRange(signals);
return Task.FromResult(signals.Count());
}
public Task<EpssSignalPublishResult> PublishPriorityChangedAsync(Guid tenantId, string cveId, string oldBand, string newBand, double epssScore, DateOnly modelDate, CancellationToken cancellationToken = default)
=> Task.FromResult(new EpssSignalPublishResult { Success = true, MessageId = "recorded" });
}
}

View File

@@ -0,0 +1,294 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.Worker.Processing;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests.Epss;
public sealed class EpssSignalJobTests
{
[Fact]
public async Task GenerateSignalsAsync_CreatesSignalsAndPublishesBatch()
{
var modelDate = new DateOnly(2027, 1, 16);
var tenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
var provider = new FixedEpssProvider(modelDate);
var epssRepository = new Mock<IEpssRepository>(MockBehavior.Strict);
epssRepository
.Setup(r => r.GetImportRunAsync(modelDate, It.IsAny<CancellationToken>()))
.ReturnsAsync(new EpssImportRun(
ImportRunId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
ModelDate: modelDate,
SourceUri: "bundle://test.csv.gz",
RetrievedAtUtc: DateTimeOffset.Parse("2027-01-16T00:05:00Z"),
FileSha256: "sha256:test",
DecompressedSha256: "sha256:decompressed",
RowCount: 3,
ModelVersionTag: "v2027.01.16",
PublishedDate: modelDate,
Status: "SUCCEEDED",
Error: null,
CreatedAtUtc: DateTimeOffset.Parse("2027-01-16T00:06:00Z")));
var changes = new List<EpssChangeRecord>
{
new()
{
CveId = "CVE-2024-0001",
Flags = EpssChangeFlags.BigJumpUp,
PreviousScore = 0.10,
NewScore = 0.30,
NewPercentile = 0.995,
PreviousBand = EpssPriorityBand.Medium,
ModelDate = modelDate
},
new()
{
CveId = "CVE-2024-0002",
Flags = EpssChangeFlags.NewScored,
PreviousScore = null,
NewScore = 0.60,
NewPercentile = 0.97,
PreviousBand = EpssPriorityBand.Unknown,
ModelDate = modelDate
}
};
epssRepository
.Setup(r => r.GetChangesAsync(modelDate, null, 200000, It.IsAny<CancellationToken>()))
.ReturnsAsync(changes);
var observedCveRepository = new Mock<IObservedCveRepository>(MockBehavior.Strict);
observedCveRepository
.Setup(r => r.GetActiveTenantsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { tenantId });
observedCveRepository
.Setup(r => r.FilterObservedAsync(tenantId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Guid _, IEnumerable<string> cves, CancellationToken __) =>
new HashSet<string>(cves, StringComparer.OrdinalIgnoreCase));
var createdSignals = new List<EpssSignal>();
var signalRepository = new Mock<IEpssSignalRepository>(MockBehavior.Strict);
signalRepository
.Setup(r => r.CreateBulkAsync(It.IsAny<IEnumerable<EpssSignal>>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<EpssSignal>, CancellationToken>((signals, _) => createdSignals.AddRange(signals))
.ReturnsAsync((IEnumerable<EpssSignal> signals, CancellationToken _) => signals.Count());
signalRepository
.Setup(r => r.CreateAsync(It.IsAny<EpssSignal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((EpssSignal signal, CancellationToken _) => signal);
signalRepository
.Setup(r => r.PruneAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(0);
signalRepository
.Setup(r => r.GetByTenantAsync(It.IsAny<Guid>(), It.IsAny<DateOnly>(), It.IsAny<DateOnly>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<EpssSignal>());
signalRepository
.Setup(r => r.GetByCveAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<EpssSignal>());
signalRepository
.Setup(r => r.GetHighPriorityAsync(It.IsAny<Guid>(), It.IsAny<DateOnly>(), It.IsAny<DateOnly>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<EpssSignal>());
signalRepository
.Setup(r => r.GetConfigAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((EpssSignalConfig?)null);
signalRepository
.Setup(r => r.UpsertConfigAsync(It.IsAny<EpssSignalConfig>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((EpssSignalConfig cfg, CancellationToken _) => cfg);
var publisher = new Mock<IEpssSignalPublisher>(MockBehavior.Strict);
publisher
.Setup(p => p.PublishBatchAsync(It.IsAny<IEnumerable<EpssSignal>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<EpssSignal> signals, CancellationToken _) => signals.Count());
publisher
.Setup(p => p.PublishAsync(It.IsAny<EpssSignal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EpssSignalPublishResult { Success = true, MessageId = "ok" });
publisher
.Setup(p => p.PublishPriorityChangedAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<double>(), It.IsAny<DateOnly>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EpssSignalPublishResult { Success = true, MessageId = "ok" });
var job = new EpssSignalJob(
epssRepository.Object,
signalRepository.Object,
observedCveRepository.Object,
publisher.Object,
provider,
Microsoft.Extensions.Options.Options.Create(new EpssSignalOptions
{
Enabled = true,
BatchSize = 500
}),
TimeProvider.System,
NullLogger<EpssSignalJob>.Instance);
await job.GenerateSignalsAsync();
Assert.Equal(2, createdSignals.Count);
Assert.All(createdSignals, s =>
{
Assert.Equal(tenantId, s.TenantId);
Assert.Equal(modelDate, s.ModelDate);
Assert.Equal("v2027.01.16", s.ModelVersion);
Assert.False(s.IsModelChange);
Assert.False(string.IsNullOrWhiteSpace(s.DedupeKey));
Assert.NotNull(s.ExplainHash);
Assert.NotEmpty(s.ExplainHash);
});
Assert.Contains(createdSignals, s => s.EventType == EpssSignalEventTypes.NewHigh && s.CveId == "CVE-2024-0002");
Assert.Contains(createdSignals, s => s.EventType == EpssSignalEventTypes.RiskSpike && s.CveId == "CVE-2024-0001");
}
[Fact]
public async Task GenerateSignalsAsync_EmitsModelUpdatedSummarySignal()
{
var modelDate = new DateOnly(2027, 1, 16);
var tenantId = Guid.Parse("22222222-2222-2222-2222-222222222222");
var provider = new FixedEpssProvider(modelDate);
var epssRepository = new Mock<IEpssRepository>(MockBehavior.Strict);
epssRepository
.SetupSequence(r => r.GetImportRunAsync(modelDate, It.IsAny<CancellationToken>()))
.ReturnsAsync(new EpssImportRun(
ImportRunId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
ModelDate: modelDate,
SourceUri: "bundle://test.csv.gz",
RetrievedAtUtc: DateTimeOffset.Parse("2027-01-16T00:05:00Z"),
FileSha256: "sha256:test",
DecompressedSha256: "sha256:decompressed",
RowCount: 1,
ModelVersionTag: "v2027.01.16",
PublishedDate: modelDate,
Status: "SUCCEEDED",
Error: null,
CreatedAtUtc: DateTimeOffset.Parse("2027-01-16T00:06:00Z")))
.ReturnsAsync(new EpssImportRun(
ImportRunId: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
ModelDate: modelDate,
SourceUri: "bundle://test.csv.gz",
RetrievedAtUtc: DateTimeOffset.Parse("2027-01-16T00:05:00Z"),
FileSha256: "sha256:test",
DecompressedSha256: "sha256:decompressed",
RowCount: 1,
ModelVersionTag: "v2027.01.16b",
PublishedDate: modelDate,
Status: "SUCCEEDED",
Error: null,
CreatedAtUtc: DateTimeOffset.Parse("2027-01-16T00:06:00Z")));
var changes = new List<EpssChangeRecord>
{
new()
{
CveId = "CVE-2024-0001",
Flags = EpssChangeFlags.NewScored,
PreviousScore = null,
NewScore = 0.10,
NewPercentile = 0.91,
PreviousBand = EpssPriorityBand.Unknown,
ModelDate = modelDate
}
};
epssRepository
.Setup(r => r.GetChangesAsync(modelDate, null, 200000, It.IsAny<CancellationToken>()))
.ReturnsAsync(changes);
var observedCveRepository = new Mock<IObservedCveRepository>(MockBehavior.Strict);
observedCveRepository
.Setup(r => r.GetActiveTenantsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { tenantId });
observedCveRepository
.Setup(r => r.FilterObservedAsync(tenantId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Guid _, IEnumerable<string> cves, CancellationToken __) =>
new HashSet<string>(cves, StringComparer.OrdinalIgnoreCase));
var createdSignals = new List<EpssSignal>();
var createdSummaries = new List<EpssSignal>();
var signalRepository = new Mock<IEpssSignalRepository>(MockBehavior.Strict);
signalRepository
.Setup(r => r.CreateBulkAsync(It.IsAny<IEnumerable<EpssSignal>>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<EpssSignal>, CancellationToken>((signals, _) => createdSignals.AddRange(signals))
.ReturnsAsync((IEnumerable<EpssSignal> signals, CancellationToken _) => signals.Count());
signalRepository
.Setup(r => r.CreateAsync(It.IsAny<EpssSignal>(), It.IsAny<CancellationToken>()))
.Callback<EpssSignal, CancellationToken>((signal, _) => createdSummaries.Add(signal))
.ReturnsAsync((EpssSignal signal, CancellationToken _) => signal);
signalRepository
.Setup(r => r.PruneAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(0);
signalRepository
.Setup(r => r.GetByTenantAsync(It.IsAny<Guid>(), It.IsAny<DateOnly>(), It.IsAny<DateOnly>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<EpssSignal>());
signalRepository
.Setup(r => r.GetByCveAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<EpssSignal>());
signalRepository
.Setup(r => r.GetHighPriorityAsync(It.IsAny<Guid>(), It.IsAny<DateOnly>(), It.IsAny<DateOnly>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<EpssSignal>());
signalRepository
.Setup(r => r.GetConfigAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((EpssSignalConfig?)null);
signalRepository
.Setup(r => r.UpsertConfigAsync(It.IsAny<EpssSignalConfig>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((EpssSignalConfig cfg, CancellationToken _) => cfg);
var publisher = new Mock<IEpssSignalPublisher>(MockBehavior.Strict);
publisher
.Setup(p => p.PublishBatchAsync(It.IsAny<IEnumerable<EpssSignal>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<EpssSignal> signals, CancellationToken _) => signals.Count());
publisher
.Setup(p => p.PublishAsync(It.IsAny<EpssSignal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EpssSignalPublishResult { Success = true, MessageId = "ok" });
publisher
.Setup(p => p.PublishPriorityChangedAsync(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<double>(), It.IsAny<DateOnly>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EpssSignalPublishResult { Success = true, MessageId = "ok" });
var job = new EpssSignalJob(
epssRepository.Object,
signalRepository.Object,
observedCveRepository.Object,
publisher.Object,
provider,
Microsoft.Extensions.Options.Options.Create(new EpssSignalOptions
{
Enabled = true,
BatchSize = 500
}),
TimeProvider.System,
NullLogger<EpssSignalJob>.Instance);
await job.GenerateSignalsAsync(); // establishes _lastModelVersion
await job.GenerateSignalsAsync(); // model version changes -> emits summary
Assert.Single(createdSummaries);
Assert.Equal(EpssSignalEventTypes.ModelUpdated, createdSummaries[0].EventType);
Assert.Equal("MODEL_UPDATE", createdSummaries[0].CveId);
Assert.True(createdSummaries[0].IsModelChange);
Assert.Contains("v2027.01.16->v2027.01.16b", createdSummaries[0].DedupeKey, StringComparison.Ordinal);
}
private sealed class FixedEpssProvider : IEpssProvider
{
private readonly DateOnly? _latestModelDate;
public FixedEpssProvider(DateOnly? latestModelDate)
{
_latestModelDate = latestModelDate;
}
public Task<EpssEvidence?> GetCurrentAsync(string cveId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<EpssBatchResult> GetCurrentBatchAsync(IEnumerable<string> cveIds, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<EpssEvidence?> GetAsOfDateAsync(string cveId, DateOnly asOfDate, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<IReadOnlyList<EpssEvidence>> GetHistoryAsync(string cveId, DateOnly startDate, DateOnly endDate, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default) => Task.FromResult(_latestModelDate);
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default) => Task.FromResult(true);
}
}

View File

@@ -0,0 +1,17 @@
using System.Reflection;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.Scanner.Storage;
namespace StellaOps.Scanner.Worker.Tests.Epss;
public sealed class ScannerWorkerPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<ScannerWorkerPostgresFixture>
{
protected override Assembly? GetMigrationAssembly() => typeof(ScannerStorageOptions).Assembly;
protected override string GetModuleName() => "Scanner.Storage";
}
[CollectionDefinition("scanner-worker-postgres")]
public sealed class ScannerWorkerPostgresCollection : ICollectionFixture<ScannerWorkerPostgresFixture>
{
}

View File

@@ -29,8 +29,8 @@ public sealed class ScanCompletionMetricsIntegrationTests
.Callback<ScanMetrics, CancellationToken>((m, _) => savedMetrics.Add(m))
.Returns(Task.CompletedTask);
mockRepository
.Setup(r => r.SavePhasesAsync(It.IsAny<IEnumerable<ExecutionPhase>>(), It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ExecutionPhase>, CancellationToken>((p, _) => savedPhases.AddRange(p))
.Setup(r => r.SavePhasesAsync(It.IsAny<IReadOnlyList<ExecutionPhase>>(), It.IsAny<CancellationToken>()))
.Callback<IReadOnlyList<ExecutionPhase>, CancellationToken>((p, _) => savedPhases.AddRange(p))
.Returns(Task.CompletedTask);
var factory = new TestScanMetricsCollectorFactory(mockRepository.Object);
@@ -120,7 +120,7 @@ public sealed class ScanCompletionMetricsIntegrationTests
.Callback<ScanMetrics, CancellationToken>((m, _) => savedMetrics.Add(m))
.Returns(Task.CompletedTask);
mockRepository
.Setup(r => r.SavePhasesAsync(It.IsAny<IEnumerable<ExecutionPhase>>(), It.IsAny<CancellationToken>()))
.Setup(r => r.SavePhasesAsync(It.IsAny<IReadOnlyList<ExecutionPhase>>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var factory = new TestScanMetricsCollectorFactory(mockRepository.Object);
@@ -162,7 +162,7 @@ public sealed class ScanCompletionMetricsIntegrationTests
.Callback<ScanMetrics, CancellationToken>((m, _) => savedMetrics.Add(m))
.Returns(Task.CompletedTask);
mockRepository
.Setup(r => r.SavePhasesAsync(It.IsAny<IEnumerable<ExecutionPhase>>(), It.IsAny<CancellationToken>()))
.Setup(r => r.SavePhasesAsync(It.IsAny<IReadOnlyList<ExecutionPhase>>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var factory = new TestScanMetricsCollectorFactory(mockRepository.Object);

View File

@@ -11,5 +11,9 @@
<ProjectReference Include="../../StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Queue/StellaOps.Scanner.Queue.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" Version="4.20.72" />
</ItemGroup>
</Project>