up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests;
|
||||
|
||||
public class EdgeBundleIngestionServiceTests
|
||||
{
|
||||
private readonly EdgeBundleIngestionService _service;
|
||||
private const string TestTenantId = "test-tenant";
|
||||
private const string TestGraphHash = "blake3:abc123def456";
|
||||
|
||||
public EdgeBundleIngestionServiceTests()
|
||||
{
|
||||
var opts = new SignalsOptions();
|
||||
opts.Storage.RootPath = Path.GetTempPath();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(opts);
|
||||
_service = new EdgeBundleIngestionService(NullLogger<EdgeBundleIngestionService>.Instance, options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_ParsesBundleAndStoresDocument()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new
|
||||
{
|
||||
schema = "edge-bundle-v1",
|
||||
bundleId = "bundle:test123",
|
||||
graphHash = TestGraphHash,
|
||||
bundleReason = "RuntimeHits",
|
||||
generatedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
edges = new[]
|
||||
{
|
||||
new { from = "func_a", to = "func_b", kind = "call", reason = "RuntimeHit", revoked = false, confidence = 0.9 },
|
||||
new { from = "func_b", to = "func_c", kind = "call", reason = "RuntimeHit", revoked = false, confidence = 0.8 }
|
||||
}
|
||||
};
|
||||
var bundleJson = JsonSerializer.Serialize(bundle);
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(bundleJson));
|
||||
|
||||
// Act
|
||||
var result = await _service.IngestAsync(TestTenantId, stream, null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("bundle:test123", result.BundleId);
|
||||
Assert.Equal(TestGraphHash, result.GraphHash);
|
||||
Assert.Equal("RuntimeHits", result.BundleReason);
|
||||
Assert.Equal(2, result.EdgeCount);
|
||||
Assert.Equal(0, result.RevokedCount);
|
||||
Assert.False(result.Quarantined);
|
||||
Assert.Contains("cas://reachability/edges/", result.CasUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_TracksRevokedEdgesForQuarantine()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new
|
||||
{
|
||||
schema = "edge-bundle-v1",
|
||||
bundleId = "bundle:revoked123",
|
||||
graphHash = TestGraphHash,
|
||||
bundleReason = "Revoked",
|
||||
edges = new[]
|
||||
{
|
||||
new { from = "func_a", to = "func_b", kind = "call", reason = "Revoked", revoked = true, confidence = 1.0 },
|
||||
new { from = "func_b", to = "func_c", kind = "call", reason = "TargetRemoved", revoked = true, confidence = 1.0 },
|
||||
new { from = "func_c", to = "func_d", kind = "call", reason = "LowConfidence", revoked = false, confidence = 0.3 }
|
||||
}
|
||||
};
|
||||
var bundleJson = JsonSerializer.Serialize(bundle);
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(bundleJson));
|
||||
|
||||
// Act
|
||||
var result = await _service.IngestAsync(TestTenantId, stream, null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.EdgeCount);
|
||||
Assert.Equal(2, result.RevokedCount);
|
||||
Assert.True(result.Quarantined);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsEdgeRevokedAsync_ReturnsTrueForRevokedEdges()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new
|
||||
{
|
||||
bundleId = "bundle:revoke-check",
|
||||
graphHash = TestGraphHash,
|
||||
bundleReason = "Revoked",
|
||||
edges = new[]
|
||||
{
|
||||
new { from = "vuln_func", to = "patched_func", kind = "call", reason = "Revoked", revoked = true, confidence = 1.0 }
|
||||
}
|
||||
};
|
||||
var bundleJson = JsonSerializer.Serialize(bundle);
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(bundleJson));
|
||||
await _service.IngestAsync(TestTenantId, stream, null);
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(await _service.IsEdgeRevokedAsync(TestTenantId, TestGraphHash, "vuln_func", "patched_func"));
|
||||
Assert.False(await _service.IsEdgeRevokedAsync(TestTenantId, TestGraphHash, "other_func", "some_func"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBundlesForGraphAsync_ReturnsAllBundlesForGraph()
|
||||
{
|
||||
// Arrange - ingest multiple bundles
|
||||
var graphHash = $"blake3:graph_{Guid.NewGuid():N}";
|
||||
|
||||
var bundle1 = new { bundleId = "bundle:1", graphHash, bundleReason = "RuntimeHits", edges = new[] { new { from = "a", to = "b", kind = "call", reason = "RuntimeHit", revoked = false, confidence = 1.0 } } };
|
||||
var bundle2 = new { bundleId = "bundle:2", graphHash, bundleReason = "InitArray", edges = new[] { new { from = "c", to = "d", kind = "call", reason = "InitArray", revoked = false, confidence = 1.0 } } };
|
||||
|
||||
using var stream1 = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundle1)));
|
||||
using var stream2 = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundle2)));
|
||||
|
||||
await _service.IngestAsync(TestTenantId, stream1, null);
|
||||
await _service.IngestAsync(TestTenantId, stream2, null);
|
||||
|
||||
// Act
|
||||
var bundles = await _service.GetBundlesForGraphAsync(TestTenantId, graphHash);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, bundles.Length);
|
||||
Assert.Contains(bundles, b => b.BundleId == "bundle:1");
|
||||
Assert.Contains(bundles, b => b.BundleId == "bundle:2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRevokedEdgesAsync_ReturnsOnlyRevokedEdges()
|
||||
{
|
||||
// Arrange
|
||||
var graphHash = $"blake3:revoked_graph_{Guid.NewGuid():N}";
|
||||
var bundle = new
|
||||
{
|
||||
bundleId = "bundle:mixed",
|
||||
graphHash,
|
||||
bundleReason = "Revoked",
|
||||
edges = new[]
|
||||
{
|
||||
new { from = "a", to = "b", kind = "call", reason = "Revoked", revoked = true, confidence = 1.0 },
|
||||
new { from = "c", to = "d", kind = "call", reason = "RuntimeHit", revoked = false, confidence = 0.9 },
|
||||
new { from = "e", to = "f", kind = "call", reason = "Revoked", revoked = true, confidence = 1.0 }
|
||||
}
|
||||
};
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundle)));
|
||||
await _service.IngestAsync(TestTenantId, stream, null);
|
||||
|
||||
// Act
|
||||
var revokedEdges = await _service.GetRevokedEdgesAsync(TestTenantId, graphHash);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, revokedEdges.Length);
|
||||
Assert.All(revokedEdges, e => Assert.True(e.Revoked));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_WithDsseStream_SetsVerifiedAndDsseFields()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new
|
||||
{
|
||||
bundleId = "bundle:verified",
|
||||
graphHash = TestGraphHash,
|
||||
bundleReason = "RuntimeHits",
|
||||
edges = new[] { new { from = "a", to = "b", kind = "call", reason = "RuntimeHit", revoked = false, confidence = 1.0 } }
|
||||
};
|
||||
var dsseEnvelope = new
|
||||
{
|
||||
payloadType = "application/vnd.stellaops.edgebundle.predicate+json",
|
||||
payload = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
|
||||
signatures = new[] { new { keyid = "test", sig = "abc123" } }
|
||||
};
|
||||
|
||||
using var bundleStream = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundle)));
|
||||
using var dsseStream = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(dsseEnvelope)));
|
||||
|
||||
// Act
|
||||
var result = await _service.IngestAsync(TestTenantId, bundleStream, dsseStream);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.DsseCasUri);
|
||||
Assert.EndsWith(".dsse", result.DsseCasUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_ThrowsOnMissingGraphHash()
|
||||
{
|
||||
// Arrange
|
||||
var bundle = new
|
||||
{
|
||||
bundleId = "bundle:no-graph",
|
||||
bundleReason = "RuntimeHits",
|
||||
edges = Array.Empty<object>()
|
||||
};
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundle)));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _service.IngestAsync(TestTenantId, stream, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAsync_UpdatesExistingBundleWithSameId()
|
||||
{
|
||||
// Arrange
|
||||
var graphHash = $"blake3:update_test_{Guid.NewGuid():N}";
|
||||
var bundle1 = new
|
||||
{
|
||||
bundleId = "bundle:same-id",
|
||||
graphHash,
|
||||
bundleReason = "RuntimeHits",
|
||||
edges = new[] { new { from = "a", to = "b", kind = "call", reason = "RuntimeHit", revoked = false, confidence = 0.5 } }
|
||||
};
|
||||
var bundle2 = new
|
||||
{
|
||||
bundleId = "bundle:same-id",
|
||||
graphHash,
|
||||
bundleReason = "RuntimeHits",
|
||||
edges = new[]
|
||||
{
|
||||
new { from = "a", to = "b", kind = "call", reason = "RuntimeHit", revoked = false, confidence = 0.9 },
|
||||
new { from = "c", to = "d", kind = "call", reason = "RuntimeHit", revoked = false, confidence = 0.8 }
|
||||
}
|
||||
};
|
||||
|
||||
using var stream1 = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundle1)));
|
||||
using var stream2 = new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bundle2)));
|
||||
|
||||
// Act
|
||||
await _service.IngestAsync(TestTenantId, stream1, null);
|
||||
await _service.IngestAsync(TestTenantId, stream2, null);
|
||||
|
||||
// Assert
|
||||
var bundles = await _service.GetBundlesForGraphAsync(TestTenantId, graphHash);
|
||||
Assert.Single(bundles);
|
||||
Assert.Equal(2, bundles[0].Edges.Count); // Updated to have 2 edges
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,18 @@ public class ReachabilityScoringServiceTests
|
||||
Last = 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 id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<int> GetRuntimeFactsCountAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task TrimRuntimeFactsAsync(string subjectKey, int maxRuntimeFacts, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class InMemoryReachabilityCache : IReachabilityCache
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Signals.Models;
|
||||
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 RuntimeFactsBatchIngestionTests
|
||||
{
|
||||
private const string TestTenantId = "test-tenant";
|
||||
private const string TestCallgraphId = "test-callgraph-123";
|
||||
|
||||
[Fact]
|
||||
public async Task IngestBatchAsync_ParsesNdjsonAndStoresArtifact()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryReachabilityFactRepository();
|
||||
var artifactStore = new InMemoryRuntimeFactsArtifactStore();
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
var service = CreateService(repository, artifactStore, cryptoHash);
|
||||
|
||||
var events = new[]
|
||||
{
|
||||
new { symbolId = "func_a", hitCount = 5, callgraphId = TestCallgraphId, subject = new { scanId = "scan-1" } },
|
||||
new { symbolId = "func_b", hitCount = 3, callgraphId = TestCallgraphId, subject = new { scanId = "scan-1" } },
|
||||
new { symbolId = "func_c", hitCount = 1, callgraphId = TestCallgraphId, subject = new { scanId = "scan-1" } }
|
||||
};
|
||||
var ndjson = string.Join("\n", events.Select(e => JsonSerializer.Serialize(e)));
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||
|
||||
// Act
|
||||
var result = await service.IngestBatchAsync(TestTenantId, stream, "application/x-ndjson", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.StartsWith("cas://reachability/runtime-facts/", result.CasUri);
|
||||
Assert.StartsWith("blake3:", result.BatchHash);
|
||||
Assert.Equal(1, result.ProcessedCount);
|
||||
Assert.Equal(3, result.TotalEvents);
|
||||
Assert.Equal(9, result.TotalHitCount);
|
||||
Assert.Contains("scan-1", result.SubjectKeys);
|
||||
Assert.True(artifactStore.StoredArtifacts.Count > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestBatchAsync_HandlesGzipCompressedContent()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryReachabilityFactRepository();
|
||||
var artifactStore = new InMemoryRuntimeFactsArtifactStore();
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
var service = CreateService(repository, artifactStore, cryptoHash);
|
||||
|
||||
var events = new[]
|
||||
{
|
||||
new { symbolId = "func_gzip", hitCount = 10, callgraphId = TestCallgraphId, subject = new { scanId = "scan-gzip" } }
|
||||
};
|
||||
var ndjson = string.Join("\n", events.Select(e => JsonSerializer.Serialize(e)));
|
||||
|
||||
using var compressedStream = new MemoryStream();
|
||||
await using (var gzipStream = new GZipStream(compressedStream, CompressionMode.Compress, leaveOpen: true))
|
||||
{
|
||||
await gzipStream.WriteAsync(Encoding.UTF8.GetBytes(ndjson));
|
||||
}
|
||||
|
||||
compressedStream.Position = 0;
|
||||
|
||||
// Act
|
||||
var result = await service.IngestBatchAsync(TestTenantId, compressedStream, "application/gzip", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, result.ProcessedCount);
|
||||
Assert.Equal(1, result.TotalEvents);
|
||||
Assert.Equal(10, result.TotalHitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestBatchAsync_GroupsEventsBySubject()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryReachabilityFactRepository();
|
||||
var artifactStore = new InMemoryRuntimeFactsArtifactStore();
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
var service = CreateService(repository, artifactStore, cryptoHash);
|
||||
|
||||
var events = new[]
|
||||
{
|
||||
new { symbolId = "func_a", hitCount = 1, callgraphId = "cg-1", subject = new { scanId = "scan-1" } },
|
||||
new { symbolId = "func_b", hitCount = 2, callgraphId = "cg-1", subject = new { scanId = "scan-1" } },
|
||||
new { symbolId = "func_c", hitCount = 3, callgraphId = "cg-2", subject = new { scanId = "scan-2" } },
|
||||
new { symbolId = "func_d", hitCount = 4, callgraphId = "cg-2", subject = new { scanId = "scan-2" } }
|
||||
};
|
||||
var ndjson = string.Join("\n", events.Select(e => JsonSerializer.Serialize(e)));
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||
|
||||
// Act
|
||||
var result = await service.IngestBatchAsync(TestTenantId, stream, "application/x-ndjson", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.ProcessedCount);
|
||||
Assert.Equal(4, result.TotalEvents);
|
||||
Assert.Equal(10, result.TotalHitCount);
|
||||
Assert.Contains("scan-1", result.SubjectKeys);
|
||||
Assert.Contains("scan-2", result.SubjectKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestBatchAsync_LinksCasUriToFactDocument()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryReachabilityFactRepository();
|
||||
var artifactStore = new InMemoryRuntimeFactsArtifactStore();
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
var service = CreateService(repository, artifactStore, cryptoHash);
|
||||
|
||||
var events = new[]
|
||||
{
|
||||
new { symbolId = "func_link", hitCount = 1, callgraphId = TestCallgraphId, subject = new { scanId = "scan-link" } }
|
||||
};
|
||||
var ndjson = string.Join("\n", events.Select(e => JsonSerializer.Serialize(e)));
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||
|
||||
// Act
|
||||
var result = await service.IngestBatchAsync(TestTenantId, stream, "application/x-ndjson", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var fact = await repository.GetBySubjectAsync("scan-link", CancellationToken.None);
|
||||
Assert.NotNull(fact);
|
||||
Assert.Equal(result.CasUri, fact.RuntimeFactsBatchUri);
|
||||
Assert.Equal(result.BatchHash, fact.RuntimeFactsBatchHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestBatchAsync_SkipsInvalidLines()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryReachabilityFactRepository();
|
||||
var artifactStore = new InMemoryRuntimeFactsArtifactStore();
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
var service = CreateService(repository, artifactStore, cryptoHash);
|
||||
|
||||
var ndjson = """
|
||||
{"symbolId": "func_valid", "hitCount": 1, "callgraphId": "cg-1", "subject": {"scanId": "scan-skip"}}
|
||||
invalid json line
|
||||
{"symbolId": "", "hitCount": 1, "callgraphId": "cg-1", "subject": {"scanId": "scan-skip"}}
|
||||
{"symbolId": "func_valid2", "hitCount": 2, "callgraphId": "cg-1", "subject": {"scanId": "scan-skip"}}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||
|
||||
// Act
|
||||
var result = await service.IngestBatchAsync(TestTenantId, stream, "application/x-ndjson", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, result.ProcessedCount);
|
||||
Assert.Equal(2, result.TotalEvents); // Only valid lines
|
||||
Assert.Equal(3, result.TotalHitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestBatchAsync_WorksWithoutArtifactStore()
|
||||
{
|
||||
// Arrange
|
||||
var repository = new InMemoryReachabilityFactRepository();
|
||||
var service = CreateService(repository, artifactStore: null, cryptoHash: null);
|
||||
|
||||
var events = new[]
|
||||
{
|
||||
new { symbolId = "func_no_cas", hitCount = 5, callgraphId = TestCallgraphId, subject = new { scanId = "scan-no-cas" } }
|
||||
};
|
||||
var ndjson = string.Join("\n", events.Select(e => JsonSerializer.Serialize(e)));
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||
|
||||
// Act
|
||||
var result = await service.IngestBatchAsync(TestTenantId, stream, "application/x-ndjson", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.StartsWith("cas://reachability/runtime-facts/", result.CasUri);
|
||||
Assert.Equal(1, result.ProcessedCount);
|
||||
}
|
||||
|
||||
private static RuntimeFactsIngestionService CreateService(
|
||||
IReachabilityFactRepository repository,
|
||||
IRuntimeFactsArtifactStore? artifactStore,
|
||||
ICryptoHash? cryptoHash)
|
||||
{
|
||||
var cache = new InMemoryReachabilityCache();
|
||||
var eventsPublisher = new NullEventsPublisher();
|
||||
var scoringService = new StubReachabilityScoringService();
|
||||
var provenanceNormalizer = new StubProvenanceNormalizer();
|
||||
|
||||
return new RuntimeFactsIngestionService(
|
||||
repository,
|
||||
TimeProvider.System,
|
||||
cache,
|
||||
eventsPublisher,
|
||||
scoringService,
|
||||
provenanceNormalizer,
|
||||
NullLogger<RuntimeFactsIngestionService>.Instance,
|
||||
artifactStore,
|
||||
cryptoHash);
|
||||
}
|
||||
|
||||
private sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepository
|
||||
{
|
||||
private readonly Dictionary<string, ReachabilityFactDocument> _facts = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<ReachabilityFactDocument> UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
_facts[document.SubjectKey] = document;
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
|
||||
public Task<ReachabilityFactDocument?> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_facts.TryGetValue(subjectKey, out var doc) ? doc : null);
|
||||
|
||||
public Task<IReadOnlyList<ReachabilityFactDocument>> GetExpiredAsync(DateTimeOffset cutoff, int limit, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<ReachabilityFactDocument>>([]);
|
||||
|
||||
public Task<bool> DeleteAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var removed = _facts.Remove(subjectKey);
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
public Task<int> GetRuntimeFactsCountAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_facts.TryGetValue(subjectKey, out var doc) ? doc.RuntimeFacts?.Count ?? 0 : 0);
|
||||
|
||||
public Task TrimRuntimeFactsAsync(string subjectKey, int maxCount, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class InMemoryRuntimeFactsArtifactStore : IRuntimeFactsArtifactStore
|
||||
{
|
||||
public Dictionary<string, StoredRuntimeFactsArtifact> StoredArtifacts { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public async Task<StoredRuntimeFactsArtifact> SaveAsync(RuntimeFactsArtifactSaveRequest request, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await content.CopyToAsync(ms, cancellationToken);
|
||||
|
||||
var artifact = new StoredRuntimeFactsArtifact(
|
||||
Path: $"cas/reachability/runtime-facts/{request.Hash[..2]}/{request.Hash}/{request.FileName}",
|
||||
Length: ms.Length,
|
||||
Hash: request.Hash,
|
||||
ContentType: request.ContentType,
|
||||
CasUri: $"cas://reachability/runtime-facts/{request.Hash}");
|
||||
|
||||
StoredArtifacts[request.Hash] = artifact;
|
||||
return artifact;
|
||||
}
|
||||
|
||||
public Task<Stream?> GetAsync(string hash, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<Stream?>(null);
|
||||
|
||||
public Task<bool> ExistsAsync(string hash, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(StoredArtifacts.ContainsKey(hash));
|
||||
|
||||
public Task<bool> DeleteAsync(string hash, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(StoredArtifacts.Remove(hash));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryReachabilityCache : IReachabilityCache
|
||||
{
|
||||
public Task<ReachabilityFactDocument?> GetAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<ReachabilityFactDocument?>(null);
|
||||
|
||||
public Task SetAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task InvalidateAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NullEventsPublisher : IEventsPublisher
|
||||
{
|
||||
public Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class StubReachabilityScoringService : IReachabilityScoringService
|
||||
{
|
||||
public Task<ReachabilityFactDocument> RecomputeAsync(ReachabilityRecomputeRequest request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new ReachabilityFactDocument { SubjectKey = request.Subject.ToSubjectKey() });
|
||||
}
|
||||
|
||||
private sealed class StubProvenanceNormalizer : IRuntimeFactsProvenanceNormalizer
|
||||
{
|
||||
public ContextFacts CreateContextFacts(
|
||||
IEnumerable<RuntimeFactEvent> events,
|
||||
ReachabilitySubject subject,
|
||||
string callgraphId,
|
||||
Dictionary<string, string?>? metadata,
|
||||
DateTimeOffset timestamp)
|
||||
=> new();
|
||||
|
||||
public ProvenanceFeed NormalizeToFeed(
|
||||
IEnumerable<RuntimeFactEvent> events,
|
||||
ReachabilitySubject subject,
|
||||
string callgraphId,
|
||||
Dictionary<string, string?>? metadata,
|
||||
DateTimeOffset generatedAt)
|
||||
=> new() { FeedId = "test-feed", GeneratedAt = generatedAt };
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,18 @@ public class RuntimeFactsIngestionServiceTests
|
||||
Last = 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 id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<int> GetRuntimeFactsCountAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task TrimRuntimeFactsAsync(string subjectKey, int maxRuntimeFacts, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class InMemoryReachabilityCache : IReachabilityCache
|
||||
|
||||
Reference in New Issue
Block a user