partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LatticeTriageServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_052_ReachGraph_8_state_reachability_lattice
|
||||
// Task: T1 - Unit tests for lattice triage service
|
||||
// Description: Deterministic tests for triage service operations including
|
||||
// evidence application, manual overrides, audit trail,
|
||||
// queries, and reset functionality.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
|
||||
namespace StellaOps.Reachability.Core.Tests;
|
||||
|
||||
public sealed class LatticeTriageServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly LatticeTriageService _service;
|
||||
|
||||
public LatticeTriageServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(
|
||||
new DateTimeOffset(2026, 2, 9, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IMeterFactory>(new TestMeterFactory());
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
_service = new LatticeTriageService(
|
||||
_timeProvider,
|
||||
provider.GetRequiredService<ILogger<LatticeTriageService>>(),
|
||||
provider.GetRequiredService<IMeterFactory>());
|
||||
}
|
||||
|
||||
/// <summary>Simple no-op meter factory for tests.</summary>
|
||||
private sealed class TestMeterFactory : IMeterFactory
|
||||
{
|
||||
private readonly List<Meter> _meters = [];
|
||||
|
||||
public Meter Create(MeterOptions options)
|
||||
{
|
||||
var meter = new Meter(options);
|
||||
_meters.Add(meter);
|
||||
return meter;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _meters) m.Dispose();
|
||||
_meters.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetOrCreate ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreate_NewEntry_ReturnsUnknownState()
|
||||
{
|
||||
var entry = await _service.GetOrCreateEntryAsync("pkg:npm/lodash@4.17.20", "CVE-2026-0001");
|
||||
|
||||
entry.CurrentState.Should().Be(LatticeState.Unknown);
|
||||
entry.Confidence.Should().Be(0.0);
|
||||
entry.VexStatus.Should().Be("under_investigation");
|
||||
entry.RequiresReview.Should().BeFalse();
|
||||
entry.Transitions.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreate_SameKey_ReturnsSameEntry()
|
||||
{
|
||||
var entry1 = await _service.GetOrCreateEntryAsync("pkg:npm/lodash@4.17.20", "CVE-2026-0001");
|
||||
var entry2 = await _service.GetOrCreateEntryAsync("pkg:npm/lodash@4.17.20", "CVE-2026-0001");
|
||||
|
||||
entry1.EntryId.Should().Be(entry2.EntryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreate_DifferentKeys_ReturnsDifferentEntries()
|
||||
{
|
||||
var entry1 = await _service.GetOrCreateEntryAsync("pkg:npm/lodash@4.17.20", "CVE-2026-0001");
|
||||
var entry2 = await _service.GetOrCreateEntryAsync("pkg:npm/lodash@4.17.21", "CVE-2026-0001");
|
||||
|
||||
entry1.EntryId.Should().NotBe(entry2.EntryId);
|
||||
}
|
||||
|
||||
// ── ApplyEvidence ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEvidence_StaticReachable_TransitionsFromUnknown()
|
||||
{
|
||||
var entry = await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.StaticReachable, "Static analysis found path");
|
||||
|
||||
entry.CurrentState.Should().Be(LatticeState.StaticReachable);
|
||||
entry.Confidence.Should().BeGreaterThan(0.0);
|
||||
entry.VexStatus.Should().Be("under_investigation");
|
||||
entry.Transitions.Should().HaveCount(1);
|
||||
entry.Transitions[0].FromState.Should().Be(LatticeState.Unknown);
|
||||
entry.Transitions[0].ToState.Should().Be(LatticeState.StaticReachable);
|
||||
entry.Transitions[0].Trigger.Should().Be(LatticeTransitionTrigger.StaticAnalysis);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEvidence_StaticThenRuntime_ReachesConfirmed()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.StaticReachable);
|
||||
|
||||
var entry = await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.RuntimeObserved);
|
||||
|
||||
entry.CurrentState.Should().Be(LatticeState.ConfirmedReachable);
|
||||
entry.VexStatus.Should().Be("affected");
|
||||
entry.Transitions.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEvidence_ConflictingEvidence_EntersContested()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.StaticUnreachable);
|
||||
|
||||
var entry = await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.RuntimeObserved);
|
||||
|
||||
entry.CurrentState.Should().Be(LatticeState.Contested);
|
||||
entry.RequiresReview.Should().BeTrue();
|
||||
entry.VexStatus.Should().Be("under_investigation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyEvidence_WithDigests_RecordsInTransition()
|
||||
{
|
||||
var digests = new[] { "sha256:abc", "sha256:def" };
|
||||
|
||||
var entry = await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.StaticReachable,
|
||||
evidenceDigests: digests);
|
||||
|
||||
entry.Transitions[0].EvidenceDigests.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
// ── Override ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Override_SetsTargetState()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.StaticUnreachable);
|
||||
|
||||
var result = await _service.OverrideStateAsync(new LatticeOverrideRequest
|
||||
{
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.20",
|
||||
Cve = "CVE-2026-0001",
|
||||
TargetState = LatticeState.ConfirmedReachable,
|
||||
Reason = "Vendor confirmed reachability",
|
||||
Actor = "security-team"
|
||||
});
|
||||
|
||||
result.Applied.Should().BeTrue();
|
||||
result.Entry.CurrentState.Should().Be(LatticeState.ConfirmedReachable);
|
||||
result.Transition.IsManualOverride.Should().BeTrue();
|
||||
result.Transition.Actor.Should().Be("security-team");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_FromConfirmed_ReturnsWarning()
|
||||
{
|
||||
// Reach ConfirmedReachable
|
||||
await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.StaticReachable);
|
||||
await _service.ApplyEvidenceAsync(
|
||||
"pkg:npm/lodash@4.17.20", "CVE-2026-0001",
|
||||
EvidenceType.RuntimeObserved);
|
||||
|
||||
var result = await _service.OverrideStateAsync(new LatticeOverrideRequest
|
||||
{
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.20",
|
||||
Cve = "CVE-2026-0001",
|
||||
TargetState = LatticeState.ConfirmedUnreachable,
|
||||
Reason = "Re-analysis confirmed false positive",
|
||||
Actor = "admin"
|
||||
});
|
||||
|
||||
result.Warning.Should().NotBeNullOrEmpty();
|
||||
result.Warning.Should().Contain("Overriding from confirmed state");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_HasOverride_IsTrue()
|
||||
{
|
||||
var result = await _service.OverrideStateAsync(new LatticeOverrideRequest
|
||||
{
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.20",
|
||||
Cve = "CVE-2026-0001",
|
||||
TargetState = LatticeState.ConfirmedUnreachable,
|
||||
Reason = "Manual verification",
|
||||
Actor = "tester"
|
||||
});
|
||||
|
||||
result.Entry.HasOverride.Should().BeTrue();
|
||||
}
|
||||
|
||||
// ── List and Query ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByState_ReturnsMatchingEntries()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.StaticReachable);
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/b@1.0", "CVE-002", EvidenceType.RuntimeObserved);
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/c@1.0", "CVE-003", EvidenceType.StaticUnreachable);
|
||||
|
||||
var results = await _service.ListAsync(new LatticeTriageQuery
|
||||
{
|
||||
State = LatticeState.StaticReachable
|
||||
});
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ComponentPurl.Should().Be("pkg:npm/a@1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterRequiresReview_ReturnsContestedOnly()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.StaticUnreachable);
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.RuntimeObserved); // Contested
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/b@1.0", "CVE-002", EvidenceType.StaticReachable);
|
||||
|
||||
var results = await _service.ListAsync(new LatticeTriageQuery
|
||||
{
|
||||
RequiresReview = true
|
||||
});
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].RequiresReview.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByPurlPrefix_ReturnsMatching()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/lodash@4.17.20", "CVE-001", EvidenceType.StaticReachable);
|
||||
await _service.ApplyEvidenceAsync("pkg:maven/log4j@2.14.0", "CVE-002", EvidenceType.StaticReachable);
|
||||
|
||||
var results = await _service.ListAsync(new LatticeTriageQuery
|
||||
{
|
||||
ComponentPurlPrefix = "pkg:npm/"
|
||||
});
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].ComponentPurl.Should().StartWith("pkg:npm/");
|
||||
}
|
||||
|
||||
// ── History ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_ReturnsFullTransitionLog()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.StaticReachable);
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.RuntimeObserved);
|
||||
|
||||
var history = await _service.GetHistoryAsync("pkg:npm/a@1.0", "CVE-001");
|
||||
|
||||
history.Should().HaveCount(2);
|
||||
history[0].FromState.Should().Be(LatticeState.Unknown);
|
||||
history[0].ToState.Should().Be(LatticeState.StaticReachable);
|
||||
history[1].FromState.Should().Be(LatticeState.StaticReachable);
|
||||
history[1].ToState.Should().Be(LatticeState.ConfirmedReachable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_NonexistentEntry_ReturnsEmpty()
|
||||
{
|
||||
var history = await _service.GetHistoryAsync("pkg:npm/nonexistent@1.0", "CVE-999");
|
||||
|
||||
history.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ── Reset ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Reset_ReturnsToUnknown()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.StaticReachable);
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.RuntimeObserved);
|
||||
|
||||
var entry = await _service.ResetAsync(
|
||||
"pkg:npm/a@1.0", "CVE-001",
|
||||
"admin", "Re-scan required");
|
||||
|
||||
entry.CurrentState.Should().Be(LatticeState.Unknown);
|
||||
entry.Confidence.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reset_RecordsTransition()
|
||||
{
|
||||
await _service.ApplyEvidenceAsync("pkg:npm/a@1.0", "CVE-001", EvidenceType.StaticReachable);
|
||||
|
||||
await _service.ResetAsync(
|
||||
"pkg:npm/a@1.0", "CVE-001",
|
||||
"admin", "Re-scan");
|
||||
|
||||
var history = await _service.GetHistoryAsync("pkg:npm/a@1.0", "CVE-001");
|
||||
var last = history[^1];
|
||||
last.Trigger.Should().Be(LatticeTransitionTrigger.SystemReset);
|
||||
last.Actor.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reset_NonexistentEntry_Throws()
|
||||
{
|
||||
var act = () => _service.ResetAsync(
|
||||
"pkg:npm/nonexistent@1.0", "CVE-999",
|
||||
"admin", "test");
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
// ── Edge cases ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreate_ThrowsOnNullPurl()
|
||||
{
|
||||
var act = () => _service.GetOrCreateEntryAsync(null!, "CVE-001");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Override_ThrowsOnEmptyReason()
|
||||
{
|
||||
var act = () => _service.OverrideStateAsync(new LatticeOverrideRequest
|
||||
{
|
||||
ComponentPurl = "pkg:npm/a@1.0",
|
||||
Cve = "CVE-001",
|
||||
TargetState = LatticeState.ConfirmedReachable,
|
||||
Reason = "",
|
||||
Actor = "admin"
|
||||
});
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
// ── VEX status mapping ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(LatticeState.ConfirmedUnreachable, "not_affected")]
|
||||
[InlineData(LatticeState.RuntimeObserved, "affected")]
|
||||
[InlineData(LatticeState.StaticUnreachable, "not_affected")]
|
||||
public async Task VexStatus_MapsCorrectly(LatticeState targetState, string expectedVex)
|
||||
{
|
||||
var result = await _service.OverrideStateAsync(new LatticeOverrideRequest
|
||||
{
|
||||
ComponentPurl = $"pkg:npm/test-{targetState}@1.0",
|
||||
Cve = $"CVE-{(int)targetState:D3}",
|
||||
TargetState = targetState,
|
||||
Reason = "Test",
|
||||
Actor = "test"
|
||||
});
|
||||
|
||||
result.Entry.VexStatus.Should().Be(expectedVex);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user