partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Reachability.Core;
|
||||
using StellaOps.ReachGraph.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Tests;
|
||||
|
||||
public class InMemorySignalsAdapterTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsNotObserved_WhenNoFacts()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "System",
|
||||
TypeName = "String",
|
||||
MemberName = "Trim"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(
|
||||
symbol,
|
||||
"sha256:test",
|
||||
TimeSpan.FromDays(7),
|
||||
"tenant1",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.WasObserved.Should().BeFalse();
|
||||
result.HitCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsObserved_WhenFactsExist()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddHours(-1),
|
||||
hitCount: 100,
|
||||
environment: "production",
|
||||
serviceName: "api-gateway");
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(
|
||||
symbol,
|
||||
"sha256:test",
|
||||
TimeSpan.FromDays(7),
|
||||
"tenant1",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.WasObserved.Should().BeTrue();
|
||||
result.HitCount.Should().Be(100);
|
||||
result.FirstSeen.Should().NotBeNull();
|
||||
result.LastSeen.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsNotObserved_WhenOutsideWindow()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
// Record observation 10 days ago
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddDays(-10),
|
||||
hitCount: 50);
|
||||
|
||||
// Act - query with 7-day window
|
||||
var result = await adapter.QueryAsync(
|
||||
symbol,
|
||||
"sha256:test",
|
||||
TimeSpan.FromDays(7),
|
||||
"tenant1",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.WasObserved.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_AggregatesMultipleObservations()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddHours(-2),
|
||||
hitCount: 50);
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddHours(-1),
|
||||
hitCount: 75);
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(
|
||||
symbol,
|
||||
"sha256:test",
|
||||
TimeSpan.FromDays(7),
|
||||
"tenant1",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.WasObserved.Should().BeTrue();
|
||||
result.HitCount.Should().Be(125); // 50 + 75
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_IncludesContexts()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddMinutes(-30),
|
||||
hitCount: 10,
|
||||
environment: "production",
|
||||
serviceName: "api-gateway",
|
||||
traceId: "trace-001");
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(
|
||||
symbol,
|
||||
"sha256:test",
|
||||
TimeSpan.FromDays(7),
|
||||
"tenant1",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Contexts.Should().NotBeEmpty();
|
||||
result.Contexts[0].Environment.Should().Be("production");
|
||||
result.Contexts[0].Service.Should().Be("api-gateway");
|
||||
result.Contexts[0].TraceId.Should().Be("trace-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_IsolatesByTenant()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddMinutes(-30),
|
||||
hitCount: 100);
|
||||
|
||||
// Act - query different tenant
|
||||
var result = await adapter.QueryAsync(
|
||||
symbol,
|
||||
"sha256:test",
|
||||
TimeSpan.FromDays(7),
|
||||
"tenant2",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.WasObserved.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasFactsAsync_ReturnsTrue_WhenFactsExist()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow(),
|
||||
hitCount: 1);
|
||||
|
||||
// Act
|
||||
var result = await adapter.HasFactsAsync("sha256:test", "tenant1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasFactsAsync_ReturnsFalse_WhenNoFacts()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
|
||||
// Act
|
||||
var result = await adapter.HasFactsAsync("sha256:test", "tenant1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetadataAsync_ReturnsMetadata_WhenFactsExist()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddDays(-3),
|
||||
hitCount: 50,
|
||||
environment: "production");
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddDays(-1),
|
||||
hitCount: 100,
|
||||
environment: "staging");
|
||||
|
||||
// Act
|
||||
var metadata = await adapter.GetMetadataAsync("sha256:test", "tenant1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
metadata.Should().NotBeNull();
|
||||
metadata!.ArtifactDigest.Should().Be("sha256:test");
|
||||
metadata.TotalObservations.Should().Be(150);
|
||||
metadata.Environments.Should().Contain("production");
|
||||
metadata.Environments.Should().Contain("staging");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetadataAsync_ReturnsNull_WhenNoFacts()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
|
||||
// Act
|
||||
var metadata = await adapter.GetMetadataAsync("sha256:test", "tenant1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
metadata.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Reachability.Core;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.ReachGraph.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Tests;
|
||||
|
||||
public class ReachGraphStoreAdapterTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.UtcNow);
|
||||
private readonly InMemoryReachGraphStoreService _storeService = new();
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsNotReachable_WhenGraphNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = CreateAdapter();
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "System",
|
||||
TypeName = "String",
|
||||
MemberName = "Trim"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(symbol, "sha256:notfound", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsReachable.Should().BeFalse();
|
||||
result.Symbol.Should().Be(symbol);
|
||||
result.ArtifactDigest.Should().Be("sha256:notfound");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsReachable_WhenSymbolFoundInGraph()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph("sha256:test123");
|
||||
await _storeService.UpsertAsync(graph, "tenant1", CancellationToken.None);
|
||||
|
||||
var adapter = CreateAdapter();
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "VulnerableClass",
|
||||
MemberName = "Execute"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(symbol, "sha256:test123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsReachable.Should().BeTrue();
|
||||
result.DistanceFromEntrypoint.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsNotReachable_WhenSymbolNotInGraph()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph("sha256:test123");
|
||||
await _storeService.UpsertAsync(graph, "tenant1", CancellationToken.None);
|
||||
|
||||
var adapter = CreateAdapter();
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "NonExistent",
|
||||
TypeName = "Class",
|
||||
MemberName = "Method"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(symbol, "sha256:test123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsReachable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasGraphAsync_ReturnsTrue_WhenGraphExists()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph("sha256:exists123");
|
||||
await _storeService.UpsertAsync(graph, "tenant1", CancellationToken.None);
|
||||
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.HasGraphAsync("sha256:exists123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasGraphAsync_ReturnsFalse_WhenGraphNotExists()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.HasGraphAsync("sha256:doesnotexist", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetadataAsync_ReturnsMetadata_WhenGraphExists()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph("sha256:metadata123");
|
||||
await _storeService.UpsertAsync(graph, "tenant1", CancellationToken.None);
|
||||
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
// Act
|
||||
var metadata = await adapter.GetMetadataAsync("sha256:metadata123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
metadata.Should().NotBeNull();
|
||||
metadata!.ArtifactDigest.Should().Be("sha256:metadata123");
|
||||
metadata.NodeCount.Should().BeGreaterThan(0);
|
||||
metadata.EdgeCount.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetadataAsync_ReturnsNull_WhenGraphNotExists()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
// Act
|
||||
var metadata = await adapter.GetMetadataAsync("sha256:notfound", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
metadata.Should().BeNull();
|
||||
}
|
||||
|
||||
private ReachGraphStoreAdapter CreateAdapter()
|
||||
{
|
||||
return new ReachGraphStoreAdapter(
|
||||
_storeService,
|
||||
_timeProvider,
|
||||
NullLogger<ReachGraphStoreAdapter>.Instance);
|
||||
}
|
||||
|
||||
private static ReachGraphMinimal CreateTestGraph(string artifactDigest)
|
||||
{
|
||||
var entrypoint = new ReachGraphNode
|
||||
{
|
||||
Id = "entry-main",
|
||||
Ref = "MyApp.Program.Main",
|
||||
Kind = "method",
|
||||
Depth = 0
|
||||
};
|
||||
|
||||
var vulnerableClass = new ReachGraphNode
|
||||
{
|
||||
Id = "vulnerable-class",
|
||||
Ref = "MyApp.VulnerableClass.Execute",
|
||||
Kind = "method",
|
||||
Depth = 1
|
||||
};
|
||||
|
||||
var otherNode = new ReachGraphNode
|
||||
{
|
||||
Id = "other-node",
|
||||
Ref = "MyApp.OtherClass.DoWork",
|
||||
Kind = "method",
|
||||
Depth = 2
|
||||
};
|
||||
|
||||
var edges = ImmutableArray.Create(
|
||||
new ReachGraphEdge
|
||||
{
|
||||
Source = "entry-main",
|
||||
Target = "vulnerable-class"
|
||||
},
|
||||
new ReachGraphEdge
|
||||
{
|
||||
Source = "entry-main",
|
||||
Target = "other-node"
|
||||
});
|
||||
|
||||
return new ReachGraphMinimal
|
||||
{
|
||||
Artifact = new ReachGraphArtifact
|
||||
{
|
||||
Name = "test-artifact",
|
||||
Digest = artifactDigest,
|
||||
Env = "test"
|
||||
},
|
||||
Scope = new ReachGraphScope
|
||||
{
|
||||
Entrypoints = ImmutableArray.Create("entry-main"),
|
||||
Selectors = ImmutableArray<string>.Empty,
|
||||
Cves = null
|
||||
},
|
||||
Signature = null,
|
||||
Nodes = ImmutableArray.Create(entrypoint, vulnerableClass, otherNode),
|
||||
Edges = edges
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IReachGraphStoreService for testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryReachGraphStoreService : IReachGraphStoreService
|
||||
{
|
||||
private readonly Dictionary<string, ReachGraphMinimal> _graphs = new();
|
||||
|
||||
public Task<ReachGraphStoreResult> UpsertAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var digest = graph.Artifact.Digest;
|
||||
var created = !_graphs.ContainsKey(digest);
|
||||
_graphs[digest] = graph;
|
||||
|
||||
return Task.FromResult(new ReachGraphStoreResult
|
||||
{
|
||||
Digest = digest,
|
||||
ArtifactDigest = digest,
|
||||
Created = created,
|
||||
NodeCount = graph.Nodes.Length,
|
||||
EdgeCount = graph.Edges.Length,
|
||||
StoredAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ReachGraphMinimal?> GetByDigestAsync(
|
||||
string digest,
|
||||
string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_graphs.TryGetValue(digest, out var graph);
|
||||
return Task.FromResult(graph);
|
||||
}
|
||||
|
||||
public Task<ReachGraphMinimal?> GetByArtifactAsync(
|
||||
string artifactDigest,
|
||||
string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var graph = _graphs.Values.FirstOrDefault(g => g.Artifact.Digest == artifactDigest);
|
||||
return Task.FromResult(graph);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string digest, string? tenantId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_graphs.ContainsKey(digest));
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string digest, string? tenantId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_graphs.Remove(digest));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user