consolidate the tests locations
This commit is contained in:
@@ -0,0 +1,732 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Parsing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Reachability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for CallgraphSchemaMigrator.
|
||||
/// Verifies schema migration from legacy format to stella.callgraph.v1.
|
||||
/// </summary>
|
||||
public class CallgraphSchemaMigratorTests
|
||||
{
|
||||
#region EnsureV1 - Schema Version Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_SetsSchemaToV1_WhenNotSet()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Schema = string.Empty
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Schema.Should().Be(CallgraphSchemaVersions.V1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_PreservesV1Schema_WhenAlreadySet()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Schema = CallgraphSchemaVersions.V1
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Schema.Should().Be(CallgraphSchemaVersions.V1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_UpdatesLegacySchema_ToV1()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Schema = "legacy-schema-1.0"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Schema.Should().Be(CallgraphSchemaVersions.V1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Language Parsing Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("dotnet", CallgraphLanguage.DotNet)]
|
||||
[InlineData(".net", CallgraphLanguage.DotNet)]
|
||||
[InlineData("csharp", CallgraphLanguage.DotNet)]
|
||||
[InlineData("c#", CallgraphLanguage.DotNet)]
|
||||
[InlineData("java", CallgraphLanguage.Java)]
|
||||
[InlineData("node", CallgraphLanguage.Node)]
|
||||
[InlineData("nodejs", CallgraphLanguage.Node)]
|
||||
[InlineData("javascript", CallgraphLanguage.Node)]
|
||||
[InlineData("typescript", CallgraphLanguage.Node)]
|
||||
[InlineData("python", CallgraphLanguage.Python)]
|
||||
[InlineData("go", CallgraphLanguage.Go)]
|
||||
[InlineData("golang", CallgraphLanguage.Go)]
|
||||
[InlineData("rust", CallgraphLanguage.Rust)]
|
||||
[InlineData("ruby", CallgraphLanguage.Ruby)]
|
||||
[InlineData("php", CallgraphLanguage.Php)]
|
||||
[InlineData("binary", CallgraphLanguage.Binary)]
|
||||
[InlineData("native", CallgraphLanguage.Binary)]
|
||||
[InlineData("elf", CallgraphLanguage.Binary)]
|
||||
[InlineData("swift", CallgraphLanguage.Swift)]
|
||||
[InlineData("kotlin", CallgraphLanguage.Kotlin)]
|
||||
public void EnsureV1_ParsesLanguageString_ToEnum(string languageString, CallgraphLanguage expected)
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Language = languageString,
|
||||
LanguageType = CallgraphLanguage.Unknown
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.LanguageType.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_PreservesLanguageType_WhenAlreadySet()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Language = "java",
|
||||
LanguageType = CallgraphLanguage.DotNet // Already set to something different
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.LanguageType.Should().Be(CallgraphLanguage.DotNet);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Node Visibility Inference Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersPublicVisibility_ForStandardNames()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "ProcessOrder", Visibility = SymbolVisibility.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.Visibility.Should().Be(SymbolVisibility.Public);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersPrivateVisibility_ForUnderscorePrefixed()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "_privateMethod", Visibility = SymbolVisibility.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.Visibility.Should().Be(SymbolVisibility.Private);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersPrivateVisibility_ForAngleBracketNames()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "<Main>$", Visibility = SymbolVisibility.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.Visibility.Should().Be(SymbolVisibility.Private);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersInternalVisibility_ForInternalNamespace()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "Helper", Namespace = "MyApp.Internal.Utils", Visibility = SymbolVisibility.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.Visibility.Should().Be(SymbolVisibility.Internal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_PreservesVisibility_WhenAlreadySet()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "_privateMethod", Visibility = SymbolVisibility.Protected }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.Visibility.Should().Be(SymbolVisibility.Protected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Symbol Key Building Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_BuildsSymbolKey_FromNamespaceAndName()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "ProcessOrder", Namespace = "MyApp.Services" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.SymbolKey.Should().Be("MyApp.Services.ProcessOrder");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_BuildsSymbolKey_FromNameOnly_WhenNoNamespace()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "GlobalMethod" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.SymbolKey.Should().Be("GlobalMethod");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_PreservesSymbolKey_WhenAlreadySet()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = "Method", Namespace = "Ns", SymbolKey = "Custom.Key" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.SymbolKey.Should().Be("Custom.Key");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Entrypoint Candidate Detection Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("Main")]
|
||||
[InlineData("main")]
|
||||
[InlineData("MAIN")]
|
||||
public void EnsureV1_DetectsEntrypointCandidate_ForMainMethod(string methodName)
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = methodName }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.IsEntrypointCandidate.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("OrdersController")]
|
||||
[InlineData("UserController")]
|
||||
public void EnsureV1_DetectsEntrypointCandidate_ForControllerNames(string name)
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = name }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.IsEntrypointCandidate.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RequestHandler")]
|
||||
[InlineData("EventHandler")]
|
||||
public void EnsureV1_DetectsEntrypointCandidate_ForHandlerNames(string name)
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = name }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.IsEntrypointCandidate.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(".cctor")]
|
||||
[InlineData("ModuleInitializer")]
|
||||
public void EnsureV1_DetectsEntrypointCandidate_ForModuleInitializers(string name)
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "node1", Name = name }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().ContainSingle()
|
||||
.Which.IsEntrypointCandidate.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Edge Reason Inference Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("call", EdgeReason.DirectCall)]
|
||||
[InlineData("direct", EdgeReason.DirectCall)]
|
||||
[InlineData("virtual", EdgeReason.VirtualCall)]
|
||||
[InlineData("callvirt", EdgeReason.VirtualCall)]
|
||||
[InlineData("newobj", EdgeReason.NewObj)]
|
||||
[InlineData("new", EdgeReason.NewObj)]
|
||||
[InlineData("ldftn", EdgeReason.DelegateCreate)]
|
||||
[InlineData("delegate", EdgeReason.DelegateCreate)]
|
||||
[InlineData("reflection", EdgeReason.ReflectionString)]
|
||||
[InlineData("di", EdgeReason.DiBinding)]
|
||||
[InlineData("injection", EdgeReason.DiBinding)]
|
||||
[InlineData("async", EdgeReason.AsyncContinuation)]
|
||||
[InlineData("continuation", EdgeReason.AsyncContinuation)]
|
||||
[InlineData("event", EdgeReason.EventHandler)]
|
||||
[InlineData("generic", EdgeReason.GenericInstantiation)]
|
||||
[InlineData("native", EdgeReason.NativeInterop)]
|
||||
[InlineData("pinvoke", EdgeReason.NativeInterop)]
|
||||
[InlineData("ffi", EdgeReason.NativeInterop)]
|
||||
public void EnsureV1_InfersEdgeReason_FromLegacyType(string legacyType, EdgeReason expected)
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new() { SourceId = "n1", TargetId = "n2", Type = legacyType, Reason = EdgeReason.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Edges.Should().ContainSingle()
|
||||
.Which.Reason.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersRuntimeMinted_ForRuntimeKind()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new() { SourceId = "n1", TargetId = "n2", Type = "unknown", Kind = EdgeKind.Runtime, Reason = EdgeReason.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Edges.Should().ContainSingle()
|
||||
.Which.Reason.Should().Be(EdgeReason.RuntimeMinted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersDynamicImport_ForHeuristicKind()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new() { SourceId = "n1", TargetId = "n2", Type = "unknown", Kind = EdgeKind.Heuristic, Reason = EdgeReason.Unknown }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Edges.Should().ContainSingle()
|
||||
.Which.Reason.Should().Be(EdgeReason.DynamicImport);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_PreservesEdgeReason_WhenAlreadySet()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new() { SourceId = "n1", TargetId = "n2", Type = "call", Reason = EdgeReason.VirtualCall }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Edges.Should().ContainSingle()
|
||||
.Which.Reason.Should().Be(EdgeReason.VirtualCall);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Entrypoint Inference Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersEntrypoints_FromEntrypointCandidateNodes()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
LanguageType = CallgraphLanguage.DotNet,
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "main", Name = "Main", IsEntrypointCandidate = true },
|
||||
new() { Id = "helper", Name = "Helper", IsEntrypointCandidate = false }
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Entrypoints.Should().ContainSingle()
|
||||
.Which.NodeId.Should().Be("main");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersEntrypoints_FromExplicitRoots()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
LanguageType = CallgraphLanguage.DotNet,
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "init", Name = "Initialize" }
|
||||
},
|
||||
Roots = new List<CallgraphRoot>
|
||||
{
|
||||
new("init", "init", "module_init")
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Entrypoints.Should().ContainSingle()
|
||||
.Which.NodeId.Should().Be("init");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_PreservesEntrypoints_WhenAlreadyPresent()
|
||||
{
|
||||
// Arrange
|
||||
var existingEntrypoint = new CallgraphEntrypoint
|
||||
{
|
||||
NodeId = "existing",
|
||||
Kind = EntrypointKind.Http,
|
||||
Route = "/api/test"
|
||||
};
|
||||
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "main", Name = "Main", IsEntrypointCandidate = true }
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint> { existingEntrypoint }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Entrypoints.Should().ContainSingle()
|
||||
.Which.NodeId.Should().Be("existing");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Ordering Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_SortsNodes_ByIdAlphabetically()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "z-node", Name = "Z" },
|
||||
new() { Id = "a-node", Name = "A" },
|
||||
new() { Id = "m-node", Name = "M" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Select(n => n.Id).Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_SortsEdges_BySourceThenTargetThenTypeThenOffset()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new() { SourceId = "b", TargetId = "x", Type = "call", Offset = 10 },
|
||||
new() { SourceId = "a", TargetId = "y", Type = "call", Offset = 5 },
|
||||
new() { SourceId = "a", TargetId = "x", Type = "virtual", Offset = 0 },
|
||||
new() { SourceId = "a", TargetId = "x", Type = "call", Offset = 20 }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
var sortedEdges = result.Edges.ToList();
|
||||
sortedEdges[0].SourceId.Should().Be("a");
|
||||
sortedEdges[0].TargetId.Should().Be("x");
|
||||
sortedEdges[0].Type.Should().Be("call");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_SortsEntrypoints_ByPhaseThenOrder()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
LanguageType = CallgraphLanguage.DotNet,
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "main", Name = "Main", IsEntrypointCandidate = true },
|
||||
new() { Id = "init", Name = ".cctor", IsEntrypointCandidate = true }
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Entrypoints.Should().HaveCount(2);
|
||||
// ModuleInit phase should come before Runtime
|
||||
result.Entrypoints.First().NodeId.Should().Be("init");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EnsureV1 - Null Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_ThrowsArgumentNullException_ForNullDocument()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => CallgraphSchemaMigrator.EnsureV1(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_HandlesEmptyNodes_Gracefully()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Nodes = new List<CallgraphNode>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_HandlesEmptyEdges_Gracefully()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Edges = new List<CallgraphEdge>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Edges.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Framework Inference Tests
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersAspNetCoreFramework_ForDotNetController()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
LanguageType = CallgraphLanguage.DotNet,
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "ctrl", Name = "OrdersController", IsEntrypointCandidate = true }
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Entrypoints.Should().ContainSingle()
|
||||
.Which.Framework.Should().Be(EntrypointFramework.AspNetCore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureV1_InfersSpringFramework_ForJavaController()
|
||||
{
|
||||
// Arrange
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
LanguageType = CallgraphLanguage.Java,
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new() { Id = "ctrl", Name = "OrderController", IsEntrypointCandidate = true }
|
||||
},
|
||||
Entrypoints = new List<CallgraphEntrypoint>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
// Assert
|
||||
result.Entrypoints.Should().ContainSingle()
|
||||
.Which.Framework.Should().Be(EntrypointFramework.Spring);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Parsing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Reachability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism tests for the stella.callgraph.v1 schema.
|
||||
/// These tests validate:
|
||||
/// - Round-trip serialization produces identical output
|
||||
/// - Schema migration from legacy formats
|
||||
/// - Enum values serialize as expected strings
|
||||
/// - Arrays maintain stable ordering
|
||||
/// </summary>
|
||||
public sealed class CallgraphSchemaV1DeterminismTests
|
||||
{
|
||||
private static readonly string RepoRoot = LocateRepoRoot();
|
||||
private static readonly string FixtureRoot = Path.Combine(RepoRoot, "tests", "reachability", "fixtures", "callgraph-schema-v1");
|
||||
|
||||
private static readonly JsonSerializerOptions DeterministicOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
public static IEnumerable<object[]> GoldenFixtures()
|
||||
{
|
||||
if (!Directory.Exists(FixtureRoot))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.GetFiles(FixtureRoot, "*.json").OrderBy(f => f, StringComparer.Ordinal))
|
||||
{
|
||||
yield return new object[] { Path.GetFileNameWithoutExtension(file) };
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtures))]
|
||||
public void GoldenFixture_DeserializesWithoutError(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json);
|
||||
|
||||
document.Should().NotBeNull();
|
||||
document!.Id.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtures))]
|
||||
public void GoldenFixture_NodesHaveRequiredFields(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
foreach (var node in document.Nodes)
|
||||
{
|
||||
node.Id.Should().NotBeNullOrEmpty($"Node in {fixtureName} must have Id");
|
||||
node.Name.Should().NotBeNullOrEmpty($"Node {node.Id} in {fixtureName} must have Name");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtures))]
|
||||
public void GoldenFixture_EdgesReferenceValidNodes(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var nodeIds = document.Nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var edge in document.Edges)
|
||||
{
|
||||
nodeIds.Should().Contain(edge.SourceId, $"Edge source {edge.SourceId} in {fixtureName} must reference existing node");
|
||||
nodeIds.Should().Contain(edge.TargetId, $"Edge target {edge.TargetId} in {fixtureName} must reference existing node");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtures))]
|
||||
public void GoldenFixture_EntrypointsReferenceValidNodes(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var nodeIds = document.Nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var entrypoint in document.Entrypoints)
|
||||
{
|
||||
nodeIds.Should().Contain(entrypoint.NodeId, $"Entrypoint {entrypoint.NodeId} in {fixtureName} must reference existing node");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DotNetFixture_HasCorrectLanguageEnum()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "dotnet-aspnetcore-minimal.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.LanguageType.Should().Be(CallgraphLanguage.DotNet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaFixture_HasCorrectLanguageEnum()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "java-spring-boot.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.LanguageType.Should().Be(CallgraphLanguage.Java);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeFixture_HasCorrectLanguageEnum()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "node-express-api.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.LanguageType.Should().Be(CallgraphLanguage.Node);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoFixture_HasCorrectLanguageEnum()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "go-gin-api.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.LanguageType.Should().Be(CallgraphLanguage.Go);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEdgeReasonsFixture_ContainsAllEdgeReasons()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-edge-reasons.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var expectedReasons = Enum.GetValues<EdgeReason>();
|
||||
var actualReasons = document.Edges.Select(e => e.Reason).Distinct().ToHashSet();
|
||||
|
||||
foreach (var expected in expectedReasons)
|
||||
{
|
||||
actualReasons.Should().Contain(expected, $"EdgeReason.{expected} should be covered by fixture");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEdgeReasonsFixture_ContainsAllEdgeKinds()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-edge-reasons.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var expectedKinds = Enum.GetValues<EdgeKind>();
|
||||
var actualKinds = document.Edges.Select(e => e.Kind).Distinct().ToHashSet();
|
||||
|
||||
foreach (var expected in expectedKinds)
|
||||
{
|
||||
actualKinds.Should().Contain(expected, $"EdgeKind.{expected} should be covered by fixture");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllVisibilityFixture_ContainsAllVisibilityLevels()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-visibility-levels.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var expectedVisibilities = Enum.GetValues<SymbolVisibility>();
|
||||
var actualVisibilities = document.Nodes.Select(n => n.Visibility).Distinct().ToHashSet();
|
||||
|
||||
foreach (var expected in expectedVisibilities)
|
||||
{
|
||||
actualVisibilities.Should().Contain(expected, $"SymbolVisibility.{expected} should be covered by fixture");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyFixture_HasNoSchemaField()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "legacy-no-schema.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
// Legacy fixture should deserialize but have default schema (v1) due to property default
|
||||
document.Schema.Should().Be(CallgraphSchemaVersions.V1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyFixture_MigratesToV1Schema()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "legacy-no-schema.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var migrated = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
|
||||
migrated.Schema.Should().Be(CallgraphSchemaVersions.V1);
|
||||
// Verify that nodes have visibility inferred (may be Unknown for some cases)
|
||||
migrated.Nodes.Should().AllSatisfy(n => Enum.IsDefined(n.Visibility).Should().BeTrue());
|
||||
// Verify that edges have reason inferred (defaults to DirectCall for legacy 'call' type)
|
||||
migrated.Edges.Should().AllSatisfy(e => Enum.IsDefined(e.Reason).Should().BeTrue());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("dotnet-aspnetcore-minimal")]
|
||||
[InlineData("java-spring-boot")]
|
||||
[InlineData("node-express-api")]
|
||||
[InlineData("go-gin-api")]
|
||||
public void V1Fixture_MigrationIsIdempotent(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var migrated1 = CallgraphSchemaMigrator.EnsureV1(document);
|
||||
var migrated2 = CallgraphSchemaMigrator.EnsureV1(migrated1);
|
||||
|
||||
migrated2.Schema.Should().Be(migrated1.Schema);
|
||||
migrated2.Nodes.Should().HaveCount(migrated1.Nodes.Count);
|
||||
migrated2.Edges.Should().HaveCount(migrated1.Edges.Count);
|
||||
migrated2.Entrypoints.Should().HaveCount(migrated1.Entrypoints.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeReason_SerializesAsCamelCaseString()
|
||||
{
|
||||
var edge = new CallgraphEdge
|
||||
{
|
||||
SourceId = "s1",
|
||||
TargetId = "t1",
|
||||
Type = "call",
|
||||
Reason = EdgeReason.DirectCall
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(edge, DeterministicOptions);
|
||||
|
||||
json.Should().Contain("\"reason\": \"directCall\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SymbolVisibility_SerializesAsCamelCaseString()
|
||||
{
|
||||
var node = new CallgraphNode
|
||||
{
|
||||
Id = "n1",
|
||||
Name = "Test",
|
||||
Kind = "method",
|
||||
Visibility = SymbolVisibility.Public
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(node, DeterministicOptions);
|
||||
|
||||
json.Should().Contain("\"visibility\": \"public\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointKind_SerializesAsCamelCaseString()
|
||||
{
|
||||
var entrypoint = new CallgraphEntrypoint
|
||||
{
|
||||
NodeId = "n1",
|
||||
Kind = EntrypointKind.Http,
|
||||
Framework = EntrypointFramework.AspNetCore
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(entrypoint, DeterministicOptions);
|
||||
|
||||
json.Should().Contain("\"kind\": \"http\"");
|
||||
json.Should().Contain("\"framework\": \"aspNetCore\"");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtures))]
|
||||
public void GoldenFixture_NodesSortedById(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var nodeIds = document.Nodes.Select(n => n.Id).ToList();
|
||||
var sortedIds = nodeIds.OrderBy(id => id, StringComparer.Ordinal).ToList();
|
||||
|
||||
nodeIds.Should().Equal(sortedIds, $"Nodes in {fixtureName} should be sorted by Id for determinism");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtures))]
|
||||
public void GoldenFixture_EntrypointsSortedByOrder(string fixtureName)
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var orders = document.Entrypoints.Select(e => e.Order).ToList();
|
||||
var sortedOrders = orders.OrderBy(o => o).ToList();
|
||||
|
||||
orders.Should().Equal(sortedOrders, $"Entrypoints in {fixtureName} should be sorted by Order for determinism");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DotNetFixture_HasCorrectAspNetCoreEntrypoints()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "dotnet-aspnetcore-minimal.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.Main && e.Framework == EntrypointFramework.AspNetCore);
|
||||
document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.Http && e.Route == "/weatherforecast");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JavaFixture_HasCorrectSpringEntrypoints()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "java-spring-boot.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.Main && e.Framework == EntrypointFramework.SpringBoot);
|
||||
document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.Http && e.Route == "/owners/{ownerId}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoFixture_HasModuleInitEntrypoint()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "go-gin-api.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.ModuleInit && e.Phase == EntrypointPhase.ModuleInit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEdgeReasonsFixture_ReflectionEdgeIsUnresolved()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-edge-reasons.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var reflectionEdge = document.Edges.Single(e => e.Reason == EdgeReason.ReflectionString);
|
||||
reflectionEdge.IsResolved.Should().BeFalse("Reflection edges are typically unresolved");
|
||||
reflectionEdge.Weight.Should().BeLessThan(1.0, "Reflection edges should have lower confidence");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEdgeReasonsFixture_DiBindingHasProvenance()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-edge-reasons.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
var diEdge = document.Edges.Single(e => e.Reason == EdgeReason.DiBinding);
|
||||
diEdge.Provenance.Should().NotBeNullOrEmpty("DI binding edges should include provenance");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Artifacts_HaveRequiredFields()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "dotnet-aspnetcore-minimal.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.Artifacts.Should().NotBeEmpty();
|
||||
foreach (var artifact in document.Artifacts)
|
||||
{
|
||||
artifact.ArtifactKey.Should().NotBeNullOrEmpty();
|
||||
artifact.Kind.Should().NotBeNullOrEmpty();
|
||||
artifact.Sha256.Should().NotBeNullOrEmpty().And.HaveLength(64);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metadata_HasRequiredToolInfo()
|
||||
{
|
||||
var json = File.ReadAllText(Path.Combine(FixtureRoot, "dotnet-aspnetcore-minimal.json"));
|
||||
var document = JsonSerializer.Deserialize<CallgraphDocument>(json)!;
|
||||
|
||||
document.GraphMetadata.Should().NotBeNull();
|
||||
document.GraphMetadata!.ToolId.Should().NotBeNullOrEmpty();
|
||||
document.GraphMetadata!.ToolVersion.Should().NotBeNullOrEmpty();
|
||||
document.GraphMetadata!.AnalysisTimestamp.Should().NotBe(default);
|
||||
}
|
||||
|
||||
private static string LocateRepoRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current != null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "Directory.Build.props")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Cannot locate repository root (missing Directory.Build.props).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Parsing;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Reachability.Tests;
|
||||
|
||||
public sealed class ReachabilityScoringTests
|
||||
{
|
||||
private static readonly string RepoRoot = LocateRepoRoot();
|
||||
private static readonly string FixtureRoot = Path.Combine(RepoRoot, "tests", "reachability", "fixtures", "reachbench-2025-expanded", "cases");
|
||||
|
||||
private static readonly (string CaseId, string Variant)[] SampleCases =
|
||||
{
|
||||
("java-log4j-CVE-2021-44228-log4shell", "reachable"),
|
||||
("java-log4j-CVE-2021-44228-log4shell", "unreachable"),
|
||||
("redis-CVE-2022-0543-lua-sandbox-escape", "reachable")
|
||||
};
|
||||
|
||||
public static IEnumerable<object[]> CaseVariants()
|
||||
{
|
||||
foreach (var (caseId, variant) in SampleCases)
|
||||
{
|
||||
var path = Path.Combine(FixtureRoot, caseId, "images", variant);
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
yield return new object[] { caseId, variant };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CaseVariants))]
|
||||
public async Task RecomputedFactsMatchTruthFixtures(string caseId, string variant)
|
||||
{
|
||||
var casePath = Path.Combine(FixtureRoot, caseId);
|
||||
var caseJson = JsonDocument.Parse(File.ReadAllText(Path.Combine(casePath, "case.json"))).RootElement;
|
||||
var reachablePathsNode = caseJson
|
||||
.GetProperty("ground_truth")
|
||||
.GetProperty("reachable_variant")
|
||||
.GetProperty("evidence")
|
||||
.GetProperty("paths");
|
||||
|
||||
var paths = reachablePathsNode.EnumerateArray()
|
||||
.Select(path => path.EnumerateArray().Select(x => x.GetString()!).Where(x => !string.IsNullOrWhiteSpace(x)).ToList())
|
||||
.Where(path => path.Count > 0)
|
||||
.ToList();
|
||||
|
||||
var entryPoints = paths
|
||||
.Select(path => path[0])
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var sinks = paths
|
||||
.Select(path => path[^1])
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var callgraph = BuildCallgraphFromPaths(caseId, paths);
|
||||
var callgraphRepo = new InMemoryCallgraphRepository(callgraph);
|
||||
var factRepo = new InMemoryReachabilityFactRepository();
|
||||
var options = new SignalsOptions();
|
||||
var cache = new InMemoryReachabilityCache();
|
||||
var eventsPublisher = new NullEventsPublisher();
|
||||
var unknowns = new InMemoryUnknownsRepository();
|
||||
var scoringService = new ReachabilityScoringService(
|
||||
callgraphRepo,
|
||||
factRepo,
|
||||
TimeProvider.System,
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
cache,
|
||||
unknowns,
|
||||
eventsPublisher,
|
||||
NullLogger<ReachabilityScoringService>.Instance);
|
||||
|
||||
var request = BuildRequest(casePath, variant, sinks, entryPoints);
|
||||
request.CallgraphId = callgraph.Id;
|
||||
|
||||
var fact = await scoringService.RecomputeAsync(request, CancellationToken.None);
|
||||
fact.States.Should().HaveCount(sinks.Count);
|
||||
|
||||
var expectedReachable = variant == "reachable";
|
||||
foreach (var sink in sinks)
|
||||
{
|
||||
var state = fact.States.Single(s => s.Target == sink);
|
||||
state.Reachable.Should().Be(expectedReachable, $"{caseId}:{variant} expected reachable={expectedReachable}");
|
||||
if (expectedReachable)
|
||||
{
|
||||
state.Path.Should().NotBeEmpty();
|
||||
state.Evidence.RuntimeHits.Should().NotBeEmpty();
|
||||
}
|
||||
else
|
||||
{
|
||||
state.Path.Should().BeEmpty();
|
||||
state.Evidence.BlockedEdges.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ReachabilityRecomputeRequest BuildRequest(string casePath, string variant, List<string> targets, List<string> entryPoints)
|
||||
{
|
||||
var caseJson = JsonDocument.Parse(File.ReadAllText(Path.Combine(casePath, "case.json"))).RootElement;
|
||||
var variantKey = variant == "reachable" ? "reachable_variant" : "unreachable_variant";
|
||||
var variantNode = caseJson.GetProperty("ground_truth").GetProperty(variantKey);
|
||||
|
||||
var blockedEdges = new List<ReachabilityBlockedEdge>();
|
||||
if (variantNode.TryGetProperty("evidence", out var evidence) && evidence.TryGetProperty("blocked_edges", out var blockedArray))
|
||||
{
|
||||
foreach (var item in blockedArray.EnumerateArray())
|
||||
{
|
||||
var parts = item.GetString()?.Split("->", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts is { Length: 2 })
|
||||
{
|
||||
blockedEdges.Add(new ReachabilityBlockedEdge { From = parts[0], To = parts[1] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var runtimeHits = new List<string>();
|
||||
var tracePath = Path.Combine(casePath, "images", variant, "traces.runtime.jsonl");
|
||||
if (File.Exists(tracePath))
|
||||
{
|
||||
foreach (var line in File.ReadLines(tracePath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
if (doc.RootElement.TryGetProperty("sid", out var sidProp))
|
||||
{
|
||||
runtimeHits.Add(sidProp.GetString()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ReachabilityRecomputeRequest
|
||||
{
|
||||
Subject = new ReachabilitySubject
|
||||
{
|
||||
ScanId = $"{Path.GetFileName(casePath)}:{variant}",
|
||||
Component = Path.GetFileName(casePath),
|
||||
Version = variant
|
||||
},
|
||||
EntryPoints = entryPoints,
|
||||
Targets = targets,
|
||||
RuntimeHits = runtimeHits,
|
||||
BlockedEdges = blockedEdges
|
||||
};
|
||||
}
|
||||
|
||||
private static CallgraphDocument BuildCallgraphFromPaths(string caseId, IReadOnlyList<IReadOnlyList<string>> paths)
|
||||
{
|
||||
var nodes = new Dictionary<string, CallgraphNode>(StringComparer.Ordinal);
|
||||
var edges = new List<CallgraphEdge>();
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var nodeId in path)
|
||||
{
|
||||
if (!nodes.ContainsKey(nodeId))
|
||||
{
|
||||
nodes[nodeId] = new CallgraphNode(nodeId, nodeId, "function", null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < path.Count - 1; i++)
|
||||
{
|
||||
edges.Add(new CallgraphEdge(path[i], path[i + 1], "call"));
|
||||
}
|
||||
}
|
||||
|
||||
return new CallgraphDocument
|
||||
{
|
||||
Id = caseId,
|
||||
Language = "fixture",
|
||||
Component = caseId,
|
||||
Version = "truth",
|
||||
Nodes = nodes.Values.OrderBy(n => n.Id, StringComparer.Ordinal).ToList(),
|
||||
Edges = edges
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
Artifact = new CallgraphArtifactMetadata
|
||||
{
|
||||
Path = $"cas://fixtures/{caseId}",
|
||||
Hash = "stub",
|
||||
ContentType = "application/json",
|
||||
Length = 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class InMemoryCallgraphRepository : ICallgraphRepository
|
||||
{
|
||||
private readonly Dictionary<string, CallgraphDocument> storage;
|
||||
|
||||
public InMemoryCallgraphRepository(CallgraphDocument document)
|
||||
{
|
||||
storage = new Dictionary<string, CallgraphDocument>(StringComparer.Ordinal)
|
||||
{
|
||||
[document.Id] = document
|
||||
};
|
||||
}
|
||||
|
||||
public Task<CallgraphDocument> UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
storage[document.Id] = document;
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
|
||||
public Task<CallgraphDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
storage.TryGetValue(id, out var document);
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepository
|
||||
{
|
||||
private readonly Dictionary<string, ReachabilityFactDocument> storage = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<ReachabilityFactDocument?> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
{
|
||||
storage.TryGetValue(subjectKey, out var document);
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
|
||||
public Task<ReachabilityFactDocument> UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
storage[document.SubjectKey] = document;
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReachabilityFactDocument>> GetExpiredAsync(DateTimeOffset olderThan, int limit, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<ReachabilityFactDocument>>(Array.Empty<ReachabilityFactDocument>());
|
||||
|
||||
public Task<bool> DeleteAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var removed = storage.Remove(subjectKey);
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
public Task<int> GetRuntimeFactsCountAsync(string subjectKey, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task TrimRuntimeFactsAsync(string subjectKey, int maxCount, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class InMemoryReachabilityCache : IReachabilityCache
|
||||
{
|
||||
private readonly Dictionary<string, ReachabilityFactDocument> storage = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<ReachabilityFactDocument?> GetAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
{
|
||||
storage.TryGetValue(subjectKey, out var doc);
|
||||
return Task.FromResult(doc);
|
||||
}
|
||||
|
||||
public Task SetAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
storage[document.SubjectKey] = document;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InvalidateAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
{
|
||||
storage.Remove(subjectKey);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryUnknownsRepository : IUnknownsRepository
|
||||
{
|
||||
public Task UpsertAsync(string subjectKey, IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<UnknownSymbolDocument>> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<UnknownSymbolDocument>)Array.Empty<UnknownSymbolDocument>());
|
||||
|
||||
public Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> unknowns, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<string>)Array.Empty<string>());
|
||||
|
||||
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<UnknownSymbolDocument>)Array.Empty<UnknownSymbolDocument>());
|
||||
|
||||
public Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(UnknownsBand? band, int limit, int offset, CancellationToken cancellationToken) =>
|
||||
Task.FromResult((IReadOnlyList<UnknownSymbolDocument>)Array.Empty<UnknownSymbolDocument>());
|
||||
|
||||
public Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<UnknownSymbolDocument?>(null);
|
||||
}
|
||||
|
||||
private sealed class NullEventsPublisher : IEventsPublisher
|
||||
{
|
||||
public Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string LocateRepoRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current != null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "Directory.Build.props")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Cannot locate repository root (missing Directory.Build.props).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Reachability.Tests;
|
||||
|
||||
public sealed class RuntimeFactsIngestionServiceTests
|
||||
{
|
||||
private readonly FakeReachabilityFactRepository repository = new();
|
||||
private readonly FakeReachabilityCache cache = new();
|
||||
private readonly FakeEventsPublisher eventsPublisher = new();
|
||||
private readonly FakeScoringService scoringService = new();
|
||||
private readonly FakeProvenanceNormalizer provenanceNormalizer = new();
|
||||
private readonly FakeTimeProvider timeProvider = new(DateTimeOffset.Parse("2025-11-09T10:15:00Z", null, System.Globalization.DateTimeStyles.AssumeUniversal));
|
||||
private readonly RuntimeFactsIngestionService sut;
|
||||
|
||||
public RuntimeFactsIngestionServiceTests()
|
||||
{
|
||||
sut = new RuntimeFactsIngestionService(
|
||||
repository,
|
||||
timeProvider,
|
||||
cache,
|
||||
eventsPublisher,
|
||||
scoringService,
|
||||
provenanceNormalizer,
|
||||
NullLogger<RuntimeFactsIngestionService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_InsertsAggregatedFacts()
|
||||
{
|
||||
var request = new RuntimeFactsIngestRequest
|
||||
{
|
||||
CallgraphId = "cg-123",
|
||||
Subject = new ReachabilitySubject { ScanId = "scan-1" },
|
||||
Events = new List<RuntimeFactEvent>
|
||||
{
|
||||
new()
|
||||
{
|
||||
SymbolId = "symbol::foo",
|
||||
HitCount = 3,
|
||||
ProcessId = 100,
|
||||
ProcessName = "worker",
|
||||
ContainerId = "ctr-1",
|
||||
SocketAddress = "10.0.0.5:443",
|
||||
Metadata = new Dictionary<string, string?> { ["thread"] = "main" }
|
||||
},
|
||||
new()
|
||||
{
|
||||
SymbolId = "symbol::foo",
|
||||
HitCount = 2
|
||||
},
|
||||
new()
|
||||
{
|
||||
SymbolId = "symbol::bar",
|
||||
CodeId = "elf:abcd",
|
||||
LoaderBase = "0x4000",
|
||||
HitCount = 1
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string?> { ["source"] = "zastava" }
|
||||
};
|
||||
|
||||
var response = await sut.IngestAsync(request, CancellationToken.None);
|
||||
|
||||
response.SubjectKey.Should().Be("scan-1");
|
||||
response.CallgraphId.Should().Be("cg-123");
|
||||
response.RuntimeFactCount.Should().Be(2);
|
||||
response.TotalHitCount.Should().Be(6);
|
||||
response.StoredAt.Should().Be(timeProvider.GetUtcNow());
|
||||
|
||||
repository.LastUpsert.Should().NotBeNull();
|
||||
repository.LastUpsert!.RuntimeFacts.Should().NotBeNull();
|
||||
repository.LastUpsert!.RuntimeFacts.Should().HaveCount(2);
|
||||
repository.LastUpsert!.RuntimeFacts![0].SymbolId.Should().Be("symbol::bar");
|
||||
repository.LastUpsert!.RuntimeFacts![0].HitCount.Should().Be(1);
|
||||
repository.LastUpsert!.RuntimeFacts![1].SymbolId.Should().Be("symbol::foo");
|
||||
repository.LastUpsert!.RuntimeFacts![1].ProcessId.Should().Be(100);
|
||||
repository.LastUpsert!.RuntimeFacts![1].ContainerId.Should().Be("ctr-1");
|
||||
repository.LastUpsert!.RuntimeFacts![1].HitCount.Should().Be(5);
|
||||
repository.LastUpsert!.Metadata.Should().ContainKey("source");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_MergesExistingDocument()
|
||||
{
|
||||
var existing = new ReachabilityFactDocument
|
||||
{
|
||||
Id = "507f1f77bcf86cd799439011",
|
||||
Subject = new ReachabilitySubject { ImageDigest = "sha256:abc" },
|
||||
SubjectKey = "sha256:abc",
|
||||
CallgraphId = "cg-old",
|
||||
RuntimeFacts = new List<RuntimeFactDocument>
|
||||
{
|
||||
new() { SymbolId = "old::symbol", HitCount = 1, Metadata = new Dictionary<string, string?> { ["thread"] = "bg" } }
|
||||
}
|
||||
};
|
||||
|
||||
repository.LastUpsert = existing;
|
||||
|
||||
var request = new RuntimeFactsIngestRequest
|
||||
{
|
||||
Subject = new ReachabilitySubject { ImageDigest = "sha256:abc" },
|
||||
CallgraphId = "cg-new",
|
||||
Events = new List<RuntimeFactEvent>
|
||||
{
|
||||
new() { SymbolId = "new::symbol", HitCount = 2, ProcessName = "svc" },
|
||||
new() { SymbolId = "old::symbol", HitCount = 3, ProcessId = 200, Metadata = new Dictionary<string, string?> { ["thread"] = "main" } }
|
||||
}
|
||||
};
|
||||
|
||||
var response = await sut.IngestAsync(request, CancellationToken.None);
|
||||
|
||||
response.FactId.Should().Be(existing.Id);
|
||||
repository.LastUpsert!.RuntimeFacts.Should().HaveCount(2);
|
||||
repository.LastUpsert!.RuntimeFacts![0].SymbolId.Should().Be("new::symbol");
|
||||
repository.LastUpsert!.RuntimeFacts![1].SymbolId.Should().Be("old::symbol");
|
||||
repository.LastUpsert!.RuntimeFacts![1].HitCount.Should().Be(4);
|
||||
repository.LastUpsert!.RuntimeFacts![1].ProcessId.Should().Be(200);
|
||||
repository.LastUpsert!.RuntimeFacts![1].Metadata.Should().ContainKey("thread").WhoseValue.Should().Be("main");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public async Task IngestAsync_ValidatesCallgraphId(string? callgraphId)
|
||||
{
|
||||
var request = new RuntimeFactsIngestRequest
|
||||
{
|
||||
Subject = new ReachabilitySubject { ScanId = "scan" },
|
||||
CallgraphId = callgraphId ?? string.Empty,
|
||||
Events = new List<RuntimeFactEvent> { new() { SymbolId = "foo" } }
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<RuntimeFactsValidationException>(() => sut.IngestAsync(request, CancellationToken.None));
|
||||
}
|
||||
|
||||
private sealed class FakeReachabilityFactRepository : IReachabilityFactRepository
|
||||
{
|
||||
public ReachabilityFactDocument? LastUpsert { get; set; }
|
||||
|
||||
public Task<ReachabilityFactDocument> UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
LastUpsert = document;
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
|
||||
public Task<ReachabilityFactDocument?> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(LastUpsert is { SubjectKey: not null } doc && doc.SubjectKey == subjectKey ? doc : null);
|
||||
|
||||
public Task<IReadOnlyList<ReachabilityFactDocument>> GetExpiredAsync(DateTimeOffset olderThan, int limit, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<ReachabilityFactDocument>>(Array.Empty<ReachabilityFactDocument>());
|
||||
|
||||
public Task<bool> DeleteAsync(string subjectKey, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(true);
|
||||
|
||||
public Task<int> GetRuntimeFactsCountAsync(string subjectKey, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task TrimRuntimeFactsAsync(string subjectKey, int maxCount, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeReachabilityCache : IReachabilityCache
|
||||
{
|
||||
private readonly Dictionary<string, ReachabilityFactDocument> storage = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<ReachabilityFactDocument?> GetAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
{
|
||||
storage.TryGetValue(subjectKey, out var document);
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
|
||||
public Task SetAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
storage[document.SubjectKey] = document;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InvalidateAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
{
|
||||
storage.Remove(subjectKey);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeEventsPublisher : IEventsPublisher
|
||||
{
|
||||
public List<ReachabilityFactDocument> Published { get; } = new();
|
||||
|
||||
public Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken)
|
||||
{
|
||||
Published.Add(fact);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeScoringService : IReachabilityScoringService
|
||||
{
|
||||
public List<ReachabilityRecomputeRequest> Requests { get; } = new();
|
||||
|
||||
public Task<ReachabilityFactDocument> RecomputeAsync(ReachabilityRecomputeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(new ReachabilityFactDocument
|
||||
{
|
||||
Subject = request.Subject,
|
||||
SubjectKey = request.Subject.ToSubjectKey(),
|
||||
CallgraphId = request.CallgraphId,
|
||||
ComputedAt = TimeProvider.System.GetUtcNow()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeProvenanceNormalizer : IRuntimeFactsProvenanceNormalizer
|
||||
{
|
||||
public ProvenanceFeed NormalizeToFeed(
|
||||
IEnumerable<RuntimeFactEvent> events,
|
||||
ReachabilitySubject subject,
|
||||
string callgraphId,
|
||||
Dictionary<string, string?>? metadata,
|
||||
DateTimeOffset generatedAt) => new()
|
||||
{
|
||||
FeedId = "fixture",
|
||||
GeneratedAt = generatedAt,
|
||||
CorrelationId = callgraphId,
|
||||
Records = new List<ProvenanceRecord>()
|
||||
};
|
||||
|
||||
public ContextFacts CreateContextFacts(
|
||||
IEnumerable<RuntimeFactEvent> events,
|
||||
ReachabilitySubject subject,
|
||||
string callgraphId,
|
||||
Dictionary<string, string?>? metadata,
|
||||
DateTimeOffset timestamp) => new()
|
||||
{
|
||||
Provenance = NormalizeToFeed(events, subject, callgraphId, metadata, timestamp),
|
||||
LastUpdatedAt = timestamp,
|
||||
RecordCount = events is ICollection<RuntimeFactEvent> collection ? collection.Count : 0
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Signals.Parsing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Reachability.Tests;
|
||||
|
||||
public sealed class RuntimeFactsNdjsonReaderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAsync_ParsesLines()
|
||||
{
|
||||
var ndjson = """
|
||||
{"symbolId":"sym::foo","hitCount":2,"processId":10,"processName":"api"}
|
||||
{"symbolId":"sym::bar","codeId":"elf:abcd","loaderBase":"0x1000","metadata":{"thread":"bg"}}
|
||||
""";
|
||||
|
||||
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||
var events = await RuntimeFactsNdjsonReader.ReadAsync(stream, gzipEncoded: false, CancellationToken.None);
|
||||
|
||||
events.Should().HaveCount(2);
|
||||
events[0].SymbolId.Should().Be("sym::foo");
|
||||
events[0].ProcessId.Should().Be(10);
|
||||
events[0].ProcessName.Should().Be("api");
|
||||
events[1].LoaderBase.Should().Be("0x1000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_HandlesGzip()
|
||||
{
|
||||
var ndjson = """
|
||||
{"symbolId":"sym::foo"}
|
||||
""";
|
||||
await using var compressed = new MemoryStream();
|
||||
await using (var gzip = new GZipStream(compressed, CompressionLevel.Optimal, leaveOpen: true))
|
||||
await using (var writer = new StreamWriter(gzip, Encoding.UTF8, leaveOpen: true))
|
||||
{
|
||||
await writer.WriteAsync(ndjson);
|
||||
}
|
||||
|
||||
compressed.Position = 0;
|
||||
|
||||
var events = await RuntimeFactsNdjsonReader.ReadAsync(compressed, gzipEncoded: true, CancellationToken.None);
|
||||
events.Should().HaveCount(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Signals.Hosting;
|
||||
using StellaOps.Signals.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Reachability.Tests;
|
||||
|
||||
public sealed class SignalsSealedModeMonitorTests : IDisposable
|
||||
{
|
||||
private readonly string tempDir = Path.Combine(Path.GetTempPath(), $"signals-sealed-tests-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
public void IsCompliant_WhenEnforcementDisabled_ReturnsTrue()
|
||||
{
|
||||
var options = new SignalsOptions();
|
||||
options.AirGap.SealedMode.EnforcementEnabled = false;
|
||||
|
||||
var monitor = CreateMonitor(options);
|
||||
|
||||
monitor.IsCompliant(out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCompliant_WhenEvidenceMissing_ReturnsFalse()
|
||||
{
|
||||
var options = CreateEnforcedOptions();
|
||||
options.AirGap.SealedMode.EvidencePath = Path.Combine(tempDir, "missing.json");
|
||||
|
||||
var monitor = CreateMonitor(options);
|
||||
|
||||
monitor.IsCompliant(out var reason).Should().BeFalse();
|
||||
reason.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCompliant_WhenEvidenceFresh_ReturnsTrue()
|
||||
{
|
||||
var evidencePath = CreateEvidenceFile(TimeSpan.Zero);
|
||||
var options = CreateEnforcedOptions();
|
||||
options.AirGap.SealedMode.EvidencePath = evidencePath;
|
||||
|
||||
var monitor = CreateMonitor(options);
|
||||
|
||||
monitor.IsCompliant(out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCompliant_WhenEvidenceStale_ReturnsFalse()
|
||||
{
|
||||
var evidencePath = CreateEvidenceFile(TimeSpan.FromHours(7));
|
||||
var options = CreateEnforcedOptions();
|
||||
options.AirGap.SealedMode.EvidencePath = evidencePath;
|
||||
|
||||
var monitor = CreateMonitor(options);
|
||||
|
||||
monitor.IsCompliant(out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
private SignalsOptions CreateEnforcedOptions()
|
||||
{
|
||||
var options = new SignalsOptions();
|
||||
options.AirGap.SealedMode.EnforcementEnabled = true;
|
||||
options.AirGap.SealedMode.MaxEvidenceAge = TimeSpan.FromHours(6);
|
||||
options.AirGap.SealedMode.CacheLifetime = TimeSpan.FromSeconds(1);
|
||||
return options;
|
||||
}
|
||||
|
||||
private string CreateEvidenceFile(TimeSpan age)
|
||||
{
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var path = Path.Combine(tempDir, $"{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, "{}");
|
||||
if (age > TimeSpan.Zero)
|
||||
{
|
||||
File.SetLastWriteTimeUtc(path, DateTime.UtcNow - age);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private SignalsSealedModeMonitor CreateMonitor(SignalsOptions options)
|
||||
{
|
||||
return new SignalsSealedModeMonitor(
|
||||
options,
|
||||
new FakeTimeProvider(DateTimeOffset.UtcNow),
|
||||
NullLogger<SignalsSealedModeMonitor>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.6.0" />
|
||||
<PackageReference Include="xunit" Version="2.7.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\fixtures\**\*">
|
||||
<Link>fixtures\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user