Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,316 @@
// <copyright file="EbpfSignalMergerTests.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
namespace StellaOps.Signals.Ebpf.Tests;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Reachability.Runtime;
using StellaOps.Scanner.Reachability.Slices;
using StellaOps.Signals.Ebpf.Schema;
using Xunit;
/// <summary>
/// Tests for <see cref="EbpfSignalMerger"/>.
/// </summary>
public sealed class EbpfSignalMergerTests
{
private readonly EbpfSignalMerger _merger;
private readonly RuntimeStaticMerger _baseMerger;
public EbpfSignalMergerTests()
{
_baseMerger = new RuntimeStaticMerger();
_merger = new EbpfSignalMerger(
_baseMerger,
NullLogger<EbpfSignalMerger>.Instance);
}
[Fact]
public void Merge_WithNoSignals_ReturnsSameGraph()
{
var graph = CreateTestGraph();
var result = _merger.Merge(graph, null);
Assert.Same(graph, result.MergedGraph);
Assert.Empty(result.Evidence);
Assert.Equal(2, result.Statistics.StaticEdgeCount);
Assert.Equal(0, result.Statistics.RuntimeEventCount);
}
[Fact]
public void Merge_WithEmptySignals_ReturnsSameGraph()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "container-123",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 0,
CallPaths = [],
ObservedSymbols = [],
};
var result = _merger.Merge(graph, signals);
Assert.Same(graph, result.MergedGraph);
Assert.Empty(result.Evidence);
}
[Fact]
public void Merge_WithMatchingSignals_CreatesConfirmedEvidence()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "container-123",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 100,
CallPaths = new List<ObservedCallPath>
{
new()
{
Symbols = ["main", "processRequest"],
ObservationCount = 50,
FirstObservedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastObservedAt = DateTimeOffset.UtcNow,
},
},
ObservedSymbols = ["main", "processRequest"],
};
var result = _merger.Merge(graph, signals);
Assert.NotEmpty(result.Evidence);
Assert.Contains(result.Evidence, e =>
e.Type == RuntimeEvidenceType.RuntimeConfirmed);
}
[Fact]
public void Merge_WithRuntimeOnlyPath_CreatesRuntimeOnlyEvidence()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "container-123",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 100,
CallPaths = new List<ObservedCallPath>
{
new()
{
// Path not in static graph
Symbols = ["dynamic_dispatch", "hidden_method"],
ObservationCount = 20,
FirstObservedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastObservedAt = DateTimeOffset.UtcNow,
},
},
ObservedSymbols = ["dynamic_dispatch", "hidden_method"],
};
var result = _merger.Merge(graph, signals);
Assert.Contains(result.Evidence, e =>
e.Type == RuntimeEvidenceType.RuntimeOnly);
Assert.True(result.Statistics.RuntimeOnlyPathCount > 0);
}
[Fact]
public void Merge_WithDetectedRuntimes_CreatesRuntimeDetectedEvidence()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "container-123",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 100,
CallPaths = [],
ObservedSymbols = [],
DetectedRuntimes = [RuntimeType.Node, RuntimeType.Python],
};
var result = _merger.Merge(graph, signals);
Assert.Contains(result.Evidence, e =>
e.Type == RuntimeEvidenceType.RuntimeDetected &&
e.RuntimeType == "Node");
Assert.Contains(result.Evidence, e =>
e.Type == RuntimeEvidenceType.RuntimeDetected &&
e.RuntimeType == "Python");
}
[Fact]
public void ValidatePath_WithValidPath_ReturnsConfirmed()
{
var graph = CreateTestGraph();
var path = new ObservedCallPath
{
Symbols = ["main", "processRequest"],
ObservationCount = 10,
FirstObservedAt = DateTimeOffset.UtcNow,
LastObservedAt = DateTimeOffset.UtcNow,
};
var result = _merger.ValidatePath(graph, path);
Assert.True(result.IsValid);
Assert.Equal(PathType.Confirmed, result.PathType);
Assert.Equal(1.0, result.MatchRatio);
}
[Fact]
public void ValidatePath_WithUnknownPath_ReturnsRuntimeOnly()
{
var graph = CreateTestGraph();
var path = new ObservedCallPath
{
Symbols = ["unknown", "method"],
ObservationCount = 10,
FirstObservedAt = DateTimeOffset.UtcNow,
LastObservedAt = DateTimeOffset.UtcNow,
};
var result = _merger.ValidatePath(graph, path);
Assert.True(result.IsValid);
Assert.Equal(PathType.RuntimeOnly, result.PathType);
Assert.Equal(0.0, result.MatchRatio);
}
[Fact]
public void ValidatePath_WithShortPath_ReturnsInvalid()
{
var graph = CreateTestGraph();
var path = new ObservedCallPath
{
Symbols = ["single"],
ObservationCount = 10,
FirstObservedAt = DateTimeOffset.UtcNow,
LastObservedAt = DateTimeOffset.UtcNow,
};
var result = _merger.ValidatePath(graph, path);
Assert.False(result.IsValid);
Assert.Equal(PathType.Invalid, result.PathType);
}
[Fact]
public void Merge_StatisticsAreAccurate()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "container-123",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 500,
CallPaths = new List<ObservedCallPath>
{
new()
{
Symbols = ["main", "processRequest"],
ObservationCount = 100,
FirstObservedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastObservedAt = DateTimeOffset.UtcNow,
},
},
ObservedSymbols = ["main", "processRequest"],
DroppedEvents = 10,
};
var result = _merger.Merge(graph, signals);
Assert.Equal(2, result.Statistics.StaticEdgeCount);
Assert.Equal(500, result.Statistics.RuntimeEventCount);
Assert.Equal(1, result.Statistics.CallPathCount);
Assert.Equal(10, result.Statistics.DroppedEventCount);
}
[Fact]
public void RuntimeEvidence_ContainsContainerId()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "my-container-id",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 100,
CallPaths = new List<ObservedCallPath>
{
new()
{
Symbols = ["main", "processRequest"],
ObservationCount = 10,
FirstObservedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastObservedAt = DateTimeOffset.UtcNow,
},
},
ObservedSymbols = [],
};
var result = _merger.Merge(graph, signals);
Assert.All(result.Evidence, e =>
Assert.Equal("my-container-id", e.ContainerId));
}
[Fact]
public void EvidenceSource_IsEbpf()
{
var graph = CreateTestGraph();
var signals = new RuntimeSignalSummary
{
ContainerId = "container-123",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 100,
CallPaths = new List<ObservedCallPath>
{
new()
{
Symbols = ["main", "processRequest"],
ObservationCount = 10,
FirstObservedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastObservedAt = DateTimeOffset.UtcNow,
},
},
ObservedSymbols = [],
};
var result = _merger.Merge(graph, signals);
Assert.All(result.Evidence, e =>
Assert.Equal(EvidenceSource.Ebpf, e.Source));
}
private static RichGraph CreateTestGraph()
{
return new RichGraph(
Nodes: new List<RichGraphNode>
{
new("main", "main", null, null, "native", "entrypoint", null, null, null, null, null),
new("processRequest", "processRequest", null, null, "native", "function", null, null, null, null, null),
new("handleError", "handleError", null, null, "native", "function", null, null, null, null, null),
},
Edges: new List<RichGraphEdge>
{
new("main", "processRequest", "call", null, null, null, 1.0, null),
new("processRequest", "handleError", "call", null, null, null, 0.8, null),
},
Roots: new List<RichGraphRoot>
{
new("main", "main", "entrypoint")
},
Analyzer: new RichGraphAnalyzer("test-analyzer", "1.0.0", null)
);
}
}

