Add Canonical JSON serialization library with tests and documentation
- Implemented CanonJson class for deterministic JSON serialization and hashing. - Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters. - Created project files for the Canonical JSON library and its tests, including necessary package references. - Added README.md for library usage and API reference. - Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReachabilityCacheTests.cs
|
||||
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-016, CACHE-017)
|
||||
// Description: Unit tests for reachability cache components.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Reachability.Cache;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public sealed class GraphDeltaComputerTests
|
||||
{
|
||||
private readonly GraphDeltaComputer _computer;
|
||||
|
||||
public GraphDeltaComputerTests()
|
||||
{
|
||||
_computer = new GraphDeltaComputer(NullLogger<GraphDeltaComputer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_SameHash_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
|
||||
var graph2 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
|
||||
|
||||
// Act
|
||||
var delta = await _computer.ComputeDeltaAsync(graph1, graph2);
|
||||
|
||||
// Assert
|
||||
delta.HasChanges.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_AddedNode_ReturnsCorrectDelta()
|
||||
{
|
||||
// Arrange
|
||||
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
|
||||
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B", "C" }, new[] { ("A", "B"), ("B", "C") });
|
||||
|
||||
// Act
|
||||
var delta = await _computer.ComputeDeltaAsync(graph1, graph2);
|
||||
|
||||
// Assert
|
||||
delta.HasChanges.Should().BeTrue();
|
||||
delta.AddedNodes.Should().Contain("C");
|
||||
delta.RemovedNodes.Should().BeEmpty();
|
||||
delta.AddedEdges.Should().ContainSingle(e => e.CallerKey == "B" && e.CalleeKey == "C");
|
||||
delta.AffectedMethodKeys.Should().Contain("C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_RemovedNode_ReturnsCorrectDelta()
|
||||
{
|
||||
// Arrange
|
||||
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B", "C" }, new[] { ("A", "B"), ("B", "C") });
|
||||
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B" }, new[] { ("A", "B") });
|
||||
|
||||
// Act
|
||||
var delta = await _computer.ComputeDeltaAsync(graph1, graph2);
|
||||
|
||||
// Assert
|
||||
delta.HasChanges.Should().BeTrue();
|
||||
delta.RemovedNodes.Should().Contain("C");
|
||||
delta.AddedNodes.Should().BeEmpty();
|
||||
delta.RemovedEdges.Should().ContainSingle(e => e.CallerKey == "B" && e.CalleeKey == "C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_EdgeChange_DetectsAffectedMethods()
|
||||
{
|
||||
// Arrange
|
||||
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B", "C" }, new[] { ("A", "B") });
|
||||
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B", "C" }, new[] { ("A", "C") });
|
||||
|
||||
// Act
|
||||
var delta = await _computer.ComputeDeltaAsync(graph1, graph2);
|
||||
|
||||
// Assert
|
||||
delta.HasChanges.Should().BeTrue();
|
||||
delta.AddedEdges.Should().ContainSingle(e => e.CallerKey == "A" && e.CalleeKey == "C");
|
||||
delta.RemovedEdges.Should().ContainSingle(e => e.CallerKey == "A" && e.CalleeKey == "B");
|
||||
delta.AffectedMethodKeys.Should().Contain(new[] { "A", "B", "C" });
|
||||
}
|
||||
|
||||
private sealed class TestGraphSnapshot : IGraphSnapshot
|
||||
{
|
||||
public string Hash { get; }
|
||||
public IReadOnlySet<string> NodeKeys { get; }
|
||||
public IReadOnlyList<GraphEdge> Edges { get; }
|
||||
public IReadOnlySet<string> EntryPoints { get; }
|
||||
|
||||
public TestGraphSnapshot(string hash, string[] nodes, (string, string)[] edges, string[]? entryPoints = null)
|
||||
{
|
||||
Hash = hash;
|
||||
NodeKeys = nodes.ToHashSet();
|
||||
Edges = edges.Select(e => new GraphEdge(e.Item1, e.Item2)).ToList();
|
||||
EntryPoints = (entryPoints ?? nodes.Take(1).ToArray()).ToHashSet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ImpactSetCalculatorTests
|
||||
{
|
||||
private readonly ImpactSetCalculator _calculator;
|
||||
|
||||
public ImpactSetCalculatorTests()
|
||||
{
|
||||
_calculator = new ImpactSetCalculator(NullLogger<ImpactSetCalculator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateImpactAsync_NoDelta_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var delta = GraphDelta.Empty;
|
||||
var graph = new TestGraphSnapshot("hash1", new[] { "Entry", "A", "B" }, new[] { ("Entry", "A"), ("A", "B") });
|
||||
|
||||
// Act
|
||||
var impact = await _calculator.CalculateImpactAsync(delta, graph);
|
||||
|
||||
// Assert
|
||||
impact.RequiresFullRecompute.Should().BeFalse();
|
||||
impact.AffectedEntryPoints.Should().BeEmpty();
|
||||
impact.SavingsRatio.Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateImpactAsync_ChangeInPath_IdentifiesAffectedEntry()
|
||||
{
|
||||
// Arrange
|
||||
var delta = new GraphDelta
|
||||
{
|
||||
AddedNodes = new HashSet<string> { "C" },
|
||||
AddedEdges = new List<GraphEdge> { new("B", "C") },
|
||||
AffectedMethodKeys = new HashSet<string> { "B", "C" }
|
||||
};
|
||||
|
||||
var graph = new TestGraphSnapshot(
|
||||
"hash2",
|
||||
new[] { "Entry", "A", "B", "C" },
|
||||
new[] { ("Entry", "A"), ("A", "B"), ("B", "C") },
|
||||
new[] { "Entry" });
|
||||
|
||||
// Act
|
||||
var impact = await _calculator.CalculateImpactAsync(delta, graph);
|
||||
|
||||
// Assert
|
||||
impact.RequiresFullRecompute.Should().BeFalse();
|
||||
impact.AffectedEntryPoints.Should().Contain("Entry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateImpactAsync_ManyAffected_TriggersFullRecompute()
|
||||
{
|
||||
// Arrange - More than 30% affected
|
||||
var delta = new GraphDelta
|
||||
{
|
||||
AffectedMethodKeys = new HashSet<string> { "Entry1", "Entry2", "Entry3", "Entry4" }
|
||||
};
|
||||
|
||||
var graph = new TestGraphSnapshot(
|
||||
"hash2",
|
||||
new[] { "Entry1", "Entry2", "Entry3", "Entry4", "Sink" },
|
||||
new[] { ("Entry1", "Sink"), ("Entry2", "Sink"), ("Entry3", "Sink"), ("Entry4", "Sink") },
|
||||
new[] { "Entry1", "Entry2", "Entry3", "Entry4" });
|
||||
|
||||
// Act
|
||||
var impact = await _calculator.CalculateImpactAsync(delta, graph);
|
||||
|
||||
// Assert - All 4 entries affected = 100% > 30% threshold
|
||||
impact.RequiresFullRecompute.Should().BeTrue();
|
||||
}
|
||||
|
||||
private sealed class TestGraphSnapshot : IGraphSnapshot
|
||||
{
|
||||
public string Hash { get; }
|
||||
public IReadOnlySet<string> NodeKeys { get; }
|
||||
public IReadOnlyList<GraphEdge> Edges { get; }
|
||||
public IReadOnlySet<string> EntryPoints { get; }
|
||||
|
||||
public TestGraphSnapshot(string hash, string[] nodes, (string, string)[] edges, string[]? entryPoints = null)
|
||||
{
|
||||
Hash = hash;
|
||||
NodeKeys = nodes.ToHashSet();
|
||||
Edges = edges.Select(e => new GraphEdge(e.Item1, e.Item2)).ToList();
|
||||
EntryPoints = (entryPoints ?? nodes.Take(1).ToArray()).ToHashSet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StateFlipDetectorTests
|
||||
{
|
||||
private readonly StateFlipDetector _detector;
|
||||
|
||||
public StateFlipDetectorTests()
|
||||
{
|
||||
_detector = new StateFlipDetector(NullLogger<StateFlipDetector>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectFlipsAsync_NoChanges_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
var current = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _detector.DetectFlipsAsync(previous, current);
|
||||
|
||||
// Assert
|
||||
result.HasFlips.Should().BeFalse();
|
||||
result.NewRiskCount.Should().Be(0);
|
||||
result.MitigatedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectFlipsAsync_BecameReachable_ReturnsNewRisk()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
var current = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _detector.DetectFlipsAsync(previous, current);
|
||||
|
||||
// Assert
|
||||
result.HasFlips.Should().BeTrue();
|
||||
result.NewRiskCount.Should().Be(1);
|
||||
result.MitigatedCount.Should().Be(0);
|
||||
result.NewlyReachable.Should().ContainSingle()
|
||||
.Which.FlipType.Should().Be(StateFlipType.BecameReachable);
|
||||
result.ShouldBlockPr.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectFlipsAsync_BecameUnreachable_ReturnsMitigated()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
var current = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _detector.DetectFlipsAsync(previous, current);
|
||||
|
||||
// Assert
|
||||
result.HasFlips.Should().BeTrue();
|
||||
result.NewRiskCount.Should().Be(0);
|
||||
result.MitigatedCount.Should().Be(1);
|
||||
result.NewlyUnreachable.Should().ContainSingle()
|
||||
.Which.FlipType.Should().Be(StateFlipType.BecameUnreachable);
|
||||
result.ShouldBlockPr.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectFlipsAsync_NewReachablePair_ReturnsNewRisk()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new List<ReachablePairResult>();
|
||||
|
||||
var current = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _detector.DetectFlipsAsync(previous, current);
|
||||
|
||||
// Assert
|
||||
result.HasFlips.Should().BeTrue();
|
||||
result.NewRiskCount.Should().Be(1);
|
||||
result.ShouldBlockPr.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectFlipsAsync_RemovedReachablePair_ReturnsMitigated()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
var current = new List<ReachablePairResult>();
|
||||
|
||||
// Act
|
||||
var result = await _detector.DetectFlipsAsync(previous, current);
|
||||
|
||||
// Assert
|
||||
result.HasFlips.Should().BeTrue();
|
||||
result.MitigatedCount.Should().Be(1);
|
||||
result.ShouldBlockPr.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectFlipsAsync_NetChange_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "E1", SinkMethodKey = "S1", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
|
||||
new() { EntryMethodKey = "E2", SinkMethodKey = "S2", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
var current = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "E1", SinkMethodKey = "S1", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
|
||||
new() { EntryMethodKey = "E2", SinkMethodKey = "S2", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
|
||||
new() { EntryMethodKey = "E3", SinkMethodKey = "S3", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _detector.DetectFlipsAsync(previous, current);
|
||||
|
||||
// Assert
|
||||
result.NewRiskCount.Should().Be(2); // E2->S2 became reachable, E3->S3 new
|
||||
result.MitigatedCount.Should().Be(1); // E1->S1 became unreachable
|
||||
result.NetChange.Should().Be(1); // +2 - 1 = 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user