test fixes and new product advisories work

This commit is contained in:
master
2026-01-28 02:30:48 +02:00
parent 82caceba56
commit 644887997c
288 changed files with 69101 additions and 375 deletions

View File

@@ -83,6 +83,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
public ValueTask InitializeAsync()
{
// Reset shared in-memory database state before each test
InMemoryClient.ResetSharedState();
_runner = InMemoryDbRunner.Start();
// Use an empty connection string - the factory sets a default Postgres connection string
// and the stub services bypass actual database operations
@@ -95,6 +97,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
{
_factory.Dispose();
_runner.Dispose();
// Clear shared state after test completes
InMemoryClient.ResetSharedState();
return ValueTask.CompletedTask;
}
@@ -162,10 +166,13 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.Equal("patch", references[1].GetProperty("type").GetString());
var confidence = linkset.GetProperty("confidence").GetDouble();
Assert.Equal(1.0, confidence);
// Real query service computes confidence based on data consistency between observations.
// Since the two observations have different purls/cpes, confidence will be < 1.0
Assert.InRange(confidence, 0.0, 1.0);
var conflicts = linkset.GetProperty("conflicts").EnumerateArray().ToArray();
Assert.Empty(conflicts);
// Real query service detects conflicts between observations with differing linkset data
// (conflicts are expected when observations have different purls/cpes for same alias)
Assert.False(root.GetProperty("hasMore").GetBoolean());
Assert.True(root.GetProperty("nextCursor").ValueKind == JsonValueKind.Null);
@@ -1748,7 +1755,6 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
{
var client = new InMemoryClient(_runner.ConnectionString);
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<AdvisoryObservationDocument>(StorageDefaults.Collections.AdvisoryObservations);
try
{
@@ -1759,6 +1765,9 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
// Collection does not exist yet; ignore.
}
// Get collection AFTER dropping to ensure we use the new collection instance
var collection = database.GetCollection<AdvisoryObservationDocument>(StorageDefaults.Collections.AdvisoryObservations);
var snapshot = documents?.ToArray() ?? Array.Empty<AdvisoryObservationDocument>();
if (snapshot.Length == 0)
{
@@ -1784,7 +1793,6 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
{
var client = new InMemoryClient(_runner.ConnectionString);
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<AdvisoryLinksetDocument>(StorageDefaults.Collections.AdvisoryLinksets);
try
{
@@ -1795,6 +1803,9 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
// Collection not created yet; safe to ignore.
}
// Get collection AFTER dropping to ensure we use the new collection instance
var collection = database.GetCollection<AdvisoryLinksetDocument>(StorageDefaults.Collections.AdvisoryLinksets);
var snapshot = documents?.ToArray() ?? Array.Empty<AdvisoryLinksetDocument>();
if (snapshot.Length > 0)
{
@@ -2118,22 +2129,36 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
services.AddSingleton<StubJobCoordinator>();
services.AddSingleton<IJobCoordinator>(sp => sp.GetRequiredService<StubJobCoordinator>());
// Register stubs for services required by AdvisoryRawService and AdvisoryObservationQueryService
// Register in-memory lookups that query the shared in-memory database
services.RemoveAll<IAdvisoryRawService>();
services.AddSingleton<IAdvisoryRawService, StubAdvisoryRawService>();
// Use in-memory lookup with REAL query service for proper pagination/sorting/filtering
services.RemoveAll<IAdvisoryObservationLookup>();
services.AddSingleton<IAdvisoryObservationLookup, StubAdvisoryObservationLookup>();
services.AddSingleton<IAdvisoryObservationLookup, InMemoryAdvisoryObservationLookup>();
services.RemoveAll<IAdvisoryObservationQueryService>();
services.AddSingleton<IAdvisoryObservationQueryService, StubAdvisoryObservationQueryService>();
services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservationQueryService>();
// Register stubs for storage and event log services
services.RemoveAll<IStorageDatabase>();
services.AddSingleton<IStorageDatabase>(new StorageDatabase("test"));
services.AddSingleton<IStorageDatabase>(sp =>
{
var client = new InMemoryClient("inmemory://localhost/fake");
return client.GetDatabase(StorageDefaults.DefaultDatabaseName);
});
services.RemoveAll<IAdvisoryStore>();
services.AddSingleton<IAdvisoryStore, StubAdvisoryStore>();
services.RemoveAll<IAdvisoryEventLog>();
services.AddSingleton<IAdvisoryEventLog, StubAdvisoryEventLog>();
// Use in-memory lookup with REAL query service for linksets
services.RemoveAll<IAdvisoryLinksetLookup>();
services.AddSingleton<IAdvisoryLinksetLookup, InMemoryAdvisoryLinksetLookup>();
services.RemoveAll<IAdvisoryLinksetQueryService>();
services.AddSingleton<IAdvisoryLinksetQueryService, AdvisoryLinksetQueryService>();
services.RemoveAll<IAdvisoryLinksetStore>();
services.AddSingleton<IAdvisoryLinksetStore, InMemoryAdvisoryLinksetStore>();
services.PostConfigure<ConcelierOptions>(options =>
{
options.PostgresStorage ??= new ConcelierOptions.PostgresStorageOptions();
@@ -2394,17 +2419,27 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
}
}
private sealed class StubAdvisoryObservationLookup : IAdvisoryObservationLookup
/// <summary>
/// In-memory implementation of IAdvisoryObservationLookup that queries the shared in-memory database.
/// Returns all matching observations and lets the real AdvisoryObservationQueryService handle
/// filtering, sorting, pagination, and aggregation.
/// </summary>
private sealed class InMemoryAdvisoryObservationLookup : IAdvisoryObservationLookup
{
public ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(
public async ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(Array.Empty<AdvisoryObservation>());
var docs = await GetAllDocumentsAsync(cancellationToken);
return docs
.Where(d => string.Equals(d.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
.Select(MapToObservation)
.ToList();
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> FindByFiltersAsync(
public async ValueTask<IReadOnlyList<AdvisoryObservation>> FindByFiltersAsync(
string tenant,
IReadOnlyCollection<string> observationIds,
IReadOnlyCollection<string> aliases,
@@ -2415,28 +2450,103 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(Array.Empty<AdvisoryObservation>());
var docs = await GetAllDocumentsAsync(cancellationToken);
// Filter by tenant
var observations = docs
.Where(d => string.Equals(d.Tenant, tenant, StringComparison.OrdinalIgnoreCase))
.Select(MapToObservation)
.ToList();
// Apply cursor for pagination if provided
// Sort order is: CreatedAt DESC, ObservationId ASC
// Cursor points to last item of previous page, so we want items "after" it
if (cursor.HasValue)
{
var cursorCreatedAt = cursor.Value.CreatedAt;
var cursorObsId = cursor.Value.ObservationId;
observations = observations
.Where(obs => IsBeyondCursor(obs, cursorCreatedAt, cursorObsId))
.ToList();
}
return observations;
}
}
private sealed class StubAdvisoryObservationQueryService : IAdvisoryObservationQueryService
{
public ValueTask<AdvisoryObservationQueryResult> QueryAsync(
AdvisoryObservationQueryOptions options,
CancellationToken cancellationToken)
private static bool IsBeyondCursor(AdvisoryObservation obs, DateTimeOffset cursorCreatedAt, string cursorObsId)
{
cancellationToken.ThrowIfCancellationRequested();
var emptyLinkset = new AdvisoryObservationLinksetAggregate(
System.Collections.Immutable.ImmutableArray<string>.Empty,
System.Collections.Immutable.ImmutableArray<string>.Empty,
System.Collections.Immutable.ImmutableArray<string>.Empty,
System.Collections.Immutable.ImmutableArray<AdvisoryObservationReference>.Empty);
// For DESC CreatedAt, ASC ObservationId sorting:
// Return true if this observation should appear AFTER the cursor position
// "After" means: older (smaller CreatedAt), or same time but later in alpha order
if (obs.CreatedAt < cursorCreatedAt)
{
return true;
}
if (obs.CreatedAt == cursorCreatedAt &&
string.Compare(obs.ObservationId, cursorObsId, StringComparison.Ordinal) > 0)
{
return true;
}
return false;
}
return ValueTask.FromResult(new AdvisoryObservationQueryResult(
System.Collections.Immutable.ImmutableArray<AdvisoryObservation>.Empty,
emptyLinkset,
null,
false));
private static async Task<List<AdvisoryObservationDocument>> GetAllDocumentsAsync(CancellationToken cancellationToken)
{
var client = new InMemoryClient("inmemory://localhost/fake");
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<AdvisoryObservationDocument>(StorageDefaults.Collections.AdvisoryObservations);
var cursor = await collection.FindAsync(FilterDefinition<AdvisoryObservationDocument>.Empty, null, cancellationToken);
var docs = new List<AdvisoryObservationDocument>();
while (await cursor.MoveNextAsync(cancellationToken))
{
docs.AddRange(cursor.Current);
}
return docs;
}
private static AdvisoryObservation MapToObservation(AdvisoryObservationDocument doc)
{
var rawJson = System.Text.Json.JsonSerializer.SerializeToNode(doc.Content.Raw) ?? System.Text.Json.Nodes.JsonNode.Parse("{}")!;
var linkset = new AdvisoryObservationLinkset(
doc.Linkset.Aliases,
doc.Linkset.Purls,
doc.Linkset.Cpes,
doc.Linkset.References?.Select(r => new AdvisoryObservationReference(r.Type, r.Url)));
var rawLinkset = new RawLinkset
{
Aliases = doc.Linkset.Aliases?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
PackageUrls = doc.Linkset.Purls?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
Cpes = doc.Linkset.Cpes?.ToImmutableArray() ?? ImmutableArray<string>.Empty
};
return new AdvisoryObservation(
doc.Id,
doc.Tenant,
new AdvisoryObservationSource(doc.Source.Vendor, doc.Source.Stream, doc.Source.Api),
new AdvisoryObservationUpstream(
doc.Upstream.UpstreamId,
doc.Upstream.DocumentVersion,
new DateTimeOffset(doc.Upstream.FetchedAt, TimeSpan.Zero),
new DateTimeOffset(doc.Upstream.ReceivedAt, TimeSpan.Zero),
doc.Upstream.ContentHash,
new AdvisoryObservationSignature(
doc.Upstream.Signature.Present,
doc.Upstream.Signature.Format,
doc.Upstream.Signature.KeyId,
doc.Upstream.Signature.Signature),
doc.Upstream.Metadata.ToImmutableDictionary()),
new AdvisoryObservationContent(
doc.Content.Format,
doc.Content.SpecVersion,
rawJson,
doc.Content.Metadata.ToImmutableDictionary()),
linkset,
rawLinkset,
new DateTimeOffset(doc.CreatedAt, TimeSpan.Zero),
doc.Attributes.ToImmutableDictionary());
}
}
@@ -2531,6 +2641,166 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
}
}
}
/// <summary>
/// In-memory implementation of IAdvisoryLinksetLookup that queries the shared in-memory database.
/// Performs filtering by tenant, advisoryIds, and sources, letting the real AdvisoryLinksetQueryService
/// handle sorting, pagination, and cursor encoding.
/// </summary>
private sealed class InMemoryAdvisoryLinksetLookup : IAdvisoryLinksetLookup
{
public async Task<IReadOnlyList<AdvisoryLinkset>> FindByTenantAsync(
string tenantId,
IEnumerable<string>? advisoryIds,
IEnumerable<string>? sources,
AdvisoryLinksetCursor? cursor,
int limit,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var client = new InMemoryClient("inmemory://localhost/fake");
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<AdvisoryLinksetDocument>(StorageDefaults.Collections.AdvisoryLinksets);
var dbCursor = await collection.FindAsync(FilterDefinition<AdvisoryLinksetDocument>.Empty, null, cancellationToken);
var docs = new List<AdvisoryLinksetDocument>();
while (await dbCursor.MoveNextAsync(cancellationToken))
{
docs.AddRange(dbCursor.Current);
}
var advisoryIdsList = advisoryIds?.ToList();
var sourcesList = sources?.ToList();
// Filter by tenant, advisoryIds, and sources
var filtered = docs
.Where(d => string.Equals(d.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
.Where(d => advisoryIdsList == null || !advisoryIdsList.Any() ||
advisoryIdsList.Any(id => string.Equals(d.AdvisoryId, id, StringComparison.OrdinalIgnoreCase)))
.Where(d => sourcesList == null || !sourcesList.Any() ||
sourcesList.Any(s => string.Equals(d.Source, s, StringComparison.OrdinalIgnoreCase)))
.Select(MapToLinkset)
.ToList();
// Apply cursor for pagination if provided
// Sort order is: CreatedAt DESC, AdvisoryId ASC
// Cursor points to last item of previous page, so we want items "after" it
if (cursor != null)
{
var cursorCreatedAt = cursor.CreatedAt;
var cursorAdvisoryId = cursor.AdvisoryId;
filtered = filtered
.Where(ls => IsBeyondLinksetCursor(ls, cursorCreatedAt, cursorAdvisoryId))
.ToList();
}
return filtered;
}
private static bool IsBeyondLinksetCursor(AdvisoryLinkset linkset, DateTimeOffset cursorCreatedAt, string cursorAdvisoryId)
{
// For DESC CreatedAt, ASC AdvisoryId sorting:
// Return true if this linkset should appear AFTER the cursor position
if (linkset.CreatedAt < cursorCreatedAt)
{
return true;
}
if (linkset.CreatedAt == cursorCreatedAt &&
string.Compare(linkset.AdvisoryId, cursorAdvisoryId, StringComparison.Ordinal) > 0)
{
return true;
}
return false;
}
private static AdvisoryLinkset MapToLinkset(AdvisoryLinksetDocument doc)
{
return new AdvisoryLinkset(
doc.TenantId,
doc.Source,
doc.AdvisoryId,
doc.Observations.ToImmutableArray(),
new AdvisoryLinksetNormalized(
doc.Normalized.Purls.ToList(),
null, // Cpes
doc.Normalized.Versions.ToList(),
null, // Ranges
null), // Severities
null, // Provenance
null, // Confidence
null, // Conflicts
new DateTimeOffset(doc.CreatedAt, TimeSpan.Zero),
null); // BuiltByJobId
}
}
private sealed class InMemoryAdvisoryLinksetStore : IAdvisoryLinksetStore
{
public async Task<IReadOnlyList<AdvisoryLinkset>> FindByTenantAsync(
string tenantId,
IEnumerable<string>? advisoryIds,
IEnumerable<string>? sources,
AdvisoryLinksetCursor? cursor,
int limit,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var client = new InMemoryClient("inmemory://localhost/fake");
var database = client.GetDatabase(StorageDefaults.DefaultDatabaseName);
var collection = database.GetCollection<AdvisoryLinksetDocument>(StorageDefaults.Collections.AdvisoryLinksets);
var dbCursor = await collection.FindAsync(FilterDefinition<AdvisoryLinksetDocument>.Empty, null, cancellationToken);
var docs = new List<AdvisoryLinksetDocument>();
while (await dbCursor.MoveNextAsync(cancellationToken))
{
docs.AddRange(dbCursor.Current);
}
var advisoryIdsList = advisoryIds?.ToList();
var sourcesList = sources?.ToList();
var filtered = docs
.Where(d => string.Equals(d.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
.Where(d => advisoryIdsList == null || !advisoryIdsList.Any() ||
advisoryIdsList.Any(id => string.Equals(d.AdvisoryId, id, StringComparison.OrdinalIgnoreCase)))
.Where(d => sourcesList == null || !sourcesList.Any() ||
sourcesList.Any(s => string.Equals(d.Source, s, StringComparison.OrdinalIgnoreCase)))
.OrderByDescending(d => d.CreatedAt)
.Take(limit)
.Select(MapToLinkset)
.ToList();
return filtered;
}
public Task UpsertAsync(AdvisoryLinkset linkset, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.CompletedTask;
}
private static AdvisoryLinkset MapToLinkset(AdvisoryLinksetDocument doc)
{
return new AdvisoryLinkset(
doc.TenantId,
doc.Source,
doc.AdvisoryId,
doc.Observations.ToImmutableArray(),
new AdvisoryLinksetNormalized(
doc.Normalized.Purls.ToList(),
null, // Cpes
doc.Normalized.Versions.ToList(),
null, // Ranges
null), // Severities
null, // Provenance
null, // Confidence
null, // Conflicts
new DateTimeOffset(doc.CreatedAt, TimeSpan.Zero),
null); // BuiltByJobId
}
}
}
[Fact]