View File

@@ -0,0 +1,209 @@
// <copyright file="RuntimeSignalCollectorTests.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
namespace StellaOps.Signals.Ebpf.Tests;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Signals.Ebpf.Probes;
using StellaOps.Signals.Ebpf.Schema;
using StellaOps.Signals.Ebpf.Services;
using Xunit;
/// <summary>
/// Tests for <see cref="RuntimeSignalCollector"/>.
/// </summary>
public sealed class RuntimeSignalCollectorTests
{
private readonly RuntimeSignalCollector _collector;
private readonly MockProbeLoader _probeLoader;
public RuntimeSignalCollectorTests()
{
_probeLoader = new MockProbeLoader();
_collector = new RuntimeSignalCollector(
NullLogger<RuntimeSignalCollector>.Instance,
_probeLoader);
}
[Fact]
public void IsSupported_ReturnsCorrectValue()
{
// On non-Linux systems, eBPF is not supported
var isSupported = _collector.IsSupported();
// This will be false on Windows/macOS test runners
Assert.True(isSupported == false || Environment.OSVersion.Platform == PlatformID.Unix);
}
[Fact]
public void GetSupportedProbeTypes_ReturnsEmptyOnUnsupportedPlatform()
{
var probeTypes = _collector.GetSupportedProbeTypes();
// On non-Linux, should be empty
if (!_collector.IsSupported())
{
Assert.Empty(probeTypes);
}
}
[Fact]
public void RuntimeCallEvent_HasCorrectProperties()
{
var evt = new RuntimeCallEvent
{
EventId = Guid.NewGuid(),
ContainerId = "container-123",
Pid = 1234,
Tid = 5678,
TimestampNs = 1000000000,
Symbol = "malloc",
FunctionAddress = 0x7fff12345678,
StackTrace = [0x7fff00001000, 0x7fff00002000],
RuntimeType = RuntimeType.Native,
Library = "libc.so.6",
Purl = "pkg:deb/ubuntu/libc6@2.31",
};
Assert.Equal("container-123", evt.ContainerId);
Assert.Equal(1234, evt.Pid);
Assert.Equal("malloc", evt.Symbol);
Assert.Equal(RuntimeType.Native, evt.RuntimeType);
Assert.Equal(2, evt.StackTrace.Count);
}
[Fact]
public void RuntimeSignalSummary_HasCorrectProperties()
{
var summary = new RuntimeSignalSummary
{
ContainerId = "container-456",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
StoppedAt = DateTimeOffset.UtcNow,
TotalEvents = 1000,
CallPaths = new List<ObservedCallPath>
{
new()
{
Symbols = ["main", "processRequest", "vulnerable_func"],
ObservationCount = 50,
Purl = "pkg:npm/lodash@4.17.20",
},
},
ObservedSymbols = ["main", "processRequest", "vulnerable_func"],
DroppedEvents = 10,
DetectedRuntimes = [RuntimeType.Node],
};
Assert.Equal(1000, summary.TotalEvents);
Assert.Single(summary.CallPaths);
Assert.Equal(3, summary.CallPaths[0].Symbols.Count);
Assert.Equal(50, summary.CallPaths[0].ObservationCount);
}
[Fact]
public void RuntimeSignalOptions_HasDefaultValues()
{
var options = new RuntimeSignalOptions();
Assert.Empty(options.TargetSymbols);
Assert.Equal(10000, options.MaxEventsPerSecond);
Assert.Null(options.MaxDuration);
Assert.True(options.ResolveSymbols);
Assert.Equal(16, options.MaxStackDepth);
Assert.Equal(256 * 1024, options.RingBufferSize);
Assert.Equal(1, options.SampleRate);
}
[Fact]
public void SignalStatistics_CapturesMetrics()
{
var stats = new SignalStatistics
{
TotalEvents = 5000,
EventsPerSecond = 250.5,
UniqueCallPaths = 42,
BufferUtilization = 0.35,
DroppedEvents = 5,
CpuOverheadPercent = 0.1,
MemoryUsageBytes = 1024 * 1024,
};
Assert.Equal(5000, stats.TotalEvents);
Assert.Equal(250.5, stats.EventsPerSecond);
Assert.Equal(42, stats.UniqueCallPaths);
Assert.Equal(0.35, stats.BufferUtilization);
}
[Fact]
public void ObservedCallPath_TracksObservations()
{
var path = new ObservedCallPath
{
Symbols = ["entry", "middle", "vulnerable"],
ObservationCount = 100,
Purl = "pkg:golang/example.com/pkg@1.0.0",
RuntimeType = RuntimeType.Go,
FirstObservedAt = DateTimeOffset.UtcNow.AddHours(-1),
LastObservedAt = DateTimeOffset.UtcNow,
};
Assert.Equal(3, path.Symbols.Count);
Assert.Equal(100, path.ObservationCount);
Assert.Equal(RuntimeType.Go, path.RuntimeType);
Assert.True(path.LastObservedAt > path.FirstObservedAt);
}
[Theory]
[InlineData(RuntimeType.Native, "Native")]
[InlineData(RuntimeType.Jvm, "Jvm")]
[InlineData(RuntimeType.Node, "Node")]
[InlineData(RuntimeType.Python, "Python")]
[InlineData(RuntimeType.DotNet, "DotNet")]
[InlineData(RuntimeType.Go, "Go")]
[InlineData(RuntimeType.Ruby, "Ruby")]
[InlineData(RuntimeType.Unknown, "Unknown")]
public void RuntimeType_HasCorrectStringRepresentation(RuntimeType type, string expected)
{
Assert.Equal(expected, type.ToString());
}
private sealed class MockProbeLoader : IEbpfProbeLoader
{
public Task<EbpfProbeHandle> LoadAndAttachAsync(
string containerId,
RuntimeSignalOptions options,
CancellationToken ct = default)
{
return Task.FromResult(new EbpfProbeHandle
{
ProbeId = Guid.NewGuid(),
ContainerId = containerId,
TracedPids = [],
});
}
public Task DetachAsync(EbpfProbeHandle handle, CancellationToken ct = default)
=> Task.CompletedTask;
public async IAsyncEnumerable<ReadOnlyMemory<byte>> ReadEventsAsync(
EbpfProbeHandle handle,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
await Task.Yield();
yield break;
}
public (string? Symbol, string? Library, string? Purl) ResolveSymbol(int pid, ulong address)
=> (null, null, null);
public double GetBufferUtilization(EbpfProbeHandle handle) => 0.0;
public double GetCpuOverhead(EbpfProbeHandle handle) => 0.0;
public long GetMemoryUsage(EbpfProbeHandle handle) => 0;
public IReadOnlyList<ProbeType> GetSupportedProbeTypes() => [];
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Signals.Ebpf\StellaOps.Signals.Ebpf.csproj" />
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MicrosoftOptions = Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence.Postgres;
using StellaOps.Signals.Persistence.Postgres.Repositories;
using StellaOps.Signals.Services;
using StellaOps.TestKit;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Signals.Persistence.Tests;
/// <summary>
/// Integration tests for callgraph projection to relational tables.
/// </summary>
[Collection(SignalsPostgresCollection.Name)]
public sealed class CallGraphProjectionIntegrationTests : IAsyncLifetime
{
private readonly SignalsPostgresFixture _fixture;
private readonly ITestOutputHelper _output;
private readonly SignalsDataSource _dataSource;
private readonly PostgresCallGraphQueryRepository _queryRepository;
private readonly CallGraphSyncService _service;
public CallGraphProjectionIntegrationTests(SignalsPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
_dataSource = new SignalsDataSource(MicrosoftOptions.Options.Create(options), NullLogger<SignalsDataSource>.Instance);
var projectionRepository = new PostgresCallGraphProjectionRepository(
_dataSource,
NullLogger<PostgresCallGraphProjectionRepository>.Instance);
_queryRepository = new PostgresCallGraphQueryRepository(
_dataSource,
NullLogger<PostgresCallGraphQueryRepository>.Instance);
_service = new CallGraphSyncService(
projectionRepository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.Instance);
}
public async Task InitializeAsync()
{
await _fixture.ExecuteSqlAsync("TRUNCATE TABLE signals.scans CASCADE;");
}
public async Task DisposeAsync()
{
await _dataSource.DisposeAsync();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SyncAsync_ProjectsNodesToRelationalTable()
{
var scanId = Guid.NewGuid();
var document = CreateSampleDocument();
// Act
var result = await _service.SyncAsync(scanId, "sha256:test-digest", document);
// Assert
Assert.True(result.WasUpdated);
Assert.Equal(document.Nodes.Count, result.NodesProjected);
Assert.Equal(document.Edges.Count, result.EdgesProjected);
Assert.Equal(document.Entrypoints.Count, result.EntrypointsProjected);
Assert.True(result.DurationMs >= 0);
_output.WriteLine($"Projected {result.NodesProjected} nodes, {result.EdgesProjected} edges, {result.EntrypointsProjected} entrypoints in {result.DurationMs}ms");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SyncAsync_IsIdempotent_DoesNotCreateDuplicates()
{
var scanId = Guid.NewGuid();
var document = CreateSampleDocument();
// Act - project twice
var result1 = await _service.SyncAsync(scanId, "sha256:test-digest", document);
var result2 = await _service.SyncAsync(scanId, "sha256:test-digest", document);
// Assert - second run should update, not duplicate
Assert.Equal(result1.NodesProjected, result2.NodesProjected);
Assert.Equal(result1.EdgesProjected, result2.EdgesProjected);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SyncAsync_WithEntrypoints_ProjectsEntrypointsCorrectly()
{
var scanId = Guid.NewGuid();
var document = new CallgraphDocument
{
Id = Guid.NewGuid().ToString("N"),
Language = "csharp",
GraphHash = "test-hash",
Nodes = new List<CallgraphNode>
{
new() { Id = "node-1", Name = "GetUsers", Namespace = "Api.Controllers" },
new() { Id = "node-2", Name = "CreateUser", Namespace = "Api.Controllers" }
},
Edges = new List<CallgraphEdge>(),
Entrypoints = new List<CallgraphEntrypoint>
{
new() { NodeId = "node-1", Kind = EntrypointKind.Http, Route = "/api/users", HttpMethod = "GET", Order = 0 },
new() { NodeId = "node-2", Kind = EntrypointKind.Http, Route = "/api/users", HttpMethod = "POST", Order = 1 }
}
};
// Act
var result = await _service.SyncAsync(scanId, "sha256:test-digest", document);
// Assert
Assert.Equal(2, result.EntrypointsProjected);
_output.WriteLine($"Projected {result.EntrypointsProjected} HTTP entrypoints");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DeleteByScanAsync_RemovesAllProjectedData()
{
var scanId = Guid.NewGuid();
var document = CreateSampleDocument();
// Project first
await _service.SyncAsync(scanId, "sha256:test-digest", document);
// Act
await _service.DeleteByScanAsync(scanId);
// Assert - query should return empty stats
var stats = await _queryRepository.GetStatsAsync(scanId);
Assert.Equal(0, stats.NodeCount);
Assert.Equal(0, stats.EdgeCount);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task QueryRepository_CanQueryProjectedData()
{
var scanId = Guid.NewGuid();
var document = CreateSampleDocument();
// Project
await _service.SyncAsync(scanId, "sha256:test-digest", document);
// Act
var stats = await _queryRepository.GetStatsAsync(scanId);
// Assert
Assert.Equal(document.Nodes.Count, stats.NodeCount);
Assert.Equal(document.Edges.Count, stats.EdgeCount);
_output.WriteLine($"Query returned: {stats.NodeCount} nodes, {stats.EdgeCount} edges");
}
private static CallgraphDocument CreateSampleDocument()
{
return new CallgraphDocument
{
Id = Guid.NewGuid().ToString("N"),
Language = "csharp",
GraphHash = "sha256:sample-graph-hash",
Nodes = new List<CallgraphNode>
{
new() { Id = "node-1", Name = "Main", Kind = "method", Namespace = "Program", Visibility = SymbolVisibility.Public, IsEntrypointCandidate = true },
new() { Id = "node-2", Name = "DoWork", Kind = "method", Namespace = "Service", Visibility = SymbolVisibility.Internal },
new() { Id = "node-3", Name = "ProcessData", Kind = "method", Namespace = "Core", Visibility = SymbolVisibility.Private }
},
Edges = new List<CallgraphEdge>
{
new() { SourceId = "node-1", TargetId = "node-2", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 },
new() { SourceId = "node-2", TargetId = "node-3", Kind = EdgeKind.Static, Reason = EdgeReason.DirectCall, Weight = 1.0 }
},
Entrypoints = new List<CallgraphEntrypoint>
{
new() { NodeId = "node-1", Kind = EntrypointKind.Main, Phase = EntrypointPhase.AppStart, Order = 0 }
}
};
}
}

View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using MicrosoftOptions = Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence.Postgres;
using StellaOps.Signals.Persistence.Postgres.Repositories;
using StellaOps.Signals.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Signals.Persistence.Tests;
[Collection(SignalsPostgresCollection.Name)]
public sealed class CallGraphSyncServiceTests : IAsyncLifetime
{
private readonly SignalsPostgresFixture _fixture;
private readonly SignalsDataSource _dataSource;
private readonly CallGraphSyncService _syncService;
private readonly PostgresCallGraphQueryRepository _queryRepository;
public CallGraphSyncServiceTests(SignalsPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
_dataSource = new SignalsDataSource(MicrosoftOptions.Options.Create(options), NullLogger<SignalsDataSource>.Instance);
var projectionRepository = new PostgresCallGraphProjectionRepository(
_dataSource,
NullLogger<PostgresCallGraphProjectionRepository>.Instance);
_syncService = new CallGraphSyncService(
projectionRepository,
TimeProvider.System,
NullLogger<CallGraphSyncService>.Instance);
_queryRepository = new PostgresCallGraphQueryRepository(_dataSource, NullLogger<PostgresCallGraphQueryRepository>.Instance);
}
public async Task InitializeAsync()
{
await _fixture.ExecuteSqlAsync("TRUNCATE TABLE signals.scans CASCADE;");
}
public async Task DisposeAsync()
{
await _dataSource.DisposeAsync();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SyncAsync_ProjectsCallgraph_AndQueryRepositoryReturnsStats()
{
var scanId = Guid.NewGuid();
var document = new CallgraphDocument
{
Id = "callgraph-1",
Language = "nodejs",
Component = "pkg:npm/demo-app@1.0.0",
Version = "1.0.0",
Artifact = new CallgraphArtifactMetadata
{
Hash = "deadbeef",
Path = "cas/reachability/graphs/deadbeef/callgraph.json",
Length = 128,
ContentType = "application/json"
},
Nodes = new List<CallgraphNode>
{
new("n1", "Main", "function", "Demo", "index.js", 1)
{
SymbolKey = "Demo::Main()",
Visibility = SymbolVisibility.Public,
IsEntrypointCandidate = true,
},
new("n2", "Helper", "function", "Demo", "lib.js", 10)
{
SymbolKey = "Demo::Helper()",
Visibility = SymbolVisibility.Internal,
Purl = "pkg:npm/lodash@4.17.21",
IsEntrypointCandidate = false,
}
},
Edges = new List<CallgraphEdge>
{
new("n1", "n2", "call")
{
Kind = EdgeKind.Static,
Reason = EdgeReason.DirectCall,
Weight = 1.0,
IsResolved = true
}
},
Entrypoints = new List<CallgraphEntrypoint>
{
new()
{
NodeId = "n1",
Kind = EntrypointKind.Http,
Framework = EntrypointFramework.Express,
Route = "/",
HttpMethod = "GET",
Phase = EntrypointPhase.Runtime,
Order = 0
}
}
};
var result1 = await _syncService.SyncAsync(scanId, document.Artifact.Hash, document, CancellationToken.None);
result1.WasUpdated.Should().BeTrue();
result1.ScanId.Should().Be(scanId);
var stats = await _queryRepository.GetStatsAsync(scanId, CancellationToken.None);
stats.NodeCount.Should().Be(2);
stats.EdgeCount.Should().Be(1);
stats.EntrypointCount.Should().Be(1);
stats.UniquePurls.Should().Be(1);
stats.HeuristicEdgeCount.Should().Be(0);
stats.UnresolvedEdgeCount.Should().Be(0);
var reachable = await _queryRepository.GetReachableSymbolsAsync(scanId, "n1", cancellationToken: CancellationToken.None);
reachable.Should().Contain("n2");
var result2 = await _syncService.SyncAsync(scanId, document.Artifact.Hash, document, CancellationToken.None);
result2.ScanId.Should().Be(result1.ScanId);
var stats2 = await _queryRepository.GetStatsAsync(scanId, CancellationToken.None);
stats2.NodeCount.Should().Be(2);
stats2.EdgeCount.Should().Be(1);
}
}

View File

@@ -0,0 +1,156 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using MicrosoftOptions = Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Persistence.Postgres;
using StellaOps.Signals.Persistence.Postgres.Repositories;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Signals.Persistence.Tests;
[Collection(SignalsPostgresCollection.Name)]
public sealed class PostgresCallgraphRepositoryTests : IAsyncLifetime
{
private readonly SignalsPostgresFixture _fixture;
private readonly PostgresCallgraphRepository _repository;
public PostgresCallgraphRepositoryTests(SignalsPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new SignalsDataSource(MicrosoftOptions.Options.Create(options), NullLogger<SignalsDataSource>.Instance);
_repository = new PostgresCallgraphRepository(dataSource, NullLogger<PostgresCallgraphRepository>.Instance);
}
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertAndGetById_RoundTripsCallgraphDocument()
{
// Arrange
var id = "callgraph-" + Guid.NewGuid().ToString("N");
var document = new CallgraphDocument
{
Id = id,
Language = "javascript",
Component = "pkg:npm/lodash@4.17.21",
Version = "4.17.21",
IngestedAt = DateTimeOffset.UtcNow,
GraphHash = "sha256:abc123",
Nodes = new List<CallgraphNode>
{
new("fn1", "main", "function", "lodash", "index.js", 1),
new("fn2", "helper", "function", "lodash", "utils.js", 10)
},
Edges = new List<CallgraphEdge>
{
new("fn1", "fn2", "call")
},
Metadata = new Dictionary<string, string?> { ["version"] = "1.0" }
};
// Act
await _repository.UpsertAsync(document, CancellationToken.None);
var fetched = await _repository.GetByIdAsync(id, CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(id);
fetched.Language.Should().Be("javascript");
fetched.Component.Should().Be("pkg:npm/lodash@4.17.21");
fetched.Nodes.Should().HaveCount(2);
fetched.Edges.Should().HaveCount(1);
fetched.Metadata.Should().ContainKey("version");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertAsync_UpdatesExistingDocument()
{
// Arrange
var id = "callgraph-" + Guid.NewGuid().ToString("N");
var document1 = new CallgraphDocument
{
Id = id,
Language = "javascript",
Component = "pkg:npm/express@4.18.0",
Version = "4.18.0",
IngestedAt = DateTimeOffset.UtcNow,
GraphHash = "hash1",
Nodes = new List<CallgraphNode> { new("fn1", "old", "function", null, "a.js", 1) },
Edges = new List<CallgraphEdge>()
};
var document2 = new CallgraphDocument
{
Id = id,
Language = "typescript",
Component = "pkg:npm/express@4.19.0",
Version = "4.19.0",
IngestedAt = DateTimeOffset.UtcNow,
GraphHash = "hash2",
Nodes = new List<CallgraphNode>
{
new("fn1", "new1", "function", null, "b.js", 1),
new("fn2", "new2", "function", null, "b.js", 5)
},
Edges = new List<CallgraphEdge>()
};
// Act
await _repository.UpsertAsync(document1, CancellationToken.None);
await _repository.UpsertAsync(document2, CancellationToken.None);
var fetched = await _repository.GetByIdAsync(id, CancellationToken.None);
// Assert
fetched.Should().NotBeNull();
fetched!.Language.Should().Be("typescript");
fetched.Version.Should().Be("4.19.0");
fetched.Nodes.Should().HaveCount(2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetByIdAsync_ReturnsNullForNonExistentId()
{
// Act
var fetched = await _repository.GetByIdAsync("nonexistent-id", CancellationToken.None);
// Assert
fetched.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertAsync_GeneratesIdIfMissing()
{
// Arrange
var document = new CallgraphDocument
{
Id = string.Empty, // empty ID should be replaced
Language = "python",
Component = "pkg:pypi/requests@2.28.0",
Version = "2.28.0",
IngestedAt = DateTimeOffset.UtcNow,
GraphHash = "hash123",
Nodes = new List<CallgraphNode>(),
Edges = new List<CallgraphEdge>()
};
// Act
var result = await _repository.UpsertAsync(document, CancellationToken.None);
// Assert
result.Id.Should().NotBeNullOrWhiteSpace();
result.Id.Should().HaveLength(32); // GUID without hyphens
}
}

View File

@@ -0,0 +1,27 @@
using System.Reflection;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.Signals.Persistence.Postgres;
using Xunit;
namespace StellaOps.Signals.Persistence.Tests;
/// <summary>
/// PostgreSQL integration test fixture for the Signals module.
/// </summary>
public sealed class SignalsPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<SignalsPostgresFixture>
{
protected override Assembly? GetMigrationAssembly()
=> typeof(SignalsDataSource).Assembly;
protected override string GetModuleName() => "Signals";
}
/// <summary>
/// Collection definition for Signals PostgreSQL integration tests.
/// Tests in this collection share a single PostgreSQL container instance.
/// </summary>
[CollectionDefinition(Name)]
public sealed class SignalsPostgresCollection : ICollectionFixture<SignalsPostgresFixture>
{
public const string Name = "SignalsPostgres";
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Signals.Persistence\StellaOps.Signals.Persistence.csproj" />
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
@@ -136,7 +136,6 @@ public class CallgraphIngestionServiceTests
if (request.ManifestContent is not null)
{
using var manifestMs = new MemoryStream();
using StellaOps.TestKit;
request.ManifestContent.CopyTo(manifestMs);
manifests[request.Hash] = manifestMs.ToArray();
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using System.Text;
using System.Text.Json;
@@ -244,7 +244,6 @@ public class EdgeBundleIngestionServiceTests
using var stream1 = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundle1)));
using var stream2 = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundle2)));
using StellaOps.TestKit;
// Act
await _service.IngestAsync(TestTenantId, stream1, null);
await _service.IngestAsync(TestTenantId, stream2, null);

View File

@@ -321,7 +321,7 @@ public class InMemoryEvidenceWeightPolicyProviderTests
}
[Fact]
public void Clear_RemovesAllPolicies()
public async Task Clear_RemovesAllPolicies()
{
var provider = new InMemoryEvidenceWeightPolicyProvider();
provider.SetPolicy(new EvidenceWeightPolicy
@@ -339,7 +339,7 @@ public class InMemoryEvidenceWeightPolicyProviderTests
provider.Clear();
provider.PolicyExistsAsync(null, "production").Result.Should().BeFalse();
provider.PolicyExistsAsync(null, "development").Result.Should().BeFalse();
(await provider.PolicyExistsAsync(null, "production")).Should().BeFalse();
(await provider.PolicyExistsAsync(null, "development")).Should().BeFalse();
}
}

View File

@@ -31,7 +31,7 @@ public class EvidenceWeightedScoreCalculatorTests
var result = _calculator.Calculate(input, _defaultPolicy);
// Without MIT, sum of weights = 0.95 (default) → 95%
result.Score.Should().BeGreaterOrEqualTo(90);
result.Score.Should().BeGreaterThanOrEqualTo(90);
result.Bucket.Should().Be(ScoreBucket.ActNow);
}
@@ -217,7 +217,7 @@ public class EvidenceWeightedScoreCalculatorTests
var result = _calculator.Calculate(input, _defaultPolicy);
result.Score.Should().BeLessOrEqualTo(15);
result.Score.Should().BeLessThanOrEqualTo(15);
result.Caps.NotAffectedCap.Should().BeTrue();
result.Flags.Should().Contain("vendor-na");
}
@@ -229,7 +229,7 @@ public class EvidenceWeightedScoreCalculatorTests
var result = _calculator.Calculate(input, _defaultPolicy);
result.Score.Should().BeGreaterOrEqualTo(60);
result.Score.Should().BeGreaterThanOrEqualTo(60);
result.Caps.RuntimeFloor.Should().BeTrue();
}
@@ -242,7 +242,7 @@ public class EvidenceWeightedScoreCalculatorTests
var result = _calculator.Calculate(input, _defaultPolicy);
// Since RTS >= 0.8, runtime floor should apply (floor at 60)
result.Score.Should().BeGreaterOrEqualTo(60);
result.Score.Should().BeGreaterThanOrEqualTo(60);
result.Caps.RuntimeFloor.Should().BeTrue();
// Speculative cap shouldn't apply because RTS > 0
result.Caps.SpeculativeCap.Should().BeFalse();
@@ -311,7 +311,7 @@ public class EvidenceWeightedScoreCalculatorTests
var result = _calculator.Calculate(input, _defaultPolicy);
result.Should().NotBeNull();
result.Score.Should().BeGreaterOrEqualTo(0);
result.Score.Should().BeGreaterThanOrEqualTo(0);
}
[Fact]

View File

@@ -57,7 +57,7 @@ public class GroundTruthValidatorTests
/// </summary>
[Theory]
[MemberData(nameof(GetGroundTruthSamples))]
public void GroundTruth_HasValidUncertaintyTiers(string samplePath, GroundTruthDocument document)
public void GroundTruth_HasValidUncertaintyTiers(string _samplePath, GroundTruthDocument document)
{
if (document.ExpectedUncertainty is null)
{
@@ -139,7 +139,7 @@ public class GroundTruthValidatorTests
/// </summary>
[Theory]
[MemberData(nameof(GetGroundTruthSamples))]
public void GroundTruth_EntryPointsHaveValidPhases(string samplePath, GroundTruthDocument document)
public void GroundTruth_EntryPointsHaveValidPhases(string _samplePath, GroundTruthDocument document)
{
var validPhases = new[] { "load", "init", "runtime", "main", "fini" };

View File

@@ -140,7 +140,7 @@ public class ReachabilityLatticeStateExtensionsTests
[InlineData(null, ReachabilityLatticeState.Unknown)]
public void FromCode_ReturnsExpectedState(string? code, ReachabilityLatticeState expected)
{
Assert.Equal(expected, ReachabilityLatticeStateExtensions.FromCode(code));
Assert.Equal(expected, ReachabilityLatticeStateExtensions.FromCode(code!));
}
[Trait("Category", TestCategories.Unit)]

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using System.IO.Compression;
using System.Text.Json;
@@ -88,7 +88,6 @@ public class ReachabilityUnionIngestionServiceTests
private static string ComputeSha(string content)
{
using var sha = System.Security.Cryptography.SHA256.Create();
using StellaOps.TestKit;
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
return Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}

View File

@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Linq;
@@ -48,7 +48,6 @@ public class RouterEventsPublisherTests
var options = CreateOptions();
var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom");
using var httpClient = new HttpClient(handler) { BaseAddress = new Uri(options.Events.Router.BaseUrl) };
using StellaOps.TestKit;
var logger = new ListLogger<RouterEventsPublisher>();
var builder = new ReachabilityFactEventBuilder(options, TimeProvider.System);
var publisher = new RouterEventsPublisher(builder, options, httpClient, logger);

View File

@@ -1,4 +1,4 @@
using System.IO.Compression;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
@@ -252,7 +252,6 @@ public class RuntimeFactsBatchIngestionTests
public async Task<StoredRuntimeFactsArtifact> SaveAsync(RuntimeFactsArtifactSaveRequest request, Stream content, CancellationToken cancellationToken)
{
using var ms = new MemoryStream();
using StellaOps.TestKit;
await content.CopyToAsync(ms, cancellationToken);
var artifact = new StoredRuntimeFactsArtifact(

View File

@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -48,7 +48,6 @@ public sealed class SimpleJsonCallgraphParserGateTests
var parser = new SimpleJsonCallgraphParser("csharp");
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json), writable: false);
using StellaOps.TestKit;
var parsed = await parser.ParseAsync(stream, CancellationToken.None);
parsed.Edges.Should().ContainSingle();

View File

@@ -6,24 +6,27 @@
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Disable Concelier shared test infra to avoid pulling unrelated projects into the Signals test graph -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" />
<!-- FsCheck for property-based testing (EvidenceWeightedScore) -->
<PackageReference Include="FsCheck" Version="3.0.0-rc3" />
<PackageReference Include="FsCheck.Xunit" Version="3.0.0-rc3" />
<PackageReference Include="FsCheck" />
<PackageReference Include="FsCheck.Xunit" />
<!-- Verify for snapshot testing (EvidenceWeightedScore) -->
<PackageReference Include="Verify.Xunit" Version="28.7.2" />
<PackageReference Include="Verify.Xunit" />
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Signals/StellaOps.Signals.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -310,7 +310,6 @@ public class UnknownsDecayServiceTests
}
using var cts = new CancellationTokenSource();
using StellaOps.TestKit;
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() =>