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.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 { ["source"] = "test" }, Analyzer: new Dictionary { ["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 ParseAsync(Stream artifactStream, CancellationToken cancellationToken) { artifactStream.Position = 0; using var doc = JsonDocument.Parse(artifactStream); var nodes = new List(); 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(); 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(), "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 artifacts = new(StringComparer.Ordinal); private readonly Dictionary manifests = new(StringComparer.Ordinal); public Task 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 GetAsync(string hash, string? fileName = null, CancellationToken cancellationToken = default) { if (artifacts.TryGetValue(hash, out var bytes)) { return Task.FromResult(new MemoryStream(bytes, writable: false)); } return Task.FromResult(null); } public Task GetManifestAsync(string hash, CancellationToken cancellationToken = default) { if (manifests.TryGetValue(hash, out var bytes)) { return Task.FromResult(new MemoryStream(bytes, writable: false)); } return Task.FromResult(null); } public Task 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 GetByIdAsync(string id, CancellationToken cancellationToken) { return Task.FromResult(LastUpserted?.Id == id ? LastUpserted : null); } public Task 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 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; } } }