test fixes and new product advisories work
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user