fix tests. new product advisories enhancements

This commit is contained in:
master
2026-01-25 19:11:36 +02:00
parent c70e83719e
commit 6e687b523a
504 changed files with 40610 additions and 3785 deletions

View File

@@ -15,13 +15,13 @@ namespace StellaOps.ReachGraph.WebService.Tests;
/// Integration tests for the ReachGraph Store API.
/// Uses in-memory providers for quick testing without containers.
/// </summary>
public class ReachGraphApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
public class ReachGraphApiIntegrationTests : IClassFixture<ReachGraphTestFactory>
{
private readonly HttpClient _client;
private const string TenantHeader = "X-Tenant-ID";
private const string TestTenant = "test-tenant";
public ReachGraphApiIntegrationTests(WebApplicationFactory<Program> factory)
public ReachGraphApiIntegrationTests(ReachGraphTestFactory factory)
{
_client = factory.CreateClient();
_client.DefaultRequestHeaders.Add(TenantHeader, TestTenant);

View File

@@ -0,0 +1,248 @@
// Licensed to StellaOps under the BUSL-1.1 license.
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.ReachGraph.Cache;
using StellaOps.ReachGraph.Persistence;
using StellaOps.ReachGraph.Schema;
using StackExchange.Redis;
using System.Collections.Concurrent;
namespace StellaOps.ReachGraph.WebService.Tests;
/// <summary>
/// Test factory for ReachGraph WebService integration tests.
/// Provides in-memory implementations of PostgreSQL and Redis dependencies.
/// </summary>
public sealed class ReachGraphTestFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Must configure settings BEFORE services are built to avoid connection string exception
builder.UseSetting("ConnectionStrings:PostgreSQL", "Host=localhost;Database=reachgraph_test;Username=test;Password=test");
builder.UseSetting("ConnectionStrings:Redis", "localhost:6379,abortConnect=false");
builder.UseEnvironment("Development");
builder.ConfigureServices(services =>
{
// Remove real implementations that would try to connect
var npgsqlDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(Npgsql.NpgsqlDataSource));
if (npgsqlDescriptor != null) services.Remove(npgsqlDescriptor);
var redisDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IConnectionMultiplexer));
if (redisDescriptor != null) services.Remove(redisDescriptor);
services.RemoveAll<IReachGraphRepository>();
services.RemoveAll<IReachGraphCache>();
// Add in-memory implementations
services.AddSingleton<IReachGraphRepository, InMemoryReachGraphRepository>();
services.AddSingleton<IReachGraphCache, InMemoryReachGraphCache>();
});
}
}
/// <summary>
/// In-memory implementation of <see cref="IReachGraphRepository"/> for testing.
/// </summary>
internal sealed class InMemoryReachGraphRepository : IReachGraphRepository
{
private readonly ConcurrentDictionary<string, (ReachGraphMinimal Graph, DateTimeOffset StoredAt)> _graphs = new();
private readonly ConcurrentDictionary<string, List<string>> _byArtifact = new();
private readonly ConcurrentDictionary<string, List<string>> _byCve = new();
private readonly List<ReplayLogEntry> _replayLog = new();
public Task<StoreResult> StoreAsync(ReachGraphMinimal graph, string tenantId, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var digest = $"blake3:{Guid.NewGuid():N}";
var key = MakeKey(tenantId, digest);
var isNew = _graphs.TryAdd(key, (graph, DateTimeOffset.UtcNow));
// Track by artifact
var artifactKey = MakeArtifactKey(tenantId, graph.Artifact.Digest);
_byArtifact.AddOrUpdate(
artifactKey,
_ => [digest],
(_, list) => { if (!list.Contains(digest)) list.Add(digest); return list; });
// Track by CVE
if (graph.Scope.Cves is { Length: > 0 })
{
foreach (var cve in graph.Scope.Cves)
{
var cveKey = MakeCveKey(tenantId, cve);
_byCve.AddOrUpdate(
cveKey,
_ => [digest],
(_, list) => { if (!list.Contains(digest)) list.Add(digest); return list; });
}
}
return Task.FromResult(new StoreResult
{
Digest = digest,
Created = isNew,
ArtifactDigest = graph.Artifact.Digest,
NodeCount = graph.Nodes.Length,
EdgeCount = graph.Edges.Length,
StoredAt = DateTimeOffset.UtcNow
});
}
public Task<ReachGraphMinimal?> GetByDigestAsync(string digest, string tenantId, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var key = MakeKey(tenantId, digest);
return Task.FromResult(_graphs.TryGetValue(key, out var entry) ? entry.Graph : null);
}
public Task<bool> DeleteAsync(string digest, string tenantId, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var key = MakeKey(tenantId, digest);
return Task.FromResult(_graphs.TryRemove(key, out _));
}
public Task<IReadOnlyList<ReachGraphSummary>> ListByArtifactAsync(
string artifactDigest,
string tenantId,
int limit,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var artifactKey = MakeArtifactKey(tenantId, artifactDigest);
if (!_byArtifact.TryGetValue(artifactKey, out var digests))
{
return Task.FromResult<IReadOnlyList<ReachGraphSummary>>(Array.Empty<ReachGraphSummary>());
}
var items = digests
.Take(limit)
.Select(d =>
{
var graph = _graphs[MakeKey(tenantId, d)].Graph;
return new ReachGraphSummary
{
Digest = d,
ArtifactDigest = graph.Artifact.Digest,
NodeCount = graph.Nodes.Length,
EdgeCount = graph.Edges.Length,
BlobSizeBytes = 0,
CreatedAt = DateTimeOffset.UtcNow,
Scope = graph.Scope
};
})
.ToList();
return Task.FromResult<IReadOnlyList<ReachGraphSummary>>(items);
}
public Task<IReadOnlyList<ReachGraphSummary>> FindByCveAsync(
string cveId,
string tenantId,
int limit,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var cveKey = MakeCveKey(tenantId, cveId);
if (!_byCve.TryGetValue(cveKey, out var digests))
{
return Task.FromResult<IReadOnlyList<ReachGraphSummary>>(Array.Empty<ReachGraphSummary>());
}
var items = digests
.Take(limit)
.Select(d =>
{
var graph = _graphs[MakeKey(tenantId, d)].Graph;
return new ReachGraphSummary
{
Digest = d,
ArtifactDigest = graph.Artifact.Digest,
NodeCount = graph.Nodes.Length,
EdgeCount = graph.Edges.Length,
BlobSizeBytes = 0,
CreatedAt = DateTimeOffset.UtcNow,
Scope = graph.Scope
};
})
.ToList();
return Task.FromResult<IReadOnlyList<ReachGraphSummary>>(items);
}
public Task RecordReplayAsync(ReplayLogEntry entry, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
_replayLog.Add(entry);
return Task.CompletedTask;
}
private static string MakeKey(string tenantId, string digest) => $"{tenantId}:{digest}";
private static string MakeArtifactKey(string tenantId, string artifactDigest) => $"{tenantId}:artifact:{artifactDigest}";
private static string MakeCveKey(string tenantId, string cveId) => $"{tenantId}:cve:{cveId}";
}
/// <summary>
/// In-memory implementation of <see cref="IReachGraphCache"/> for testing.
/// </summary>
internal sealed class InMemoryReachGraphCache : IReachGraphCache
{
private readonly ConcurrentDictionary<string, ReachGraphMinimal> _cache = new();
private readonly ConcurrentDictionary<string, byte[]> _sliceCache = new();
public Task<ReachGraphMinimal?> GetAsync(string digest, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
return Task.FromResult(_cache.TryGetValue(digest, out var graph) ? graph : null);
}
public Task SetAsync(string digest, ReachGraphMinimal graph, TimeSpan? ttl, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
_cache[digest] = graph;
return Task.CompletedTask;
}
public Task<byte[]?> GetSliceAsync(string digest, string sliceKey, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var key = $"{digest}:{sliceKey}";
return Task.FromResult(_sliceCache.TryGetValue(key, out var slice) ? slice : null);
}
public Task SetSliceAsync(string digest, string sliceKey, byte[] slice, TimeSpan? ttl, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var key = $"{digest}:{sliceKey}";
_sliceCache[key] = slice;
return Task.CompletedTask;
}
public Task InvalidateAsync(string digest, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
_cache.TryRemove(digest, out _);
// Also remove related slices
var keysToRemove = _sliceCache.Keys.Where(k => k.StartsWith($"{digest}:")).ToList();
foreach (var key in keysToRemove)
{
_sliceCache.TryRemove(key, out _);
}
return Task.CompletedTask;
}
public Task<bool> ExistsAsync(string digest, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
return Task.FromResult(_cache.ContainsKey(digest));
}
}