using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Signals.Models; using StellaOps.Signals.Persistence; using StellaOps.Signals.Services; using Xunit; public class RuntimeFactsIngestionServiceTests { [Fact] public async Task IngestAsync_AggregatesHits_AndRecomputesReachability() { var factRepository = new InMemoryReachabilityFactRepository(); var scoringService = new RecordingScoringService(); var cache = new InMemoryReachabilityCache(); var eventsPublisher = new RecordingEventsPublisher(); var provenanceNormalizer = new RuntimeFactsProvenanceNormalizer(); var service = new RuntimeFactsIngestionService( factRepository, TimeProvider.System, cache, eventsPublisher, scoringService, provenanceNormalizer, NullLogger.Instance); var request = new RuntimeFactsIngestRequest { Subject = new ReachabilitySubject { Component = "web", Version = "2.1.0" }, CallgraphId = "cg-123", Metadata = new Dictionary { { "source", "runtime" } }, Events = new List { new() { SymbolId = "svc.foo", HitCount = 2, Metadata = new Dictionary { { "pid", "12" } } }, new() { SymbolId = "svc.bar", HitCount = 1 }, new() { SymbolId = "svc.foo", HitCount = 3 } } }; var response = await service.IngestAsync(request, CancellationToken.None); Assert.Equal("web|2.1.0", response.SubjectKey); Assert.Equal("cg-123", response.CallgraphId); var persisted = factRepository.Last ?? throw new Xunit.Sdk.XunitException("Fact not persisted"); Assert.Equal(2, persisted.RuntimeFacts?.Count); var foo = persisted.RuntimeFacts?.Single(f => f.SymbolId == "svc.foo"); Assert.Equal(5, foo?.HitCount); var bar = persisted.RuntimeFacts?.Single(f => f.SymbolId == "svc.bar"); Assert.Equal(1, bar?.HitCount); var recorded = scoringService.LastRequest ?? throw new Xunit.Sdk.XunitException("Recompute not triggered"); Assert.Equal("cg-123", recorded.CallgraphId); Assert.Contains("svc.foo", recorded.Targets); Assert.Contains("svc.bar", recorded.RuntimeHits!); Assert.Equal("runtime", recorded.Metadata?["source"]); Assert.Equal("runtime", persisted.Metadata?["provenance.source"]); Assert.Equal("cg-123", persisted.Metadata?["provenance.callgraphId"]); Assert.NotNull(persisted.Metadata?["provenance.ingestedAt"]); // Verify context_facts with AOC provenance (SIGNALS-24-003) Assert.NotNull(persisted.ContextFacts); Assert.NotNull(persisted.ContextFacts.Provenance); Assert.Equal(1, persisted.ContextFacts.Provenance.SchemaVersion); Assert.Equal(ProvenanceFeedType.RuntimeFacts, persisted.ContextFacts.Provenance.FeedType); Assert.Equal(3, persisted.ContextFacts.RecordCount); // Three events (provenance tracks each observation) Assert.NotEmpty(persisted.ContextFacts.Provenance.Records); Assert.All(persisted.ContextFacts.Provenance.Records, record => { Assert.NotEmpty(record.RecordId); Assert.NotEmpty(record.RecordType); Assert.NotNull(record.Subject); Assert.NotNull(record.Facts); }); } private sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepository { public ReachabilityFactDocument? Last { get; private set; } public Task GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken) { return Task.FromResult(Last); } public Task UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken) { Last = document; return Task.FromResult(document); } } private sealed class InMemoryReachabilityCache : IReachabilityCache { private readonly Dictionary cache = new(StringComparer.Ordinal); public Task GetAsync(string subjectKey, CancellationToken cancellationToken) { cache.TryGetValue(subjectKey, out var doc); return Task.FromResult(doc); } public Task SetAsync(ReachabilityFactDocument document, CancellationToken cancellationToken) { cache[document.SubjectKey] = document; return Task.CompletedTask; } public Task InvalidateAsync(string subjectKey, CancellationToken cancellationToken) { cache.Remove(subjectKey); return Task.CompletedTask; } } private sealed class RecordingEventsPublisher : IEventsPublisher { public ReachabilityFactDocument? Last { get; private set; } public Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken) { Last = fact; return Task.CompletedTask; } } private sealed class RecordingScoringService : IReachabilityScoringService { public ReachabilityRecomputeRequest? LastRequest { get; private set; } public Task RecomputeAsync(ReachabilityRecomputeRequest request, CancellationToken cancellationToken) { LastRequest = request; return Task.FromResult(new ReachabilityFactDocument { CallgraphId = request.CallgraphId, Subject = request.Subject, SubjectKey = request.Subject?.ToSubjectKey() ?? string.Empty, EntryPoints = request.EntryPoints, States = new List(), RuntimeFacts = new List() }); } } #region Tenant Isolation Tests [Fact] public async Task IngestAsync_IsolatesFactsBySubjectKey_NoDataLeakBetweenTenants() { // Arrange: Two tenants with different subjects var factRepository = new TenantAwareFactRepository(); var service = CreateService(factRepository); var tenant1Request = new RuntimeFactsIngestRequest { Subject = new ReachabilitySubject { ScanId = "scan-tenant1" }, CallgraphId = "cg-tenant1", Events = new List { new() { SymbolId = "tenant1.secret.func", HitCount = 1 } } }; var tenant2Request = new RuntimeFactsIngestRequest { Subject = new ReachabilitySubject { ScanId = "scan-tenant2" }, CallgraphId = "cg-tenant2", Events = new List { new() { SymbolId = "tenant2.public.func", HitCount = 1 } } }; // Act await service.IngestAsync(tenant1Request, CancellationToken.None); await service.IngestAsync(tenant2Request, CancellationToken.None); // Assert: Each tenant only sees their own data var tenant1Facts = await factRepository.GetBySubjectAsync("scan-tenant1", CancellationToken.None); var tenant2Facts = await factRepository.GetBySubjectAsync("scan-tenant2", CancellationToken.None); tenant1Facts.Should().NotBeNull(); tenant1Facts!.RuntimeFacts.Should().ContainSingle(f => f.SymbolId == "tenant1.secret.func"); tenant1Facts.RuntimeFacts.Should().NotContain(f => f.SymbolId == "tenant2.public.func"); tenant2Facts.Should().NotBeNull(); tenant2Facts!.RuntimeFacts.Should().ContainSingle(f => f.SymbolId == "tenant2.public.func"); tenant2Facts.RuntimeFacts.Should().NotContain(f => f.SymbolId == "tenant1.secret.func"); } [Fact] public async Task IngestAsync_SubjectKeyIsDeterministic_ForSameInput() { // Arrange var factRepository = new TenantAwareFactRepository(); var service = CreateService(factRepository); var subject = new ReachabilitySubject { Component = "mylib", Version = "1.0.0" }; var request1 = new RuntimeFactsIngestRequest { Subject = subject, CallgraphId = "cg-1", Events = new List { new() { SymbolId = "sym1", HitCount = 1 } } }; var request2 = new RuntimeFactsIngestRequest { Subject = new ReachabilitySubject { Component = "mylib", Version = "1.0.0" }, CallgraphId = "cg-2", Events = new List { new() { SymbolId = "sym2", HitCount = 1 } } }; // Act var response1 = await service.IngestAsync(request1, CancellationToken.None); var response2 = await service.IngestAsync(request2, CancellationToken.None); // Assert: Same subject produces same key (deterministic) response1.SubjectKey.Should().Be(response2.SubjectKey); response1.SubjectKey.Should().Be("mylib|1.0.0"); } [Fact] public async Task IngestAsync_BuildIdCorrelation_PreservesPerFactBuildId() { // Arrange var factRepository = new TenantAwareFactRepository(); var service = CreateService(factRepository); var request = new RuntimeFactsIngestRequest { Subject = new ReachabilitySubject { ImageDigest = "sha256:abc123" }, CallgraphId = "cg-buildid-test", Events = new List { new() { SymbolId = "libssl.SSL_read", BuildId = "gnu-build-id:5f0c7c3cab2eb9bc", HitCount = 10 }, new() { SymbolId = "libcrypto.EVP_encrypt", BuildId = "gnu-build-id:a1b2c3d4e5f6", HitCount = 5 } } }; // Act var response = await service.IngestAsync(request, CancellationToken.None); // Assert: Build-IDs are preserved per runtime fact var persisted = await factRepository.GetBySubjectAsync(response.SubjectKey, CancellationToken.None); persisted.Should().NotBeNull(); persisted!.RuntimeFacts.Should().HaveCount(2); var sslFact = persisted.RuntimeFacts.Single(f => f.SymbolId == "libssl.SSL_read"); sslFact.BuildId.Should().Be("gnu-build-id:5f0c7c3cab2eb9bc"); var cryptoFact = persisted.RuntimeFacts.Single(f => f.SymbolId == "libcrypto.EVP_encrypt"); cryptoFact.BuildId.Should().Be("gnu-build-id:a1b2c3d4e5f6"); } [Fact] public async Task IngestAsync_CodeIdCorrelation_PreservesPerFactCodeId() { // Arrange var factRepository = new TenantAwareFactRepository(); var service = CreateService(factRepository); var request = new RuntimeFactsIngestRequest { Subject = new ReachabilitySubject { Component = "native-lib", Version = "2.0.0" }, CallgraphId = "cg-codeid-test", Events = new List { new() { SymbolId = "stripped_func_0x1234", CodeId = "code:binary:abc123xyz", HitCount = 3 } } }; // Act var response = await service.IngestAsync(request, CancellationToken.None); // Assert: Code-ID is preserved for stripped binaries var persisted = await factRepository.GetBySubjectAsync(response.SubjectKey, CancellationToken.None); persisted.Should().NotBeNull(); persisted!.RuntimeFacts.Should().ContainSingle(); persisted.RuntimeFacts[0].CodeId.Should().Be("code:binary:abc123xyz"); } [Fact] public async Task IngestAsync_RejectsRequest_WhenSubjectMissing() { // Arrange var service = CreateService(new TenantAwareFactRepository()); var request = new RuntimeFactsIngestRequest { Subject = null!, CallgraphId = "cg-1", Events = new List { new() { SymbolId = "sym", HitCount = 1 } } }; // Act & Assert await Assert.ThrowsAsync( () => service.IngestAsync(request, CancellationToken.None)); } [Fact] public async Task IngestAsync_RejectsRequest_WhenCallgraphIdMissing() { // Arrange var service = CreateService(new TenantAwareFactRepository()); var request = new RuntimeFactsIngestRequest { Subject = new ReachabilitySubject { ScanId = "scan-1" }, CallgraphId = null!, Events = new List { new() { SymbolId = "sym", HitCount = 1 } } }; // Act & Assert await Assert.ThrowsAsync( () => service.IngestAsync(request, CancellationToken.None)); } [Fact] public async Task IngestAsync_RejectsRequest_WhenEventsEmpty() { // Arrange var service = CreateService(new TenantAwareFactRepository()); var request = new RuntimeFactsIngestRequest { Subject = new ReachabilitySubject { ScanId = "scan-1" }, CallgraphId = "cg-1", Events = new List() }; // Act & Assert await Assert.ThrowsAsync( () => service.IngestAsync(request, CancellationToken.None)); } [Fact] public async Task IngestAsync_RejectsRequest_WhenEventMissingSymbolId() { // Arrange var service = CreateService(new TenantAwareFactRepository()); var request = new RuntimeFactsIngestRequest { Subject = new ReachabilitySubject { ScanId = "scan-1" }, CallgraphId = "cg-1", Events = new List { new() { SymbolId = null!, HitCount = 1 } } }; // Act & Assert await Assert.ThrowsAsync( () => service.IngestAsync(request, CancellationToken.None)); } #endregion #region Evidence URI Tests [Fact] public async Task IngestAsync_PreservesEvidenceUri_FromRuntimeEvent() { // Arrange var factRepository = new TenantAwareFactRepository(); var service = CreateService(factRepository); var request = new RuntimeFactsIngestRequest { Subject = new ReachabilitySubject { ScanId = "scan-evidence" }, CallgraphId = "cg-evidence", Events = new List { new() { SymbolId = "vulnerable.func", HitCount = 1, EvidenceUri = "cas://signals/evidence/sha256:deadbeef" } } }; // Act var response = await service.IngestAsync(request, CancellationToken.None); // Assert var persisted = await factRepository.GetBySubjectAsync(response.SubjectKey, CancellationToken.None); persisted.Should().NotBeNull(); persisted!.RuntimeFacts.Should().ContainSingle(); persisted.RuntimeFacts[0].EvidenceUri.Should().Be("cas://signals/evidence/sha256:deadbeef"); } #endregion #region Helper Methods private static RuntimeFactsIngestionService CreateService(IReachabilityFactRepository factRepository) { return new RuntimeFactsIngestionService( factRepository, TimeProvider.System, new InMemoryReachabilityCache(), new RecordingEventsPublisher(), new RecordingScoringService(), new RuntimeFactsProvenanceNormalizer(), NullLogger.Instance); } #endregion #region Test Doubles private sealed class TenantAwareFactRepository : IReachabilityFactRepository { private readonly Dictionary _store = new(StringComparer.Ordinal); public Task GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken) { return Task.FromResult(_store.TryGetValue(subjectKey, out var doc) ? doc : null); } public Task UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken) { _store[document.SubjectKey] = document; return Task.FromResult(document); } public Task> GetExpiredAsync(DateTimeOffset cutoff, int limit, CancellationToken cancellationToken) { var expired = _store.Values .Where(d => d.ComputedAt < cutoff) .OrderBy(d => d.ComputedAt) .Take(limit) .ToList(); return Task.FromResult>(expired); } public Task DeleteAsync(string subjectKey, CancellationToken cancellationToken) { return Task.FromResult(_store.Remove(subjectKey)); } public Task GetRuntimeFactsCountAsync(string subjectKey, CancellationToken cancellationToken) { if (_store.TryGetValue(subjectKey, out var doc)) { return Task.FromResult(doc.RuntimeFacts?.Count ?? 0); } return Task.FromResult(0); } public Task TrimRuntimeFactsAsync(string subjectKey, int maxCount, CancellationToken cancellationToken) { if (_store.TryGetValue(subjectKey, out var doc) && doc.RuntimeFacts is { Count: > 0 }) { if (doc.RuntimeFacts.Count > maxCount) { doc.RuntimeFacts = doc.RuntimeFacts .OrderByDescending(f => f.ObservedAt ?? DateTimeOffset.MinValue) .Take(maxCount) .ToList(); } } return Task.CompletedTask; } } #endregion }