Files
git.stella-ops.org/src/Signals/__Tests/StellaOps.Signals.Tests/CallgraphIngestionServiceTests.cs
master 00d2c99af9 feat: add Attestation Chain and Triage Evidence API clients and models
- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains.
- Created models for Attestation Chain, including DSSE envelope structures and verification results.
- Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component.
- Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence.
- Introduced mock implementations for both API clients to facilitate testing and development.
2025-12-18 13:15:13 +02:00

224 lines
8.9 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
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 StellaOps.Signals.Storage;
using StellaOps.Signals.Storage.Models;
using Xunit;
namespace StellaOps.Signals.Tests;
public class CallgraphIngestionServiceTests
{
private readonly InMemoryCallgraphRepository _repository = new();
private readonly InMemoryArtifactStore _artifactStore = new();
private readonly CallgraphNormalizationService _normalizer = new();
private readonly TimeProvider _timeProvider = TimeProvider.System;
[Fact]
public async Task IngestAsync_normalizes_graph_and_persists_manifest_hash()
{
var parser = new StubParser("java");
var resolver = new StubParserResolver(parser);
var options = Microsoft.Extensions.Options.Options.Create(new SignalsOptions());
var reachabilityStore = new InMemoryReachabilityStoreRepository(_timeProvider);
var callGraphSyncService = new StubCallGraphSyncService();
var service = new CallgraphIngestionService(
resolver,
_artifactStore,
_repository,
reachabilityStore,
_normalizer,
callGraphSyncService,
options,
_timeProvider,
NullLogger<CallgraphIngestionService>.Instance);
var artifactJson = @"{""nodes"":[{""id"":""com/example/Foo.bar:(I)V"",""kind"":""fn""}],
""edges"":[{""source"":""com/example/Foo.bar:(I)V"",""target"":""com/example/Foo.bar:(I)V""}]}";
var request = new CallgraphIngestRequest(
Language: "java",
Component: "demo",
Version: "1.0.0",
ArtifactFileName: "graph.json",
ArtifactContentType: "application/json",
ArtifactContentBase64: Convert.ToBase64String(Encoding.UTF8.GetBytes(artifactJson)),
SchemaVersion: null,
Metadata: new Dictionary<string, string?> { ["source"] = "test" },
Analyzer: new Dictionary<string, string?> { ["name"] = "stub" });
var response = await service.IngestAsync(request, CancellationToken.None);
response.CallgraphId.Should().NotBeNullOrWhiteSpace();
response.GraphHash.Should().NotBeNullOrWhiteSpace();
response.NodeCount.Should().Be(1);
response.EdgeCount.Should().Be(1);
response.ManifestCasUri.Should().Be("cas://signals/manifests/graph.json");
var stored = _repository.LastUpserted!;
stored.Artifact.Hash.Should().Be(response.ArtifactHash);
stored.Nodes[0].Namespace.Should().Be("com.example.Foo");
stored.Nodes[0].Language.Should().Be("java");
stored.Metadata!["schemaVersion"].Should().Be("1.0");
stored.Metadata!["analyzer.name"].Should().Be("stub");
stored.Artifact.GraphHash.Should().Be(response.GraphHash);
var storedNodes = await reachabilityStore.GetFuncNodesByGraphAsync(response.GraphHash, CancellationToken.None);
storedNodes.Should().HaveCount(1);
storedNodes[0].SymbolId.Should().Be("com/example/Foo.bar:(I)V");
var storedEdges = await reachabilityStore.GetCallEdgesByGraphAsync(response.GraphHash, CancellationToken.None);
storedEdges.Should().HaveCount(1);
storedEdges[0].SourceId.Should().Be("com/example/Foo.bar:(I)V");
storedEdges[0].TargetId.Should().Be("com/example/Foo.bar:(I)V");
}
private sealed class StubParser : ICallgraphParser
{
public StubParser(string language) => Language = language;
public string Language { get; }
public Task<CallgraphParseResult> ParseAsync(Stream artifactStream, CancellationToken cancellationToken)
{
artifactStream.Position = 0;
using var doc = JsonDocument.Parse(artifactStream);
var nodes = new List<CallgraphNode>();
foreach (var node in doc.RootElement.GetProperty("nodes").EnumerateArray())
{
nodes.Add(new CallgraphNode(node.GetProperty("id").GetString()!, "", "function", null, null, null));
}
var edges = new List<CallgraphEdge>();
foreach (var edge in doc.RootElement.GetProperty("edges").EnumerateArray())
{
edges.Add(new CallgraphEdge(
edge.GetProperty("source").GetString()!,
edge.GetProperty("target").GetString()!,
"call"));
}
return Task.FromResult(new CallgraphParseResult(nodes, edges, Array.Empty<CallgraphRoot>(), "1.0", "1.0", null));
}
}
private sealed class StubParserResolver : ICallgraphParserResolver
{
private readonly ICallgraphParser _parser;
public StubParserResolver(ICallgraphParser parser) => _parser = parser;
public ICallgraphParser Resolve(string language) => _parser;
}
private sealed class InMemoryArtifactStore : ICallgraphArtifactStore
{
private readonly Dictionary<string, byte[]> artifacts = new(StringComparer.Ordinal);
private readonly Dictionary<string, byte[]> manifests = new(StringComparer.Ordinal);
public Task<StoredCallgraphArtifact> SaveAsync(CallgraphArtifactSaveRequest request, Stream content, CancellationToken cancellationToken)
{
using var ms = new MemoryStream();
content.CopyTo(ms);
artifacts[request.Hash] = ms.ToArray();
if (request.ManifestContent is not null)
{
using var manifestMs = new MemoryStream();
request.ManifestContent.CopyTo(manifestMs);
manifests[request.Hash] = manifestMs.ToArray();
}
var path = $"cas://signals/artifacts/{request.FileName}";
var manifestPath = "cas://signals/manifests/graph.json";
return Task.FromResult(new StoredCallgraphArtifact(
Path: path,
Length: ms.Length,
Hash: request.Hash,
ContentType: request.ContentType,
CasUri: path,
ManifestPath: manifestPath,
ManifestCasUri: manifestPath));
}
public Task<Stream?> GetAsync(string hash, string? fileName = null, CancellationToken cancellationToken = default)
{
if (artifacts.TryGetValue(hash, out var bytes))
{
return Task.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
}
return Task.FromResult<Stream?>(null);
}
public Task<Stream?> GetManifestAsync(string hash, CancellationToken cancellationToken = default)
{
if (manifests.TryGetValue(hash, out var bytes))
{
return Task.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
}
return Task.FromResult<Stream?>(null);
}
public Task<bool> ExistsAsync(string hash, CancellationToken cancellationToken = default)
{
return Task.FromResult(artifacts.ContainsKey(hash));
}
}
private sealed class InMemoryCallgraphRepository : ICallgraphRepository
{
public CallgraphDocument? LastUpserted { get; private set; }
public Task<CallgraphDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
return Task.FromResult(LastUpserted?.Id == id ? LastUpserted : null);
}
public Task<CallgraphDocument> UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken)
{
LastUpserted = document;
return Task.FromResult(document);
}
}
private sealed class StubCallGraphSyncService : ICallGraphSyncService
{
public CallGraphSyncResult? LastSyncResult { get; private set; }
public CallgraphDocument? LastSyncedDocument { get; private set; }
public Task<CallGraphSyncResult> SyncAsync(
Guid scanId,
string artifactDigest,
CallgraphDocument document,
CancellationToken cancellationToken = default)
{
LastSyncedDocument = document;
var result = new CallGraphSyncResult(
ScanId: scanId,
NodesProjected: document.Nodes.Count,
EdgesProjected: document.Edges.Count,
EntrypointsProjected: document.Entrypoints.Count,
WasUpdated: true,
DurationMs: 1);
LastSyncResult = result;
return Task.FromResult(result);
}
public Task DeleteByScanAsync(Guid scanId, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
}