feat: Add Bun language analyzer and related functionality

- Implemented BunPackageNormalizer to deduplicate packages by name and version.
- Created BunProjectDiscoverer to identify Bun project roots in the filesystem.
- Added project files for the Bun analyzer including manifest and project configuration.
- Developed comprehensive tests for Bun language analyzer covering various scenarios.
- Included fixture files for testing standard installs, isolated linker installs, lockfile-only scenarios, and workspaces.
- Established stubs for authentication sessions to facilitate testing in the web application.
This commit is contained in:
StellaOps Bot
2025-12-06 11:20:35 +02:00
parent b978ae399f
commit a7cd10020a
85 changed files with 7414 additions and 42 deletions

View File

@@ -0,0 +1,148 @@
namespace StellaOps.Concelier.WebService.Deprecation;
/// <summary>
/// Standard HTTP deprecation headers per RFC 8594 and Sunset header spec.
/// Per CONCELIER-WEB-OAS-63-001.
/// </summary>
public static class DeprecationHeaders
{
/// <summary>
/// The Deprecation header field (RFC 8594).
/// Value is a date when the API was deprecated.
/// </summary>
public const string Deprecation = "Deprecation";
/// <summary>
/// The Sunset header field.
/// Value is an HTTP-date when the API will be removed.
/// </summary>
public const string Sunset = "Sunset";
/// <summary>
/// Link header with relation type pointing to successor API.
/// </summary>
public const string Link = "Link";
/// <summary>
/// Custom header for deprecation notice message.
/// </summary>
public const string XDeprecationNotice = "X-Deprecation-Notice";
/// <summary>
/// Custom header for migration guide URL.
/// </summary>
public const string XDeprecationGuide = "X-Deprecation-Guide";
}
/// <summary>
/// Deprecation information for an API endpoint.
/// </summary>
public sealed record DeprecationInfo
{
/// <summary>
/// Date when the API was deprecated (RFC 8594 format).
/// </summary>
public required DateTimeOffset DeprecatedAt { get; init; }
/// <summary>
/// Date when the API will be removed (Sunset header).
/// Null if no sunset date is set.
/// </summary>
public DateTimeOffset? SunsetAt { get; init; }
/// <summary>
/// URI of the successor API endpoint.
/// </summary>
public required string SuccessorUri { get; init; }
/// <summary>
/// Human-readable deprecation message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// URL to migration guide documentation.
/// </summary>
public string? MigrationGuideUrl { get; init; }
}
/// <summary>
/// Registry of deprecated endpoints and their successors.
/// </summary>
public static class DeprecatedEndpoints
{
/// <summary>
/// Date when legacy linkset/observation APIs were deprecated.
/// </summary>
public static readonly DateTimeOffset LegacyApisDeprecatedAt = new(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
/// <summary>
/// Date when legacy linkset/observation APIs will be removed.
/// </summary>
public static readonly DateTimeOffset LegacyApisSunsetAt = new(2026, 6, 1, 0, 0, 0, TimeSpan.Zero);
/// <summary>
/// Base URL for migration documentation.
/// </summary>
public const string MigrationGuideBaseUrl = "https://docs.stellaops.io/concelier/migration/lnm-v1";
/// <summary>
/// Legacy /linksets endpoint deprecation info.
/// </summary>
public static readonly DeprecationInfo LegacyLinksets = new()
{
DeprecatedAt = LegacyApisDeprecatedAt,
SunsetAt = LegacyApisSunsetAt,
SuccessorUri = "/v1/lnm/linksets",
Message = "This endpoint is deprecated. Use /v1/lnm/linksets instead for Link-Not-Merge linkset retrieval.",
MigrationGuideUrl = $"{MigrationGuideBaseUrl}#linksets"
};
/// <summary>
/// Legacy /advisories/observations endpoint deprecation info.
/// </summary>
public static readonly DeprecationInfo LegacyAdvisoryObservations = new()
{
DeprecatedAt = LegacyApisDeprecatedAt,
SunsetAt = LegacyApisSunsetAt,
SuccessorUri = "/v1/lnm/linksets",
Message = "This endpoint is deprecated. Use /v1/lnm/linksets with includeObservations=true instead.",
MigrationGuideUrl = $"{MigrationGuideBaseUrl}#observations"
};
/// <summary>
/// Legacy /advisories/linksets endpoint deprecation info.
/// </summary>
public static readonly DeprecationInfo LegacyAdvisoryLinksets = new()
{
DeprecatedAt = LegacyApisDeprecatedAt,
SunsetAt = LegacyApisSunsetAt,
SuccessorUri = "/v1/lnm/linksets",
Message = "This endpoint is deprecated. Use /v1/lnm/linksets instead for Link-Not-Merge linkset retrieval.",
MigrationGuideUrl = $"{MigrationGuideBaseUrl}#linksets"
};
/// <summary>
/// Legacy /advisories/linksets/export endpoint deprecation info.
/// </summary>
public static readonly DeprecationInfo LegacyAdvisoryLinksetsExport = new()
{
DeprecatedAt = LegacyApisDeprecatedAt,
SunsetAt = LegacyApisSunsetAt,
SuccessorUri = "/v1/lnm/linksets",
Message = "This endpoint is deprecated. Use /v1/lnm/linksets with appropriate pagination for bulk export.",
MigrationGuideUrl = $"{MigrationGuideBaseUrl}#export"
};
/// <summary>
/// Legacy /concelier/observations endpoint deprecation info.
/// </summary>
public static readonly DeprecationInfo LegacyConcelierObservations = new()
{
DeprecatedAt = LegacyApisDeprecatedAt,
SunsetAt = LegacyApisSunsetAt,
SuccessorUri = "/v1/lnm/linksets",
Message = "This endpoint is deprecated. Use /v1/lnm/linksets with includeObservations=true instead.",
MigrationGuideUrl = $"{MigrationGuideBaseUrl}#observations"
};
}

View File

@@ -0,0 +1,97 @@
using System.Globalization;
namespace StellaOps.Concelier.WebService.Deprecation;
/// <summary>
/// Extension methods for adding deprecation headers to HTTP responses.
/// Per CONCELIER-WEB-OAS-63-001.
/// </summary>
public static class DeprecationMiddlewareExtensions
{
/// <summary>
/// Adds deprecation headers to the HTTP response.
/// </summary>
public static void AddDeprecationHeaders(this HttpContext context, DeprecationInfo deprecation)
{
var headers = context.Response.Headers;
// RFC 8594 Deprecation header (HTTP-date format)
headers[DeprecationHeaders.Deprecation] = FormatHttpDate(deprecation.DeprecatedAt);
// Sunset header if set
if (deprecation.SunsetAt.HasValue)
{
headers[DeprecationHeaders.Sunset] = FormatHttpDate(deprecation.SunsetAt.Value);
}
// Link header pointing to successor
headers[DeprecationHeaders.Link] = $"<{deprecation.SuccessorUri}>; rel=\"successor-version\"";
// Custom deprecation notice
headers[DeprecationHeaders.XDeprecationNotice] = deprecation.Message;
// Migration guide URL if available
if (!string.IsNullOrEmpty(deprecation.MigrationGuideUrl))
{
headers[DeprecationHeaders.XDeprecationGuide] = deprecation.MigrationGuideUrl;
}
}
/// <summary>
/// Formats a DateTimeOffset as an HTTP-date (RFC 7231).
/// </summary>
private static string FormatHttpDate(DateTimeOffset date)
{
// HTTP-date format: "Sun, 06 Nov 1994 08:49:37 GMT"
return date.UtcDateTime.ToString("r", CultureInfo.InvariantCulture);
}
}
/// <summary>
/// Middleware that adds deprecation headers to deprecated endpoints.
/// </summary>
public sealed class DeprecationMiddleware
{
private readonly RequestDelegate _next;
private readonly Dictionary<string, DeprecationInfo> _deprecatedPaths;
public DeprecationMiddleware(RequestDelegate next)
{
_next = next;
_deprecatedPaths = new Dictionary<string, DeprecationInfo>(StringComparer.OrdinalIgnoreCase)
{
["/linksets"] = DeprecatedEndpoints.LegacyLinksets,
["/advisories/observations"] = DeprecatedEndpoints.LegacyAdvisoryObservations,
["/advisories/linksets"] = DeprecatedEndpoints.LegacyAdvisoryLinksets,
["/advisories/linksets/export"] = DeprecatedEndpoints.LegacyAdvisoryLinksetsExport,
["/concelier/observations"] = DeprecatedEndpoints.LegacyConcelierObservations
};
}
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value ?? string.Empty;
// Check if this is a deprecated path
if (_deprecatedPaths.TryGetValue(path, out var deprecation))
{
context.AddDeprecationHeaders(deprecation);
}
await _next(context);
}
}
/// <summary>
/// Extension methods for registering the deprecation middleware.
/// </summary>
public static class DeprecationMiddlewareRegistration
{
/// <summary>
/// Adds the deprecation middleware to the pipeline.
/// </summary>
public static IApplicationBuilder UseDeprecationHeaders(this IApplicationBuilder app)
{
return app.UseMiddleware<DeprecationMiddleware>();
}
}

View File

@@ -48,6 +48,7 @@ using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Aoc;
using StellaOps.Concelier.WebService.Deprecation;
using StellaOps.Aoc.AspNetCore.Routing;
using StellaOps.Aoc.AspNetCore.Results;
using StellaOps.Concelier.WebService.Contracts;
@@ -229,6 +230,30 @@ builder.Services.AddConcelierAocGuards();
builder.Services.AddConcelierLinksetMappers();
builder.Services.TryAddSingleton<IAdvisoryLinksetQueryService, AdvisoryLinksetQueryService>();
builder.Services.AddSingleton<LinksetCacheTelemetry>();
builder.Services.AddSingleton<ILinksetCacheTelemetry>(sp => sp.GetRequiredService<LinksetCacheTelemetry>());
// Register read-through cache service for LNM linksets (CONCELIER-AIAI-31-002)
// When Postgres is enabled, uses it as cache backing; otherwise builds from observations directly
builder.Services.AddSingleton<ReadThroughLinksetCacheService>(sp =>
{
var observations = sp.GetRequiredService<IAdvisoryObservationLookup>();
var telemetry = sp.GetRequiredService<ILinksetCacheTelemetry>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
// Get Postgres cache if available (registered by AddConcelierPostgresStorage)
var cacheLookup = sp.GetService<IAdvisoryLinksetStore>() as IAdvisoryLinksetLookup;
var cacheSink = sp.GetService<IAdvisoryLinksetStore>() as IAdvisoryLinksetSink;
return new ReadThroughLinksetCacheService(
observations,
telemetry,
timeProvider,
cacheLookup,
cacheSink);
});
// Use read-through cache as the primary linkset lookup
builder.Services.AddSingleton<IAdvisoryLinksetLookup>(sp => sp.GetRequiredService<ReadThroughLinksetCacheService>());
builder.Services.AddAdvisoryRawServices();
builder.Services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservationQueryService>();
builder.Services.AddSingleton<AdvisoryChunkBuilder>();
@@ -462,6 +487,9 @@ if (authorityConfigured)
app.UseAuthorization();
}
// Deprecation headers for legacy endpoints (CONCELIER-WEB-OAS-63-001)
app.UseDeprecationHeaders();
app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) =>
@@ -848,6 +876,7 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
[FromQuery(Name = "source")] string? source,
[FromServices] IAdvisoryLinksetQueryService queryService,
[FromServices] IAdvisoryObservationQueryService observationQueryService,
[FromServices] IAdvisoryLinksetStore linksetStore,
[FromServices] LinksetCacheTelemetry telemetry,
CancellationToken cancellationToken,
[FromQuery(Name = "includeConflicts")] bool includeConflicts = true,
@@ -872,24 +901,57 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
}
var stopwatch = Stopwatch.StartNew();
var advisoryIds = new[] { advisoryId.Trim() };
var normalizedAdvisoryId = advisoryId.Trim();
var advisoryIds = new[] { normalizedAdvisoryId };
var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() };
var result = await queryService
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant!, advisoryIds, sources, Limit: 1), cancellationToken)
// Phase 1: Try cache lookup first (CONCELIER-AIAI-31-002)
var cached = await linksetStore
.FindByTenantAsync(tenant!, advisoryIds, sources, cursor: null, limit: 1, cancellationToken)
.ConfigureAwait(false);
if (result.Linksets.IsDefaultOrEmpty)
AdvisoryLinkset linkset;
bool fromCache = false;
if (cached.Count > 0)
{
return ConcelierProblemResultFactory.AdvisoryNotFound(context, advisoryId);
// Cache hit
linkset = cached[0];
fromCache = true;
telemetry.RecordHit(tenant, linkset.Source);
}
else
{
// Cache miss - rebuild from query service
var result = await queryService
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant!, advisoryIds, sources, Limit: 1), cancellationToken)
.ConfigureAwait(false);
if (result.Linksets.IsDefaultOrEmpty)
{
return ConcelierProblemResultFactory.AdvisoryNotFound(context, advisoryId);
}
linkset = result.Linksets[0];
// Write to cache
try
{
await linksetStore.UpsertAsync(linkset, cancellationToken).ConfigureAwait(false);
telemetry.RecordWrite(tenant, linkset.Source);
}
catch (Exception ex)
{
// Log but don't fail request on cache write errors
context.RequestServices.GetRequiredService<ILogger<Program>>()
.LogWarning(ex, "Failed to write linkset to cache for {AdvisoryId}", normalizedAdvisoryId);
}
telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
}
var linkset = result.Linksets[0];
var summary = await BuildObservationSummaryAsync(observationQueryService, tenant!, linkset, cancellationToken).ConfigureAwait(false);
var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations, summary);
telemetry.RecordHit(tenant, linkset.Source);
telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations, summary, cached: fromCache);
return Results.Ok(response);
}).WithName("GetLnmLinkset");
@@ -2553,7 +2615,8 @@ LnmLinksetResponse ToLnmResponse(
bool includeTimeline,
bool includeObservations,
LinksetObservationSummary summary,
DataFreshnessInfo? freshness = null)
DataFreshnessInfo? freshness = null,
bool cached = false)
{
var normalized = linkset.Normalized;
var severity = summary.Severity ?? (normalized?.Severities?.FirstOrDefault() is { } severityDict
@@ -2606,7 +2669,7 @@ LnmLinksetResponse ToLnmResponse(
conflicts,
timeline,
normalizedDto,
Cached: false,
Cached: cached,
Remarks: Array.Empty<string>(),
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>(),
Freshness: freshness);

View File

@@ -1,9 +1,14 @@
using System.Diagnostics.Metrics;
using System.Collections.Generic;
using StellaOps.Concelier.Core.Linksets;
namespace StellaOps.Concelier.WebService.Telemetry;
internal sealed class LinksetCacheTelemetry
/// <summary>
/// Telemetry for LNM linkset cache operations.
/// Per CONCELIER-AIAI-31-002.
/// </summary>
internal sealed class LinksetCacheTelemetry : ILinksetCacheTelemetry
{
private static readonly Meter Meter = new("StellaOps.Concelier.Linksets");

View File

@@ -0,0 +1,30 @@
namespace StellaOps.Concelier.Core.Linksets;
/// <summary>
/// Abstraction for linkset cache telemetry.
/// Per CONCELIER-AIAI-31-002.
/// </summary>
public interface ILinksetCacheTelemetry
{
/// <summary>
/// Records a cache hit.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="source">Source vendor (e.g., "ghsa", "nvd").</param>
void RecordHit(string? tenant, string source);
/// <summary>
/// Records a cache write.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="source">Source vendor.</param>
void RecordWrite(string? tenant, string source);
/// <summary>
/// Records a synchronous rebuild latency.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="source">Source vendor.</param>
/// <param name="elapsedMs">Elapsed time in milliseconds.</param>
void RecordRebuild(string? tenant, string source, double elapsedMs);
}

View File

@@ -0,0 +1,306 @@
using System.Collections.Immutable;
using System.Diagnostics;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Models.Observations;
namespace StellaOps.Concelier.Core.Linksets;
/// <summary>
/// Provides read-through caching for LNM linksets.
/// Per CONCELIER-AIAI-31-002.
///
/// Read-through behavior:
/// 1. First queries the configured cache (Postgres via IAdvisoryLinksetLookup)
/// 2. On cache miss, rebuilds from MongoDB observations
/// 3. Stores rebuilt linksets in cache
/// 4. Returns results
/// </summary>
public sealed class ReadThroughLinksetCacheService : IAdvisoryLinksetLookup
{
private readonly IAdvisoryLinksetLookup? _cacheLookup;
private readonly IAdvisoryLinksetSink? _cacheSink;
private readonly IAdvisoryObservationLookup _observations;
private readonly ILinksetCacheTelemetry _telemetry;
private readonly TimeProvider _timeProvider;
public ReadThroughLinksetCacheService(
IAdvisoryObservationLookup observations,
ILinksetCacheTelemetry telemetry,
TimeProvider timeProvider,
IAdvisoryLinksetLookup? cacheLookup = null,
IAdvisoryLinksetSink? cacheSink = null)
{
_observations = observations ?? throw new ArgumentNullException(nameof(observations));
_telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_cacheLookup = cacheLookup;
_cacheSink = cacheSink;
}
public async Task<IReadOnlyList<AdvisoryLinkset>> FindByTenantAsync(
string tenantId,
IEnumerable<string>? advisoryIds,
IEnumerable<string>? sources,
AdvisoryLinksetCursor? cursor,
int limit,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be positive.");
}
var normalizedTenant = tenantId.Trim().ToLowerInvariant();
var advisoryIdSet = advisoryIds?.Select(a => a.Trim()).Where(a => !string.IsNullOrWhiteSpace(a)).ToHashSet(StringComparer.OrdinalIgnoreCase);
var sourceSet = sources?.Select(s => s.Trim()).Where(s => !string.IsNullOrWhiteSpace(s)).ToHashSet(StringComparer.OrdinalIgnoreCase);
// Step 1: Try cache first if available
if (_cacheLookup is not null)
{
var cached = await _cacheLookup
.FindByTenantAsync(normalizedTenant, advisoryIdSet, sourceSet, cursor, limit, cancellationToken)
.ConfigureAwait(false);
if (cached.Count > 0)
{
// Cache hit
foreach (var linkset in cached)
{
_telemetry.RecordHit(normalizedTenant, linkset.Source);
}
return cached;
}
}
// Step 2: Cache miss - rebuild from observations
var stopwatch = Stopwatch.StartNew();
var linksets = await RebuildFromObservationsAsync(
normalizedTenant,
advisoryIdSet,
sourceSet,
cursor,
limit,
cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
if (linksets.Count == 0)
{
return linksets;
}
// Step 3: Store in cache if sink is available
if (_cacheSink is not null)
{
foreach (var linkset in linksets)
{
try
{
await _cacheSink.UpsertAsync(linkset, cancellationToken).ConfigureAwait(false);
_telemetry.RecordWrite(normalizedTenant, linkset.Source);
}
catch
{
// Cache write failure should not fail the request
// Log would be handled by the sink implementation
}
}
}
// Record rebuild metrics
foreach (var linkset in linksets)
{
_telemetry.RecordRebuild(normalizedTenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
}
return linksets;
}
private async Task<IReadOnlyList<AdvisoryLinkset>> RebuildFromObservationsAsync(
string tenant,
IReadOnlySet<string>? advisoryIds,
IReadOnlySet<string>? sources,
AdvisoryLinksetCursor? cursor,
int limit,
CancellationToken cancellationToken)
{
// Query observations for the tenant
// Note: For specific advisoryIds, we'd ideally have a more targeted query
// but the current interface returns all tenant observations
var observations = await _observations
.ListByTenantAsync(tenant, cancellationToken)
.ConfigureAwait(false);
if (observations.Count == 0)
{
return Array.Empty<AdvisoryLinkset>();
}
// Filter by advisoryId and source if specified
var filtered = observations.AsEnumerable();
if (advisoryIds is { Count: > 0 })
{
filtered = filtered.Where(o => advisoryIds.Contains(o.Upstream.UpstreamId));
}
if (sources is { Count: > 0 })
{
filtered = filtered.Where(o => sources.Contains(o.Source.Vendor));
}
// Group by (source, advisoryId) to build linksets
var groups = filtered
.GroupBy(
o => (o.Source.Vendor, o.Upstream.UpstreamId),
new VendorUpstreamComparer())
.ToList();
var now = _timeProvider.GetUtcNow();
var linksets = new List<AdvisoryLinkset>(groups.Count);
foreach (var group in groups)
{
var observationIds = group
.Select(o => o.ObservationId)
.Distinct(StringComparer.Ordinal)
.ToImmutableArray();
var createdAt = group.Max(o => o.CreatedAt);
var normalized = BuildNormalized(group);
var provenance = BuildProvenance(group, now);
var linkset = new AdvisoryLinkset(
tenant,
group.Key.Vendor,
group.Key.UpstreamId,
observationIds,
normalized,
provenance,
ComputeConfidence(group),
DetectConflicts(group),
createdAt,
null);
linksets.Add(linkset);
}
// Apply cursor-based pagination
var ordered = linksets
.OrderByDescending(ls => ls.CreatedAt)
.ThenBy(ls => ls.AdvisoryId, StringComparer.Ordinal)
.AsEnumerable();
if (cursor is not null)
{
ordered = ordered.Where(ls =>
ls.CreatedAt < cursor.CreatedAt ||
(ls.CreatedAt == cursor.CreatedAt &&
string.Compare(ls.AdvisoryId, cursor.AdvisoryId, StringComparison.Ordinal) > 0));
}
return ordered.Take(limit).ToList();
}
private static AdvisoryLinksetNormalized? BuildNormalized(IEnumerable<AdvisoryObservation> observations)
{
var purls = observations
.SelectMany(o => o.Linkset.Purls.IsDefaultOrEmpty ? Enumerable.Empty<string>() : o.Linkset.Purls)
.Distinct(StringComparer.Ordinal)
.OrderBy(p => p, StringComparer.Ordinal)
.ToImmutableArray();
var cpes = observations
.SelectMany(o => o.Linkset.Cpes.IsDefaultOrEmpty ? Enumerable.Empty<string>() : o.Linkset.Cpes)
.Distinct(StringComparer.Ordinal)
.OrderBy(c => c, StringComparer.Ordinal)
.ToImmutableArray();
if (purls.Length == 0 && cpes.Length == 0)
{
return null;
}
return new AdvisoryLinksetNormalized(
purls.Length > 0 ? purls : null,
cpes.Length > 0 ? cpes : null,
null,
null,
null);
}
private static AdvisoryLinksetProvenance BuildProvenance(
IEnumerable<AdvisoryObservation> observations,
DateTimeOffset now)
{
var hashes = observations
.Select(o => o.ObservationId)
.Distinct(StringComparer.Ordinal)
.OrderBy(h => h, StringComparer.Ordinal)
.ToImmutableArray();
return new AdvisoryLinksetProvenance(
hashes,
"read-through-cache",
null);
}
private static double ComputeConfidence(IEnumerable<AdvisoryObservation> observations)
{
// Simple confidence: based on number of corroborating observations
var count = observations.Count();
return count switch
{
1 => 0.5,
2 => 0.7,
3 => 0.85,
_ => Math.Min(1.0, 0.85 + (count - 3) * 0.03)
};
}
private static IReadOnlyList<AdvisoryLinksetConflict> DetectConflicts(
IEnumerable<AdvisoryObservation> observations)
{
var conflicts = new List<AdvisoryLinksetConflict>();
var obsList = observations.ToList();
if (obsList.Count <= 1)
{
return conflicts;
}
// Detect PURL conflicts (same package, different versions mentioned)
var purlsByPackage = obsList
.SelectMany(o => o.Linkset.Purls.IsDefaultOrEmpty ? Enumerable.Empty<string>() : o.Linkset.Purls)
.Where(p => p.Contains('@'))
.GroupBy(p => p.Split('@')[0], StringComparer.Ordinal)
.Where(g => g.Distinct(StringComparer.Ordinal).Count() > 1);
foreach (var group in purlsByPackage)
{
var values = group.Distinct(StringComparer.Ordinal).ToImmutableArray();
conflicts.Add(new AdvisoryLinksetConflict(
"purl_version",
"Multiple versions specified for same package",
values,
null));
}
return conflicts;
}
private sealed class VendorUpstreamComparer : IEqualityComparer<(string Vendor, string UpstreamId)>
{
public bool Equals((string Vendor, string UpstreamId) x, (string Vendor, string UpstreamId) y)
=> StringComparer.OrdinalIgnoreCase.Equals(x.Vendor, y.Vendor)
&& StringComparer.Ordinal.Equals(x.UpstreamId, y.UpstreamId);
public int GetHashCode((string Vendor, string UpstreamId) obj)
{
var hash = new HashCode();
hash.Add(obj.Vendor, StringComparer.OrdinalIgnoreCase);
hash.Add(obj.UpstreamId, StringComparer.Ordinal);
return hash.ToHashCode();
}
}
}

View File

@@ -0,0 +1,139 @@
using System.Collections.Immutable;
using StellaOps.Concelier.Core.Linksets;
namespace StellaOps.Concelier.WebService.Tests.Cache;
/// <summary>
/// Tests for LNM linkset cache read-through behavior.
/// Per CONCELIER-AIAI-31-002.
/// </summary>
public sealed class LinksetCacheReadThroughTests
{
[Fact]
public void AdvisoryLinkset_CanBeCreatedForCache()
{
var linkset = new AdvisoryLinkset(
TenantId: "test-tenant",
Source: "nvd",
AdvisoryId: "CVE-2024-0001",
ObservationIds: ImmutableArray.Create("obs-1", "obs-2"),
Normalized: new AdvisoryLinksetNormalized(
Purls: new[] { "pkg:npm/lodash@4.17.20" },
Cpes: new[] { "cpe:2.3:a:lodash:lodash:*" },
Versions: new[] { "4.17.20" },
Ranges: null,
Severities: null),
Provenance: new AdvisoryLinksetProvenance(
ObservationHashes: new[] { "sha256:abc123" },
ToolVersion: "1.0.0",
PolicyHash: null),
Confidence: 0.95,
Conflicts: null,
CreatedAt: DateTimeOffset.UtcNow,
BuiltByJobId: "job-123");
Assert.Equal("test-tenant", linkset.TenantId);
Assert.Equal("nvd", linkset.Source);
Assert.Equal("CVE-2024-0001", linkset.AdvisoryId);
Assert.Equal(2, linkset.ObservationIds.Length);
}
[Fact]
public void AdvisoryLinkset_WithConflicts_CanBeCreated()
{
var conflicts = new List<AdvisoryLinksetConflict>
{
new AdvisoryLinksetConflict(
Field: "severity",
Reason: "severity-mismatch",
Values: new[] { "critical", "high" },
SourceIds: new[] { "nvd", "github" })
};
var linkset = new AdvisoryLinkset(
TenantId: "test-tenant",
Source: "aggregated",
AdvisoryId: "CVE-2024-0002",
ObservationIds: ImmutableArray.Create("obs-1"),
Normalized: null,
Provenance: null,
Confidence: 0.72,
Conflicts: conflicts,
CreatedAt: DateTimeOffset.UtcNow,
BuiltByJobId: null);
Assert.NotNull(linkset.Conflicts);
Assert.Single(linkset.Conflicts);
Assert.Equal("severity", linkset.Conflicts[0].Field);
Assert.Equal("severity-mismatch", linkset.Conflicts[0].Reason);
}
[Fact]
public void AdvisoryLinksetNormalized_ContainsExpectedFields()
{
var normalized = new AdvisoryLinksetNormalized(
Purls: new[] { "pkg:npm/example@1.0.0", "pkg:npm/example@1.0.1" },
Cpes: new[] { "cpe:2.3:a:example:*" },
Versions: new[] { "1.0.0", "1.0.1" },
Ranges: new[]
{
new Dictionary<string, object?>
{
["type"] = "SEMVER",
["events"] = new[]
{
new Dictionary<string, object?> { ["introduced"] = "0" },
new Dictionary<string, object?> { ["fixed"] = "1.0.2" }
}
}
},
Severities: new[]
{
new Dictionary<string, object?>
{
["type"] = "CVSS_V3",
["score"] = 9.8
}
});
Assert.NotNull(normalized.Purls);
Assert.Equal(2, normalized.Purls.Count);
Assert.NotNull(normalized.Versions);
Assert.Equal(2, normalized.Versions.Count);
Assert.NotNull(normalized.Ranges);
Assert.Single(normalized.Ranges);
}
[Fact]
public void AdvisoryLinksetProvenance_ContainsHashes()
{
var provenance = new AdvisoryLinksetProvenance(
ObservationHashes: new[] { "sha256:abc123", "sha256:def456" },
ToolVersion: "concelier-v1.0.0",
PolicyHash: "sha256:policy789");
Assert.Equal(2, provenance.ObservationHashes!.Count);
Assert.Equal("concelier-v1.0.0", provenance.ToolVersion);
Assert.Equal("sha256:policy789", provenance.PolicyHash);
}
[Fact]
public void CacheKey_DeterministicFromLinkset()
{
// Cache key should be deterministic: {tenant}:{advisoryId}:{source}
var linkset = new AdvisoryLinkset(
TenantId: "acme",
Source: "nvd",
AdvisoryId: "CVE-2024-0001",
ObservationIds: ImmutableArray<string>.Empty,
Normalized: null,
Provenance: null,
Confidence: null,
Conflicts: null,
CreatedAt: DateTimeOffset.UtcNow,
BuiltByJobId: null);
var cacheKey = $"{linkset.TenantId}:{linkset.AdvisoryId}:{linkset.Source}";
Assert.Equal("acme:CVE-2024-0001:nvd", cacheKey);
}
}

View File

@@ -0,0 +1,117 @@
using StellaOps.Concelier.WebService.Deprecation;
namespace StellaOps.Concelier.WebService.Tests.Deprecation;
/// <summary>
/// Tests for deprecation headers infrastructure.
/// Per CONCELIER-WEB-OAS-63-001.
/// </summary>
public sealed class DeprecationHeadersTests
{
[Fact]
public void DeprecationInfo_LegacyLinksets_HasCorrectValues()
{
var info = DeprecatedEndpoints.LegacyLinksets;
Assert.Equal(DeprecatedEndpoints.LegacyApisDeprecatedAt, info.DeprecatedAt);
Assert.Equal(DeprecatedEndpoints.LegacyApisSunsetAt, info.SunsetAt);
Assert.Equal("/v1/lnm/linksets", info.SuccessorUri);
Assert.NotEmpty(info.Message);
Assert.NotNull(info.MigrationGuideUrl);
}
[Fact]
public void DeprecationInfo_LegacyAdvisoryObservations_HasCorrectValues()
{
var info = DeprecatedEndpoints.LegacyAdvisoryObservations;
Assert.Equal(DeprecatedEndpoints.LegacyApisDeprecatedAt, info.DeprecatedAt);
Assert.Equal(DeprecatedEndpoints.LegacyApisSunsetAt, info.SunsetAt);
Assert.Equal("/v1/lnm/linksets", info.SuccessorUri);
Assert.Contains("includeObservations", info.Message);
}
[Fact]
public void DeprecationInfo_LegacyAdvisoryLinksets_HasCorrectValues()
{
var info = DeprecatedEndpoints.LegacyAdvisoryLinksets;
Assert.Equal(DeprecatedEndpoints.LegacyApisDeprecatedAt, info.DeprecatedAt);
Assert.Equal("/v1/lnm/linksets", info.SuccessorUri);
}
[Fact]
public void DeprecationInfo_LegacyAdvisoryLinksetsExport_HasCorrectValues()
{
var info = DeprecatedEndpoints.LegacyAdvisoryLinksetsExport;
Assert.Equal(DeprecatedEndpoints.LegacyApisDeprecatedAt, info.DeprecatedAt);
Assert.Equal("/v1/lnm/linksets", info.SuccessorUri);
Assert.Contains("pagination", info.Message);
}
[Fact]
public void DeprecationInfo_LegacyConcelierObservations_HasCorrectValues()
{
var info = DeprecatedEndpoints.LegacyConcelierObservations;
Assert.Equal(DeprecatedEndpoints.LegacyApisDeprecatedAt, info.DeprecatedAt);
Assert.Equal("/v1/lnm/linksets", info.SuccessorUri);
}
[Fact]
public void AllDeprecatedEndpoints_HaveMigrationGuides()
{
var endpoints = new[]
{
DeprecatedEndpoints.LegacyLinksets,
DeprecatedEndpoints.LegacyAdvisoryObservations,
DeprecatedEndpoints.LegacyAdvisoryLinksets,
DeprecatedEndpoints.LegacyAdvisoryLinksetsExport,
DeprecatedEndpoints.LegacyConcelierObservations
};
foreach (var endpoint in endpoints)
{
Assert.NotNull(endpoint.MigrationGuideUrl);
Assert.StartsWith(DeprecatedEndpoints.MigrationGuideBaseUrl, endpoint.MigrationGuideUrl);
}
}
[Fact]
public void AllDeprecatedEndpoints_HaveSunsetDates()
{
var endpoints = new[]
{
DeprecatedEndpoints.LegacyLinksets,
DeprecatedEndpoints.LegacyAdvisoryObservations,
DeprecatedEndpoints.LegacyAdvisoryLinksets,
DeprecatedEndpoints.LegacyAdvisoryLinksetsExport,
DeprecatedEndpoints.LegacyConcelierObservations
};
foreach (var endpoint in endpoints)
{
Assert.NotNull(endpoint.SunsetAt);
Assert.True(endpoint.SunsetAt > endpoint.DeprecatedAt);
}
}
[Fact]
public void SunsetDate_IsAfterDeprecationDate()
{
Assert.True(
DeprecatedEndpoints.LegacyApisSunsetAt > DeprecatedEndpoints.LegacyApisDeprecatedAt,
"Sunset date must be after deprecation date");
}
[Fact]
public void DeprecationHeaders_ConstantsAreDefined()
{
Assert.Equal("Deprecation", DeprecationHeaders.Deprecation);
Assert.Equal("Sunset", DeprecationHeaders.Sunset);
Assert.Equal("Link", DeprecationHeaders.Link);
Assert.Equal("X-Deprecation-Notice", DeprecationHeaders.XDeprecationNotice);
Assert.Equal("X-Deprecation-Guide", DeprecationHeaders.XDeprecationGuide);
}
}