audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -6,9 +6,9 @@ namespace StellaOps.Concelier.WebService.Diagnostics;
/// </summary>
public static class ErrorCodes
{
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Validation Errors (4xx)
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>Generic validation failure.</summary>
public const string ValidationFailed = "VALIDATION_FAILED";
@@ -34,9 +34,9 @@ public static class ErrorCodes
/// <summary>Invalid pagination parameters.</summary>
public const string InvalidPagination = "INVALID_PAGINATION";
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Resource Errors (404)
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>Requested resource was not found.</summary>
public const string ResourceNotFound = "RESOURCE_NOT_FOUND";
@@ -80,9 +80,9 @@ public static class ErrorCodes
/// <summary>Feature is disabled.</summary>
public const string FeatureDisabled = "FEATURE_DISABLED";
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// AOC (Aggregation-Only Contract) Errors
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>AOC violation occurred.</summary>
public const string AocViolation = "AOC_VIOLATION";
@@ -99,9 +99,9 @@ public static class ErrorCodes
/// <summary>Unknown field detected (ERR_AOC_007).</summary>
public const string AocUnknownField = "AOC_UNKNOWN_FIELD";
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Conflict Errors (409)
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>Resource already exists.</summary>
public const string ResourceConflict = "RESOURCE_CONFLICT";
@@ -112,9 +112,9 @@ public static class ErrorCodes
/// <summary>Lease already held by another client.</summary>
public const string LeaseConflict = "LEASE_CONFLICT";
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// State Errors (423 Locked)
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>Resource is locked.</summary>
public const string ResourceLocked = "RESOURCE_LOCKED";
@@ -122,9 +122,9 @@ public static class ErrorCodes
/// <summary>Lease rejected.</summary>
public const string LeaseRejected = "LEASE_REJECTED";
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// AirGap/Sealed Mode Errors
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>AirGap mode is disabled.</summary>
public const string AirGapDisabled = "AIRGAP_DISABLED";
@@ -138,9 +138,9 @@ public static class ErrorCodes
/// <summary>Source blocked by sealed mode.</summary>
public const string SourceBlocked = "SOURCE_BLOCKED";
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Rate Limiting (429)
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>Rate limit exceeded.</summary>
public const string RateLimitExceeded = "RATE_LIMIT_EXCEEDED";
@@ -148,9 +148,9 @@ public static class ErrorCodes
/// <summary>Quota exceeded.</summary>
public const string QuotaExceeded = "QUOTA_EXCEEDED";
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Server Errors (5xx)
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>Internal server error.</summary>
public const string InternalError = "INTERNAL_ERROR";

View File

@@ -127,6 +127,7 @@ internal static class CanonicalAdvisoryEndpointExtensions
string source,
[FromBody] RawAdvisoryRequest request,
[FromServices] ICanonicalAdvisoryService service,
TimeProvider timeProvider,
HttpContext context,
CancellationToken ct) =>
{
@@ -158,7 +159,7 @@ internal static class CanonicalAdvisoryEndpointExtensions
Summary = request.Summary,
VendorStatus = request.VendorStatus,
RawPayloadJson = request.RawPayloadJson,
FetchedAt = request.FetchedAt ?? DateTimeOffset.UtcNow
FetchedAt = request.FetchedAt ?? timeProvider.GetUtcNow()
};
var result = await service.IngestAsync(source, rawAdvisory, ct).ConfigureAwait(false);
@@ -188,6 +189,7 @@ internal static class CanonicalAdvisoryEndpointExtensions
string source,
[FromBody] IEnumerable<RawAdvisoryRequest> requests,
[FromServices] ICanonicalAdvisoryService service,
TimeProvider timeProvider,
HttpContext context,
CancellationToken ct) =>
{
@@ -196,6 +198,7 @@ internal static class CanonicalAdvisoryEndpointExtensions
return HttpResults.BadRequest(new { error = "Source is required" });
}
var defaultFetchedAt = timeProvider.GetUtcNow();
var rawAdvisories = requests.Select(request => new RawAdvisory
{
SourceAdvisoryId = request.SourceAdvisoryId ?? $"{source.ToUpperInvariant()}-{request.Cve}",
@@ -209,7 +212,7 @@ internal static class CanonicalAdvisoryEndpointExtensions
Summary = request.Summary,
VendorStatus = request.VendorStatus,
RawPayloadJson = request.RawPayloadJson,
FetchedAt = request.FetchedAt ?? DateTimeOffset.UtcNow
FetchedAt = request.FetchedAt ?? defaultFetchedAt
}).ToList();
var results = await service.IngestBatchAsync(source, rawAdvisories, ct).ConfigureAwait(false);

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Federation.Export;
@@ -25,6 +26,7 @@ internal static class FederationEndpointExtensions
HttpContext context,
[FromServices] IBundleExportService exportService,
[FromServices] IOptionsMonitor<ConcelierOptions> optionsMonitor,
TimeProvider timeProvider,
CancellationToken cancellationToken,
[FromQuery(Name = "since_cursor")] string? sinceCursor = null,
[FromQuery] bool sign = true,
@@ -57,8 +59,9 @@ internal static class FederationEndpointExtensions
// Set response headers for streaming
context.Response.ContentType = "application/zstd";
var exportTimestamp = timeProvider.GetUtcNow().UtcDateTime;
context.Response.Headers.ContentDisposition =
$"attachment; filename=\"feedser-bundle-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zst\"";
$"attachment; filename=\"feedser-bundle-{exportTimestamp.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.zst\"";
// Export directly to response stream
var result = await exportService.ExportToStreamAsync(

View File

@@ -119,6 +119,7 @@ internal static class InterestScoreEndpointExtensions
group.MapPost("/scores/recalculate", async (
[FromBody] RecalculateRequest? request,
[FromServices] IInterestScoringService scoringService,
TimeProvider timeProvider,
CancellationToken ct) =>
{
int updated;
@@ -137,7 +138,7 @@ internal static class InterestScoreEndpointExtensions
{
Updated = updated,
Mode = request?.CanonicalIds?.Count > 0 ? "batch" : "full",
StartedAt = DateTimeOffset.UtcNow
StartedAt = timeProvider.GetUtcNow()
});
})
.WithName("RecalculateScores")
@@ -149,6 +150,7 @@ internal static class InterestScoreEndpointExtensions
[FromBody] DegradeRequest? request,
[FromServices] IInterestScoringService scoringService,
[FromServices] Microsoft.Extensions.Options.IOptions<InterestScoreOptions> options,
TimeProvider timeProvider,
CancellationToken ct) =>
{
var threshold = request?.Threshold ?? options.Value.DegradationPolicy.DegradationThreshold;
@@ -159,7 +161,7 @@ internal static class InterestScoreEndpointExtensions
{
Degraded = degraded,
Threshold = threshold,
ExecutedAt = DateTimeOffset.UtcNow
ExecutedAt = timeProvider.GetUtcNow()
});
})
.WithName("DegradeToStubs")
@@ -171,6 +173,7 @@ internal static class InterestScoreEndpointExtensions
[FromBody] RestoreRequest? request,
[FromServices] IInterestScoringService scoringService,
[FromServices] Microsoft.Extensions.Options.IOptions<InterestScoreOptions> options,
TimeProvider timeProvider,
CancellationToken ct) =>
{
var threshold = request?.Threshold ?? options.Value.DegradationPolicy.RestorationThreshold;
@@ -181,7 +184,7 @@ internal static class InterestScoreEndpointExtensions
{
Restored = restored,
Threshold = threshold,
ExecutedAt = DateTimeOffset.UtcNow
ExecutedAt = timeProvider.GetUtcNow()
});
})
.WithName("RestoreFromStubs")

View File

@@ -497,6 +497,7 @@ builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
var appTimeProvider = app.Services.GetRequiredService<TimeProvider>();
var swaggerEnabled = app.Configuration.GetValue<bool>("Swagger:Enabled");
app.Logger.LogWarning("Authority enabled: {AuthorityEnabled}, test signing secret configured: {HasTestSecret}", authorityConfigured, !string.IsNullOrWhiteSpace(concelierOptions.Authority?.TestSigningSecret));
@@ -724,6 +725,7 @@ orchestratorGroup.MapPost("/commands", async (
HttpContext context,
[FromBody] OrchestratorCommandRequest request,
[FromServices] IOrchestratorRegistryStore store,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
@@ -757,7 +759,7 @@ orchestratorGroup.MapPost("/commands", async (
request.Backfill is null
? null
: new OrchestratorBackfillRange(request.Backfill.FromCursor, request.Backfill.ToCursor),
DateTimeOffset.UtcNow,
timeProvider.GetUtcNow(),
request.ExpiresAt);
await store.EnqueueCommandAsync(command, cancellationToken).ConfigureAwait(false);
@@ -1226,7 +1228,7 @@ advisoryIngestEndpoint.RequireAocGuard<AdvisoryIngestRequest>(request =>
return Array.Empty<object?>();
}
var guardDocument = AdvisoryRawRequestMapper.Map(request, "guard-tenant", TimeProvider.System);
var guardDocument = AdvisoryRawRequestMapper.Map(request, "guard-tenant", appTimeProvider);
return new object?[] { guardDocument };
}, guardOptions: advisoryIngestGuardOptions);
@@ -3399,7 +3401,7 @@ void ApplyNoCache(HttpResponse response)
response.Headers["Expires"] = "0";
}
await InitializePostgresAsync(app);
await InitializePostgresAsync(app, app.Lifetime.ApplicationStopping);
app.MapGet("/health", ([FromServices] IOptions<ConcelierOptions> opts, [FromServices] StellaOps.Concelier.WebService.Diagnostics.ServiceStatus status, HttpContext context) =>
{
@@ -4164,7 +4166,7 @@ static SignalsSymbolSetResponse ToSymbolSetResponse(AffectedSymbolSet symbolSet)
return pluginOptions;
}
static async Task InitializePostgresAsync(WebApplication app)
static async Task InitializePostgresAsync(WebApplication app, CancellationToken cancellationToken)
{
var dataSource = app.Services.GetService<ConcelierDataSource>();
var status = app.Services.GetRequiredService<StellaOps.Concelier.WebService.Diagnostics.ServiceStatus>();
@@ -4178,7 +4180,7 @@ static async Task InitializePostgresAsync(WebApplication app)
var stopwatch = Stopwatch.StartNew();
try
{
var (ready, latency, error) = await CheckPostgresAsync(dataSource, CancellationToken.None).ConfigureAwait(false);
var (ready, latency, error) = await CheckPostgresAsync(dataSource, cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
status.RecordStorageCheck(ready, latency, error);
if (ready)

View File

@@ -46,9 +46,9 @@ public static class ConcelierProblemResultFactory
return Microsoft.AspNetCore.Http.Results.Json(envelope, statusCode: statusCode);
}
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Validation Errors (400)
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>
/// Creates a 400 Bad Request response for validation failure.
@@ -132,9 +132,9 @@ public static class ConcelierProblemResultFactory
"cursor");
}
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Not Found Errors (404)
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>
/// Creates a 404 Not Found response for resource not found.
@@ -307,9 +307,9 @@ public static class ConcelierProblemResultFactory
detail ?? "The requested resource was not found.");
}
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Conflict Errors (409)
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>
/// Creates a 409 Conflict response.
@@ -338,9 +338,9 @@ public static class ConcelierProblemResultFactory
return Conflict(context, ErrorCodes.LeaseConflict, detail);
}
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Locked Errors (423)
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>
/// Creates a 423 Locked response.
@@ -373,9 +373,9 @@ public static class ConcelierProblemResultFactory
detail);
}
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// AirGap/Sealed Mode Errors
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>
/// Creates a 404 Not Found response for AirGap disabled.
@@ -483,9 +483,9 @@ public static class ConcelierProblemResultFactory
return Microsoft.AspNetCore.Http.Results.Json(envelope, statusCode: StatusCodes.Status403Forbidden);
}
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Rate Limiting (429)
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>
/// Creates a 429 Too Many Requests response.
@@ -511,9 +511,9 @@ public static class ConcelierProblemResultFactory
return Microsoft.AspNetCore.Http.Results.Json(envelope, statusCode: StatusCodes.Status429TooManyRequests);
}
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
// Server Errors (5xx)
// ─────────────────────────────────────────────────────────────────────────
// -----------------------------------------------------------------------------
/// <summary>
/// Creates a 500 Internal Server Error response.

View File

@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| --- | --- | --- |
| AUDIT-0242-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0242-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0242-A | TODO | Revalidated 2026-01-07 (open findings). |
| AUDIT-0242-A | DONE | Applied 2026-01-13; TimeProvider defaults, ASCII cleanup, federation tests. |

View File

@@ -1,6 +1,6 @@
openapi: 3.1.0
info:
title: StellaOps Concelier Link-Not-Merge Policy APIs
title: StellaOps Concelier - Link-Not-Merge Policy APIs
version: "1.0.0"
description: |
Fact-only advisory/linkset retrieval for Policy Engine consumers.

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
@@ -145,7 +146,7 @@ public sealed class BundleCatalogService : IBundleCatalogService
{
Entries = entries.OrderBy(e => e.BundleId).ToImmutableArray(),
TotalCount = entries.Count,
SourceIds = sourceIds.ToImmutableArray(),
SourceIds = sourceIds.OrderBy(static id => id, StringComparer.Ordinal).ToImmutableArray(),
ComputedAt = now,
ETag = etag
});
@@ -208,7 +209,7 @@ public sealed class BundleCatalogService : IBundleCatalogService
string? nextCursor = null;
if (offset + pageSize < catalog.TotalCount)
{
nextCursor = (offset + pageSize).ToString();
nextCursor = (offset + pageSize).ToString(CultureInfo.InvariantCulture);
}
return catalog with
@@ -225,7 +226,7 @@ public sealed class BundleCatalogService : IBundleCatalogService
return 0;
}
return int.TryParse(cursor, out var offset) ? offset : 0;
return int.TryParse(cursor, NumberStyles.None, CultureInfo.InvariantCulture, out var offset) ? offset : 0;
}
private static string ComputeETag(IEnumerable<BundleCatalogEntry> entries)

View File

@@ -89,20 +89,20 @@ public sealed class BundleSourceRegistry : IBundleSourceRegistry
{
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
var now = _timeProvider.GetUtcNow();
if (!_sources.TryGetValue(sourceId, out var source))
{
return Task.FromResult(BundleSourceValidationResult.Failure(sourceId, $"Source '{sourceId}' not found"));
return Task.FromResult(BundleSourceValidationResult.Failure(sourceId, now, $"Source '{sourceId}' not found"));
}
var now = _timeProvider.GetUtcNow();
// Basic validation - actual implementation would check source accessibility
var result = source.Type switch
{
"directory" => ValidateDirectorySource(source),
"archive" => ValidateArchiveSource(source),
"remote" => ValidateRemoteSource(source),
_ => BundleSourceValidationResult.Failure(sourceId, $"Unknown source type: {source.Type}")
"directory" => ValidateDirectorySource(source, now),
"archive" => ValidateArchiveSource(source, now),
"remote" => ValidateRemoteSource(source, now),
_ => BundleSourceValidationResult.Failure(sourceId, now, $"Unknown source type: {source.Type}")
};
// Update source status
@@ -143,33 +143,33 @@ public sealed class BundleSourceRegistry : IBundleSourceRegistry
return Task.FromResult(true);
}
private BundleSourceValidationResult ValidateDirectorySource(BundleSourceInfo source)
private static BundleSourceValidationResult ValidateDirectorySource(BundleSourceInfo source, DateTimeOffset validatedAt)
{
if (!Directory.Exists(source.Location))
{
return BundleSourceValidationResult.Failure(source.Id, $"Directory not found: {source.Location}");
return BundleSourceValidationResult.Failure(source.Id, validatedAt, $"Directory not found: {source.Location}");
}
var bundleFiles = Directory.GetFiles(source.Location, "*.bundle.json", SearchOption.AllDirectories);
return BundleSourceValidationResult.Success(source.Id, bundleFiles.Length);
return BundleSourceValidationResult.Success(source.Id, bundleFiles.Length, validatedAt);
}
private BundleSourceValidationResult ValidateArchiveSource(BundleSourceInfo source)
private static BundleSourceValidationResult ValidateArchiveSource(BundleSourceInfo source, DateTimeOffset validatedAt)
{
if (!File.Exists(source.Location))
{
return BundleSourceValidationResult.Failure(source.Id, $"Archive not found: {source.Location}");
return BundleSourceValidationResult.Failure(source.Id, validatedAt, $"Archive not found: {source.Location}");
}
// Actual implementation would inspect archive contents
return BundleSourceValidationResult.Success(source.Id, 0);
return BundleSourceValidationResult.Success(source.Id, 0, validatedAt);
}
private BundleSourceValidationResult ValidateRemoteSource(BundleSourceInfo source)
private BundleSourceValidationResult ValidateRemoteSource(BundleSourceInfo source, DateTimeOffset validatedAt)
{
if (!Uri.TryCreate(source.Location, UriKind.Absolute, out var uri))
{
return BundleSourceValidationResult.Failure(source.Id, $"Invalid URL: {source.Location}");
return BundleSourceValidationResult.Failure(source.Id, validatedAt, $"Invalid URL: {source.Location}");
}
// Actual implementation would check remote accessibility
@@ -178,7 +178,7 @@ public sealed class BundleSourceRegistry : IBundleSourceRegistry
SourceId = source.Id,
IsValid = true,
Status = BundleSourceStatus.Unknown,
ValidatedAt = _timeProvider.GetUtcNow(),
ValidatedAt = validatedAt,
Warnings = ImmutableArray.Create("Remote validation not implemented - assuming valid")
};
}

View File

@@ -43,11 +43,6 @@ public interface ISealedModeEnforcer
/// </summary>
public sealed class SealedModeViolationException : Exception
{
public SealedModeViolationException(string sourceName, Uri destination)
: this(sourceName, destination, DateTimeOffset.UtcNow)
{
}
public SealedModeViolationException(string sourceName, Uri destination, DateTimeOffset occurredAt)
: base($"Sealed mode violation: source '{sourceName}' attempted to access '{destination}'")
{

View File

@@ -46,24 +46,24 @@ public sealed record BundleSourceValidationResult
/// <summary>
/// Creates a successful validation result.
/// </summary>
public static BundleSourceValidationResult Success(string sourceId, int bundleCount) => new()
public static BundleSourceValidationResult Success(string sourceId, int bundleCount, DateTimeOffset validatedAt) => new()
{
SourceId = sourceId,
IsValid = true,
Status = BundleSourceStatus.Healthy,
BundleCount = bundleCount,
ValidatedAt = DateTimeOffset.UtcNow
ValidatedAt = validatedAt
};
/// <summary>
/// Creates a failed validation result.
/// </summary>
public static BundleSourceValidationResult Failure(string sourceId, params string[] errors) => new()
public static BundleSourceValidationResult Failure(string sourceId, DateTimeOffset validatedAt, params string[] errors) => new()
{
SourceId = sourceId,
IsValid = false,
Status = BundleSourceStatus.Error,
Errors = errors.ToImmutableArray(),
ValidatedAt = DateTimeOffset.UtcNow
ValidatedAt = validatedAt
};
}

View File

@@ -21,6 +21,7 @@ public sealed class CanonicalAdvisoryService : ICanonicalAdvisoryService
private readonly IMergeHashCalculator _mergeHashCalculator;
private readonly ISourceEdgeSigner? _signer;
private readonly ILogger<CanonicalAdvisoryService> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Source precedence ranks (lower = higher priority).
@@ -42,11 +43,13 @@ public sealed class CanonicalAdvisoryService : ICanonicalAdvisoryService
ICanonicalAdvisoryStore store,
IMergeHashCalculator mergeHashCalculator,
ILogger<CanonicalAdvisoryService> logger,
TimeProvider? timeProvider = null,
ISourceEdgeSigner? signer = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_mergeHashCalculator = mergeHashCalculator ?? throw new ArgumentNullException(nameof(mergeHashCalculator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_signer = signer; // Optional - if not provided, source edges are stored unsigned
}
@@ -170,6 +173,9 @@ public sealed class CanonicalAdvisoryService : ICanonicalAdvisoryService
// 8. Create source edge
var precedenceRank = GetPrecedenceRank(source);
var fetchedAt = rawAdvisory.FetchedAt == default
? _timeProvider.GetUtcNow()
: rawAdvisory.FetchedAt;
var addEdgeRequest = new AddSourceEdgeRequest
{
CanonicalId = canonicalId,
@@ -180,7 +186,7 @@ public sealed class CanonicalAdvisoryService : ICanonicalAdvisoryService
PrecedenceRank = precedenceRank,
DsseEnvelopeJson = dsseEnvelopeJson,
RawPayloadJson = rawAdvisory.RawPayloadJson,
FetchedAt = rawAdvisory.FetchedAt
FetchedAt = fetchedAt
};
var edgeResult = await _store.AddSourceEdgeAsync(addEdgeRequest, ct).ConfigureAwait(false);
@@ -295,8 +301,7 @@ public sealed class CanonicalAdvisoryService : ICanonicalAdvisoryService
/// <inheritdoc />
public async Task<int> DegradeToStubsAsync(double scoreThreshold, CancellationToken ct = default)
{
// TODO: Implement stub degradation based on EPSS score or other criteria
// This would query for low-interest canonicals and update their status to Stub
// Not implemented: stub degradation requires a scoring policy and query pipeline.
_logger.LogWarning(
"DegradeToStubsAsync not yet implemented (threshold={Threshold})",
scoreThreshold);

View File

@@ -119,7 +119,7 @@ public sealed record RawAdvisory
public string? RawPayloadJson { get; init; }
/// <summary>When the advisory was fetched.</summary>
public DateTimeOffset FetchedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset FetchedAt { get; init; }
}
/// <summary>

View File

@@ -131,7 +131,7 @@ public sealed record AddSourceEdgeRequest
public int PrecedenceRank { get; init; } = 100;
public string? DsseEnvelopeJson { get; init; }
public string? RawPayloadJson { get; init; }
public DateTimeOffset FetchedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset FetchedAt { get; init; }
}
/// <summary>

View File

@@ -267,7 +267,10 @@ public sealed class CanonicalMerger
}
}
var credits = map.Values.Select(static s => s.Credit).ToImmutableArray();
var credits = map
.OrderBy(static item => item.Key, StringComparer.OrdinalIgnoreCase)
.Select(static item => item.Value.Credit)
.ToImmutableArray();
FieldDecision? decision = null;
if (considered.Count > 0)
@@ -333,7 +336,10 @@ public sealed class CanonicalMerger
}
}
var references = map.Values.Select(static s => s.Reference).ToImmutableArray();
var references = map
.OrderBy(static item => item.Key, StringComparer.OrdinalIgnoreCase)
.Select(static item => item.Value.Reference)
.ToImmutableArray();
FieldDecision? decision = null;
if (considered.Count > 0)
@@ -370,12 +376,12 @@ public sealed class CanonicalMerger
additionalProvenance.Add(enriched.MergeProvenance);
map[key] = new PackageSelection(enriched.Package, candidate.Source, candidate.Modified);
decisions.Add(new FieldDecision(
Field: $"affectedPackages[{key}]",
SelectedSource: candidate.Source,
DecisionReason: "precedence",
SelectedModified: candidate.Modified,
ConsideredSources: consideredSources.ToImmutableArray()));
decisions.Add(new FieldDecision(
Field: $"affectedPackages[{key}]",
SelectedSource: candidate.Source,
DecisionReason: "precedence",
SelectedModified: candidate.Modified,
ConsideredSources: consideredSources.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray()));
continue;
}
@@ -398,11 +404,14 @@ public sealed class CanonicalMerger
SelectedSource: candidate.Source,
DecisionReason: reason,
SelectedModified: candidate.Modified,
ConsideredSources: consideredSources.ToImmutableArray()));
ConsideredSources: consideredSources.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray()));
}
}
var packages = map.Values.Select(static s => s.Package).ToImmutableArray();
var packages = map
.OrderBy(static item => item.Key, StringComparer.OrdinalIgnoreCase)
.Select(static item => item.Value.Package)
.ToImmutableArray();
return new PackagesMergeResult(packages, decisions, additionalProvenance);
}
@@ -435,7 +444,7 @@ public sealed class CanonicalMerger
SelectedSource: candidate.Source,
DecisionReason: "precedence",
SelectedModified: candidate.Modified,
ConsideredSources: consideredSources.ToImmutableArray()));
ConsideredSources: consideredSources.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray()));
continue;
}
@@ -471,7 +480,7 @@ public sealed class CanonicalMerger
SelectedSource: candidate.Source,
DecisionReason: decisionReason,
SelectedModified: candidate.Modified,
ConsideredSources: consideredSources.ToImmutableArray()));
ConsideredSources: consideredSources.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray()));
}
}

View File

@@ -47,7 +47,7 @@ public static class AdvisoryDsseMetadataResolver
}
catch (InvalidOperationException)
{
// Same as above fall through to remaining provenance entries.
// Same as above - fall through to remaining provenance entries.
}
}

View File

@@ -5,7 +5,6 @@ using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -21,7 +20,6 @@ public sealed class AdvisoryEventLog : IAdvisoryEventLog
{
private static readonly JsonWriterOptions CanonicalWriterOptions = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = false,
SkipValidation = false,
};

View File

@@ -150,8 +150,8 @@ public sealed class JobCoordinator : IJobCoordinator
var capturedLease = lease ?? throw new InvalidOperationException("Lease acquisition returned null.");
try
{
_ = Task.Run(() => ExecuteJobAsync(definition, capturedLease, started, parameterSnapshot, trigger, linkedTokenSource), CancellationToken.None)
.ContinueWith(t =>
var executionTask = ExecuteJobAsync(definition, capturedLease, started, parameterSnapshot, trigger, linkedTokenSource);
_ = executionTask.ContinueWith(t =>
{
if (t.Exception is not null)
{
@@ -188,7 +188,7 @@ public sealed class JobCoordinator : IJobCoordinator
// Release handled by background execution path. If we failed before scheduling, release here.
if (lease is not null)
{
var releaseError = await TryReleaseLeaseAsync(lease, definition.Kind).ConfigureAwait(false);
var releaseError = await TryReleaseLeaseAsync(lease, definition.Kind, cancellationToken).ConfigureAwait(false);
if (releaseError is not null)
{
_logger.LogError(releaseError, "Failed to release lease {LeaseKey} for job {Kind}", lease.Key, definition.Kind);
@@ -401,11 +401,11 @@ public sealed class JobCoordinator : IJobCoordinator
}
}
private async Task<Exception?> TryReleaseLeaseAsync(JobLease lease, string kind)
private async Task<Exception?> TryReleaseLeaseAsync(JobLease lease, string kind, CancellationToken cancellationToken)
{
try
{
await _leaseStore.ReleaseAsync(lease.Key, _holderId, CancellationToken.None).ConfigureAwait(false);
await _leaseStore.ReleaseAsync(lease.Key, _holderId, cancellationToken).ConfigureAwait(false);
return null;
}
catch (Exception ex)
@@ -494,7 +494,7 @@ public sealed class JobCoordinator : IJobCoordinator
leaseException = await ObserveLeaseTaskAsync(heartbeatTask).ConfigureAwait(false);
var releaseException = await TryReleaseLeaseAsync(lease, definition.Kind).ConfigureAwait(false);
var releaseException = await TryReleaseLeaseAsync(lease, definition.Kind, cancellationToken).ConfigureAwait(false);
leaseException = CombineLeaseExceptions(leaseException, releaseException);
if (leaseException is not null)
@@ -510,7 +510,7 @@ public sealed class JobCoordinator : IJobCoordinator
{
error = string.IsNullOrWhiteSpace(error)
? leaseMessage
: $"{error}{Environment.NewLine}{leaseMessage}";
: $"{error}\n{leaseMessage}";
executionException = executionException is null
? leaseException
: new AggregateException(executionException, leaseException);
@@ -518,7 +518,7 @@ public sealed class JobCoordinator : IJobCoordinator
}
}
completedSnapshot = await CompleteRunAsync(run.RunId, finalStatus, error, CancellationToken.None).ConfigureAwait(false);
completedSnapshot = await CompleteRunAsync(run.RunId, finalStatus, error, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(error))
{

View File

@@ -1,5 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using System;
using System.Linq;
using System.Threading;
@@ -80,7 +80,7 @@ public sealed class AdvisoryLinksetQueryService : IAdvisoryLinksetQueryService
}
var ticksText = payload[..separator];
if (!long.TryParse(ticksText, out var ticks))
if (!long.TryParse(ticksText, NumberStyles.None, CultureInfo.InvariantCulture, out var ticks))
{
throw new FormatException("Cursor timestamp invalid.");
}
@@ -105,7 +105,7 @@ public sealed class AdvisoryLinksetQueryService : IAdvisoryLinksetQueryService
private static string? EncodeCursor(AdvisoryLinkset linkset)
{
var payload = $"{linkset.CreatedAt.UtcTicks}:{linkset.AdvisoryId}";
var payload = $"{linkset.CreatedAt.UtcTicks.ToString(CultureInfo.InvariantCulture)}:{linkset.AdvisoryId}";
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload));
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Concelier.Models;
@@ -67,7 +68,7 @@ public sealed record AdvisoryLinksetUpdatedEvent(
Conflicts: conflicts,
Provenance: provenance,
CreatedAt: linkset.CreatedAt,
ReplayCursor: replayCursor ?? linkset.CreatedAt.ToUniversalTime().Ticks.ToString(),
ReplayCursor: replayCursor ?? linkset.CreatedAt.ToUniversalTime().Ticks.ToString(CultureInfo.InvariantCulture),
BuiltByJobId: linkset.BuiltByJobId,
TraceId: traceId);
}
@@ -94,7 +95,7 @@ public sealed record AdvisoryLinksetUpdatedEvent(
sb.Append('|');
sb.Append(linkset.Source);
sb.Append('|');
sb.Append(linkset.CreatedAt.ToUniversalTime().Ticks);
sb.Append(linkset.CreatedAt.ToUniversalTime().Ticks.ToString(CultureInfo.InvariantCulture));
sb.Append('|');
sb.Append(delta.Type);
sb.Append('|');
@@ -135,7 +136,7 @@ public sealed record AdvisoryLinksetUpdatedEvent(
}
/// <summary>
/// Extracts namespace prefix from tenant ID (e.g., "org:acme" "org").
/// Extracts namespace prefix from tenant ID (e.g., "org:acme" -> "org").
/// </summary>
private static string? ExtractNamespace(string tenantId)
{
@@ -243,13 +244,34 @@ public sealed record AdvisoryLinksetUpdatedEvent(
private static bool ConflictsEqual(IReadOnlyList<AdvisoryLinksetConflict>? a, IReadOnlyList<AdvisoryLinksetConflict>? b)
{
if (a is null && b is null) return true;
if (a is null || b is null) return false;
if (a.Count != b.Count) return false;
for (var i = 0; i < a.Count; i++)
if (a is null && b is null)
{
if (a[i].Field != b[i].Field || a[i].Reason != b[i].Reason)
return true;
}
if (a is null || b is null)
{
return false;
}
if (a.Count != b.Count)
{
return false;
}
var orderedA = a
.OrderBy(c => c.Field, StringComparer.Ordinal)
.ThenBy(c => c.Reason, StringComparer.Ordinal)
.ToList();
var orderedB = b
.OrderBy(c => c.Field, StringComparer.Ordinal)
.ThenBy(c => c.Reason, StringComparer.Ordinal)
.ToList();
for (var i = 0; i < orderedA.Count; i++)
{
if (!string.Equals(orderedA[i].Field, orderedB[i].Field, StringComparison.Ordinal) ||
!string.Equals(orderedA[i].Reason, orderedB[i].Reason, StringComparison.Ordinal))
{
return false;
}
@@ -267,7 +289,10 @@ public sealed record AdvisoryLinksetUpdatedEvent(
}
return conflicts
.Select(c => new AdvisoryLinksetConflictSummary(c.Field, c.Reason, c.SourceIds?.ToImmutableArray() ?? ImmutableArray<string>.Empty))
.Select(c => new AdvisoryLinksetConflictSummary(
c.Field,
c.Reason,
SortValues(c.SourceIds)))
.OrderBy(c => c.Field, StringComparer.Ordinal)
.ThenBy(c => c.Reason, StringComparer.Ordinal)
.ToImmutableArray();
@@ -283,13 +308,27 @@ public sealed record AdvisoryLinksetUpdatedEvent(
PolicyHash: null);
}
var hashes = provenance.ObservationHashes?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
var hashes = SortValues(provenance.ObservationHashes);
return new AdvisoryLinksetProvenanceSummary(
ObservationHashes: hashes,
ToolVersion: provenance.ToolVersion,
PolicyHash: provenance.PolicyHash);
}
private static ImmutableArray<string> SortValues(IReadOnlyList<string>? values)
{
if (values is null || values.Count == 0)
{
return ImmutableArray<string>.Empty;
}
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray();
}
}
/// <summary>
@@ -332,7 +371,7 @@ public sealed record AdvisoryLinksetTenantMetadata(
/// Per CONCELIER-POLICY-23-002.
/// </summary>
/// <param name="Value">Raw confidence score (0.0 - 1.0).</param>
/// <param name="Tier">Confidence tier: high (0.9), medium (0.7), low (0.5), very-low (&lt;0.5), unknown (null).</param>
/// <param name="Tier">Confidence tier: high (>=0.9), medium (>=0.7), low (>=0.5), very-low (&lt;0.5), unknown (null).</param>
/// <param name="ConflictCount">Number of conflicts detected in the linkset.</param>
/// <param name="Factors">Human-readable factors contributing to confidence score.</param>
public sealed record AdvisoryLinksetConfidenceSummary(

View File

@@ -90,7 +90,8 @@ internal static class LinksetCorrelation
if (anyAliases)
{
var values = inputs
.Select(i => $"{i.Vendor ?? "source"}:{i.Aliases.FirstOrDefault() ?? "<none>"}")
.Select(i => $"{i.Vendor ?? "source"}:{FirstSortedOrDefault(i.Aliases)}")
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
conflicts.Add(new AdvisoryLinksetConflict("aliases", "alias-inconsistency", values));
}
@@ -151,10 +152,12 @@ internal static class LinksetCorrelation
.SelectMany(i => i.Purls
.Where(p => ExtractPackageKey(p) == package)
.Select(p => $"{i.Vendor ?? "source"}:{p}"))
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
var sourceIds = inputs
.Select(i => i.Vendor ?? "source")
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
if (values.Length > 1)
@@ -261,21 +264,23 @@ internal static class LinksetCorrelation
if (overlap == 0d && !string.Equals(inputList[i].Vendor, inputList[j].Vendor, StringComparison.OrdinalIgnoreCase))
{
var firstExample = FirstSortedOrDefault(first);
var secondExample = FirstSortedOrDefault(second);
var values = new[]
{
$"{inputList[i].Vendor ?? "source"}:{first.FirstOrDefault() ?? "<none>"}",
$"{inputList[j].Vendor ?? "source"}:{second.FirstOrDefault() ?? "<none>"}"
$"{inputList[i].Vendor ?? "source"}:{firstExample}",
$"{inputList[j].Vendor ?? "source"}:{secondExample}"
};
conflicts.Add(new AdvisoryLinksetConflict(
"references",
"reference-clash",
values,
values.OrderBy(static value => value, StringComparer.Ordinal).ToArray(),
new[]
{
inputList[i].Vendor ?? "source",
inputList[j].Vendor ?? "source"
}));
}.OrderBy(static value => value, StringComparer.Ordinal).ToArray()));
}
}
}
@@ -323,18 +328,25 @@ internal static class LinksetCorrelation
foreach (var conflict in conflicts)
{
var key = $"{conflict.Field}|{conflict.Reason}|{string.Join('|', conflict.Values ?? Array.Empty<string>())}";
var normalizedValues = NormalizeValues(conflict.Values);
var normalizedSources = NormalizeValues(conflict.SourceIds);
var key = $"{conflict.Field}|{conflict.Reason}|{string.Join('|', normalizedValues)}";
if (set.Add(key))
{
if (conflict.SourceIds is null || conflict.SourceIds.Count == 0)
if (normalizedSources.Count == 0)
{
var allSources = inputs.Select(i => i.Vendor ?? "source").Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
list.Add(conflict with { SourceIds = allSources });
normalizedSources = inputs
.Select(i => i.Vendor ?? "source")
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
}
else
list.Add(conflict with
{
list.Add(conflict);
}
Values = normalizedValues,
SourceIds = normalizedSources
});
}
}
@@ -346,4 +358,28 @@ internal static class LinksetCorrelation
}
private static double Clamp01(double value) => Math.Clamp(value, 0d, 1d);
private static string FirstSortedOrDefault(IEnumerable<string> values)
{
var first = values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.OrderBy(static value => value, StringComparer.Ordinal)
.FirstOrDefault();
return string.IsNullOrEmpty(first) ? "<none>" : first;
}
private static IReadOnlyList<string> NormalizeValues(IReadOnlyList<string>? values)
{
if (values is null || values.Count == 0)
{
return Array.Empty<string>();
}
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Concelier.Models;
@@ -51,7 +52,7 @@ public sealed record AdvisoryObservationUpdatedEvent(
DocumentSha: observation.Upstream.ContentHash,
ObservationHash: observationHash,
IngestedAt: observation.CreatedAt,
ReplayCursor: replayCursor ?? observation.CreatedAt.ToUniversalTime().Ticks.ToString(),
ReplayCursor: replayCursor ?? observation.CreatedAt.ToUniversalTime().Ticks.ToString(CultureInfo.InvariantCulture),
SupersedesId: supersedesId,
TraceId: traceId);
}
@@ -76,11 +77,17 @@ public sealed record AdvisoryObservationUpdatedEvent(
.OrderBy(static v => v, StringComparer.Ordinal)
.ToImmutableArray();
var relationships = rawLinkset.Relationships.Select(static rel => new AdvisoryObservationRelationshipSummary(
rel.Type,
rel.Source,
rel.Target,
rel.Provenance)).ToImmutableArray();
var relationships = rawLinkset.Relationships
.OrderBy(static rel => rel.Type, StringComparer.Ordinal)
.ThenBy(static rel => rel.Source, StringComparer.Ordinal)
.ThenBy(static rel => rel.Target, StringComparer.Ordinal)
.ThenBy(static rel => rel.Provenance ?? string.Empty, StringComparer.Ordinal)
.Select(static rel => new AdvisoryObservationRelationshipSummary(
rel.Type,
rel.Source,
rel.Target,
rel.Provenance))
.ToImmutableArray();
return new AdvisoryObservationLinksetSummary(
Aliases: SortSet(linkset.Aliases),

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
@@ -313,8 +314,8 @@ public sealed class AdvisoryFieldChangeEmitter : IAdvisoryFieldChangeEmitter
{
changes.Add(new AdvisoryFieldChange(
Field: "cvss_score",
PreviousValue: previousScore.Value.ToString("F1"),
CurrentValue: currentScore.Value.ToString("F1"),
PreviousValue: previousScore.Value.ToString("F1", CultureInfo.InvariantCulture),
CurrentValue: currentScore.Value.ToString("F1", CultureInfo.InvariantCulture),
Category: AdvisoryFieldChangeCategory.Risk,
Provenance: currentProvenance));
}

View File

@@ -79,7 +79,7 @@ public sealed class LinkNotMergeTenantCapabilitiesProvider : ITenantCapabilities
public TenantCapabilitiesResponse GetCapabilities(TenantScope scope)
{
ArgumentNullException.ThrowIfNull(scope);
scope.Validate();
scope.Validate(_timeProvider);
// In Link-Not-Merge mode, merge is never allowed
// This enforces the contract even if the token claims mergeAllowed=true
@@ -89,7 +89,7 @@ public sealed class LinkNotMergeTenantCapabilitiesProvider : ITenantCapabilities
public void ValidateScope(TenantScope scope, params string[] requiredScopes)
{
ArgumentNullException.ThrowIfNull(scope);
scope.Validate();
scope.Validate(_timeProvider);
if (requiredScopes.Length == 0)
{

View File

@@ -19,11 +19,13 @@ public sealed record TenantScope(
/// <summary>
/// Validates that the tenant scope is well-formed.
/// </summary>
/// <param name="timeProvider">Time provider used for expiry checks.</param>
/// <param name="asOf">The time to check expiry against. Defaults to current UTC time.</param>
public void Validate(DateTimeOffset? asOf = null)
public void Validate(TimeProvider timeProvider, DateTimeOffset? asOf = null)
{
var now = asOf ?? DateTimeOffset.UtcNow;
ArgumentNullException.ThrowIfNull(timeProvider);
var now = asOf ?? timeProvider.GetUtcNow();
if (string.IsNullOrWhiteSpace(TenantId))
{
throw new TenantScopeException("auth/tenant-scope-missing", "TenantId is required");

View File

@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Core.AirGap;
using StellaOps.Concelier.Core.AirGap.Models;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.AirGap;
public sealed class BundleCatalogServiceTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task GetCatalogAsync_SortsSourcesAndUsesInvariantCursor()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
var tempRoot = Path.Combine(
Path.GetTempPath(),
$"concelier-bundle-catalog-{Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)}");
var dirA = Path.Combine(tempRoot, "a");
var dirB = Path.Combine(tempRoot, "b");
Directory.CreateDirectory(dirA);
Directory.CreateDirectory(dirB);
File.WriteAllText(Path.Combine(dirA, "bundle-b.bundle.json"), "{}");
File.WriteAllText(Path.Combine(dirB, "bundle-a.bundle.json"), "{}");
try
{
CultureInfo.CurrentCulture = new CultureInfo("ar-SA");
CultureInfo.CurrentUICulture = new CultureInfo("ar-SA");
var sources = new[]
{
new BundleSourceInfo
{
Id = "b-source",
Type = "directory",
Location = dirA,
Enabled = true,
RegisteredAt = FixedNow
},
new BundleSourceInfo
{
Id = "a-source",
Type = "directory",
Location = dirB,
Enabled = true,
RegisteredAt = FixedNow
}
};
var registry = new FakeBundleSourceRegistry(sources);
var service = new BundleCatalogService(
registry,
NullLogger<BundleCatalogService>.Instance,
new FakeTimeProvider(FixedNow));
var firstPage = await service.GetCatalogAsync(
cursor: null,
limit: 1,
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(new[] { "a-source", "b-source" }, firstPage.SourceIds.ToArray());
Assert.Single(firstPage.Entries);
Assert.Equal("bundle-a.bundle", firstPage.Entries[0].BundleId);
Assert.False(string.IsNullOrWhiteSpace(firstPage.NextCursor));
Assert.All(firstPage.NextCursor!, ch => Assert.InRange(ch, '0', '9'));
var secondPage = await service.GetCatalogAsync(
firstPage.NextCursor,
limit: 1,
cancellationToken: TestContext.Current.CancellationToken);
Assert.Single(secondPage.Entries);
Assert.Equal("bundle-b.bundle", secondPage.Entries[0].BundleId);
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
private sealed class FakeBundleSourceRegistry : IBundleSourceRegistry
{
private readonly IReadOnlyList<BundleSourceInfo> _sources;
public FakeBundleSourceRegistry(IReadOnlyList<BundleSourceInfo> sources)
{
_sources = sources;
}
public IReadOnlyList<BundleSourceInfo> GetSources() => _sources;
public BundleSourceInfo? GetSource(string sourceId)
=> _sources.FirstOrDefault(source => string.Equals(source.Id, sourceId, StringComparison.OrdinalIgnoreCase));
public Task<BundleSourceInfo> RegisterAsync(BundleSourceRegistration registration, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("RegisterAsync is not used by BundleCatalogServiceTests.");
public Task<bool> UnregisterAsync(string sourceId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("UnregisterAsync is not used by BundleCatalogServiceTests.");
public Task<BundleSourceValidationResult> ValidateAsync(string sourceId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("ValidateAsync is not used by BundleCatalogServiceTests.");
public Task<bool> SetEnabledAsync(string sourceId, bool enabled, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("SetEnabledAsync is not used by BundleCatalogServiceTests.");
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Globalization;
using System.IO;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Core.AirGap;
using StellaOps.Concelier.Core.AirGap.Models;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.AirGap;
public sealed class BundleSourceRegistryTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task ValidateAsync_UsesTimeProviderForValidatedAt()
{
var tempRoot = Path.Combine(
Path.GetTempPath(),
$"concelier-bundle-registry-{Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)}");
Directory.CreateDirectory(tempRoot);
try
{
var registry = new BundleSourceRegistry(
NullLogger<BundleSourceRegistry>.Instance,
new FakeTimeProvider(FixedNow));
var registration = new BundleSourceRegistration
{
Id = "source-a",
Type = "directory",
Location = tempRoot
};
await registry.RegisterAsync(registration, TestContext.Current.CancellationToken);
var result = await registry.ValidateAsync("source-a", TestContext.Current.CancellationToken);
Assert.Equal(FixedNow, result.ValidatedAt);
Assert.Equal(BundleSourceStatus.Healthy, result.Status);
Assert.Equal(0, result.BundleCount);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
}

View File

@@ -14,6 +14,7 @@ using StellaOps.Concelier.BackportProof.Services;
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.TestKit;
using StellaOps.VersionComparison.Comparers;
using System.Globalization;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.BackportProof;
@@ -30,7 +31,10 @@ namespace StellaOps.Concelier.Core.Tests.BackportProof;
[Trait("Category", TestCategories.Unit)]
public sealed class BackportVerdictDeterminismTests
{
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse(
"2025-01-01T00:00:00Z",
CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind);
private readonly ITestOutputHelper _output;
private readonly IVersionComparatorFactory _comparatorFactory;
@@ -43,7 +47,7 @@ public sealed class BackportVerdictDeterminismTests
ApkVersionComparer.Instance);
}
#region Same Input Same Verdict Tests
#region Same Input -> Same Verdict Tests
[Fact]
public async Task SameInput_ProducesIdenticalVerdict_Across10Iterations()
@@ -98,7 +102,7 @@ public sealed class BackportVerdictDeterminismTests
// Create rules in different orders
var rulesOrder1 = CreateTestRules(context, package.Key, cve).ToList();
var rulesOrder2 = rulesOrder1.AsEnumerable().Reverse().ToList();
var rulesOrder3 = rulesOrder1.OrderBy(_ => Guid.NewGuid()).ToList();
var rulesOrder3 = rulesOrder1.Skip(1).Concat(rulesOrder1.Take(1)).ToList();
var repository1 = CreateMockRepository(rulesOrder1);
var repository2 = CreateMockRepository(rulesOrder2);
@@ -432,7 +436,7 @@ public sealed class BackportVerdictDeterminismTests
"test-source",
"https://example.com/advisory",
"sha256:test123",
DateTimeOffset.Parse("2025-01-01T00:00:00Z")),
DateTimeOffset.Parse("2025-01-01T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)),
FixedVersion = "1.36.1-r16"
},
new BoundaryRule
@@ -447,7 +451,7 @@ public sealed class BackportVerdictDeterminismTests
"vendor-csaf",
"https://vendor.example.com/csaf",
"sha256:vendor456",
DateTimeOffset.Parse("2025-01-02T00:00:00Z")),
DateTimeOffset.Parse("2025-01-02T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)),
FixedVersion = "1.36.1-r16"
}
};

View File

@@ -2,11 +2,12 @@
// BugCveMappingIntegrationTests.cs
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-409)
// Task: Integration test: Debian tracker lookup
// Description: E2E tests for bug ID CVE mapping services
// Description: E2E tests for bug ID -> CVE mapping services
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
@@ -19,7 +20,7 @@ using System.Text;
namespace StellaOps.Concelier.Core.Tests.BackportProof;
/// <summary>
/// Integration tests for bug ID CVE mapping services.
/// Integration tests for bug ID -> CVE mapping services.
/// Tests the full flow from bug reference extraction to CVE lookup.
/// </summary>
[Trait("Category", TestCategories.Integration)]
@@ -27,18 +28,25 @@ public sealed class BugCveMappingIntegrationTests : IDisposable
{
private readonly IMemoryCache _cache;
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
private readonly HttpClient _httpClient;
private readonly ServiceProvider _serviceProvider;
private readonly IHttpClientFactory _httpClientFactory;
public BugCveMappingIntegrationTests()
{
_cache = new MemoryCache(new MemoryCacheOptions());
_httpHandlerMock = new Mock<HttpMessageHandler>();
_httpClient = new HttpClient(_httpHandlerMock.Object);
var services = new ServiceCollection();
services.AddHttpClient("DebianSecurityTracker")
.ConfigurePrimaryHttpMessageHandler(() => _httpHandlerMock.Object);
services.AddHttpClient("RedHatErrata")
.ConfigurePrimaryHttpMessageHandler(() => _httpHandlerMock.Object);
_serviceProvider = services.BuildServiceProvider();
_httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>();
}
public void Dispose()
{
_httpClient.Dispose();
_serviceProvider.Dispose();
_cache.Dispose();
}
@@ -477,9 +485,7 @@ public sealed class BugCveMappingIntegrationTests : IDisposable
private IHttpClientFactory CreateHttpClientFactory()
{
var factory = new Mock<IHttpClientFactory>();
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(_httpClient);
return factory.Object;
return _httpClientFactory;
}
#endregion

View File

@@ -8,6 +8,7 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Concelier.Core.Canonical;
@@ -25,6 +26,8 @@ public sealed class CanonicalAdvisoryServiceTests
private static readonly Guid TestCanonicalId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid TestSourceId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid TestEdgeId = Guid.Parse("33333333-3333-3333-3333-333333333333");
private static readonly DateTimeOffset FixedNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly FakeTimeProvider FixedTimeProvider = new(FixedNow);
public CanonicalAdvisoryServiceTests()
{
@@ -83,6 +86,50 @@ public sealed class CanonicalAdvisoryServiceTests
Times.Once);
}
[Fact]
public async Task IngestAsync_UsesTimeProvider_WhenFetchedAtIsDefault()
{
// Arrange
_storeMock
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
_storeMock
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TestCanonicalId);
_storeMock
.Setup(x => x.SourceEdgeExistsAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
AddSourceEdgeRequest? captured = null;
_storeMock
.Setup(x => x.AddSourceEdgeAsync(It.IsAny<AddSourceEdgeRequest>(), It.IsAny<CancellationToken>()))
.Callback<AddSourceEdgeRequest, CancellationToken>((request, _) => captured = request)
.ReturnsAsync(SourceEdgeResult.Created(TestEdgeId));
var service = CreateService();
var advisory = new RawAdvisory
{
SourceAdvisoryId = "ADV-CVE-2025-0200",
Cve = "CVE-2025-0200",
AffectsKey = "pkg:npm/example@1",
VersionRangeJson = "{\"introduced\":\"1.0.0\",\"fixed\":\"1.2.3\"}",
Weaknesses = [],
Severity = "high",
Title = "Test Advisory for CVE-2025-0200",
Summary = "Test summary",
RawPayloadJson = null
};
// Act
await service.IngestAsync(TestSource, advisory);
// Assert
Assert.NotNull(captured);
Assert.Equal(FixedNow, captured!.FetchedAt);
}
[Fact]
public async Task IngestAsync_ComputesMergeHash_FromAdvisoryFields()
{
@@ -741,10 +788,10 @@ public sealed class CanonicalAdvisoryServiceTests
#region Helpers
private CanonicalAdvisoryService CreateService() =>
new(_storeMock.Object, _hashCalculatorMock.Object, _logger);
new(_storeMock.Object, _hashCalculatorMock.Object, _logger, FixedTimeProvider);
private CanonicalAdvisoryService CreateServiceWithSigner() =>
new(_storeMock.Object, _hashCalculatorMock.Object, _logger, _signerMock.Object);
new(_storeMock.Object, _hashCalculatorMock.Object, _logger, FixedTimeProvider, _signerMock.Object);
private static RawAdvisory CreateRawAdvisory(
string cve,
@@ -764,7 +811,7 @@ public sealed class CanonicalAdvisoryServiceTests
Title = $"Test Advisory for {cve}",
Summary = "Test summary",
RawPayloadJson = rawPayloadJson,
FetchedAt = DateTimeOffset.UtcNow
FetchedAt = FixedNow
};
}
@@ -780,7 +827,7 @@ public sealed class CanonicalAdvisoryServiceTests
SourceAdvisoryId = $"VENDOR-{cve}",
SourceDocHash = "sha256:existing",
PrecedenceRank = 10, // High precedence
FetchedAt = DateTimeOffset.UtcNow
FetchedAt = FixedNow
}
}
: new List<SourceEdge>();
@@ -791,8 +838,8 @@ public sealed class CanonicalAdvisoryServiceTests
Cve = cve,
AffectsKey = "pkg:npm/example@1",
MergeHash = TestMergeHash,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
CreatedAt = FixedNow,
UpdatedAt = FixedNow,
SourceEdges = sourceEdges
};
}

View File

@@ -1,6 +1,7 @@
using StellaOps.Concelier.Models;
using StellaOps.TestKit;
using System.Linq;
namespace StellaOps.Concelier.Core.Tests;
public sealed class CanonicalMergerTests
@@ -8,7 +9,7 @@ public sealed class CanonicalMergerTests
private static readonly DateTimeOffset BaseTimestamp = new(2025, 10, 10, 0, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_PrefersGhsaTitleAndSummaryByPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));
@@ -45,7 +46,7 @@ public sealed class CanonicalMergerTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_FreshnessOverrideUsesOsvSummaryWhenNewerByThreshold()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(10)));
@@ -81,7 +82,7 @@ public sealed class CanonicalMergerTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_AffectedPackagesPreferOsvPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(4)));
@@ -168,7 +169,7 @@ public sealed class CanonicalMergerTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_CvssMetricsOrderedByPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(5)));
@@ -190,7 +191,7 @@ public sealed class CanonicalMergerTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_ReferencesNormalizedAndFreshnessOverrides()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(80)));
@@ -241,7 +242,78 @@ public sealed class CanonicalMergerTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_OrdersCreditsReferencesAndPackagesDeterministically()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));
var ghsaCredit = new AdvisoryCredit(
"Bob",
"analyst",
new[] { "bob@example.com" },
CreateProvenance("ghsa", ProvenanceFieldMasks.Credits));
var osvCredit = new AdvisoryCredit(
"Alice",
"researcher",
new[] { "alice@example.com" },
CreateProvenance("osv", ProvenanceFieldMasks.Credits));
var ghsaReference = new AdvisoryReference(
"https://example.com/b",
kind: "advisory",
sourceTag: null,
summary: null,
CreateProvenance("ghsa", ProvenanceFieldMasks.References));
var osvReference = new AdvisoryReference(
"https://example.com/a",
kind: "advisory",
sourceTag: null,
summary: null,
CreateProvenance("osv", ProvenanceFieldMasks.References));
var ghsaPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/b@1",
platform: null,
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { CreateProvenance("ghsa", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var osvPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/a@1",
platform: null,
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { CreateProvenance("osv", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-ordering",
title: "GHSA Title",
modified: BaseTimestamp.AddHours(1),
credits: new[] { ghsaCredit },
references: new[] { ghsaReference },
packages: new[] { ghsaPackage });
var osv = CreateAdvisory(
source: "osv",
advisoryKey: "OSV-ordering",
title: "OSV Title",
modified: BaseTimestamp.AddHours(2),
credits: new[] { osvCredit },
references: new[] { osvReference },
packages: new[] { osvPackage });
var result = merger.Merge("CVE-2025-4242", ghsa, null, osv);
Assert.Equal(new[] { "Alice", "Bob" }, result.Advisory.Credits.Select(c => c.DisplayName).ToArray());
Assert.Equal(new[] { "https://example.com/a", "https://example.com/b" }, result.Advisory.References.Select(r => r.Url).ToArray());
Assert.Equal(new[] { "pkg:npm/a@1", "pkg:npm/b@1" }, result.Advisory.AffectedPackages.Select(p => p.Identifier).ToArray());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Merge_DescriptionFreshnessOverride()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(12)));
@@ -280,7 +352,7 @@ public sealed class CanonicalMergerTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_CwesPreferNvdPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using FluentAssertions;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.Models;
@@ -9,7 +10,7 @@ using Xunit;
namespace StellaOps.Concelier.Core.Tests.Linksets;
/// <summary>
/// Determinism and provenance-focused tests aligned with CI1CI10 gap remediation.
/// Determinism and provenance-focused tests aligned with CI1-CI10 gap remediation.
/// </summary>
public sealed class AdvisoryLinksetDeterminismTests
{
@@ -54,14 +55,14 @@ public sealed class AdvisoryLinksetDeterminismTests
{
new LinksetCorrelation.Input(
Vendor: "nvd",
FetchedAt: DateTimeOffset.Parse("2025-12-01T00:00:00Z"),
Aliases: new[] { "CVE-2025-1111" },
FetchedAt: DateTimeOffset.Parse("2025-12-01T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
Aliases: new[] { "CVE-2025-2222", "CVE-2025-1111" },
Purls: Array.Empty<string>(),
Cpes: Array.Empty<string>(),
References: Array.Empty<string>()),
new LinksetCorrelation.Input(
Vendor: "vendor",
FetchedAt: DateTimeOffset.Parse("2025-12-01T00:05:00Z"),
FetchedAt: DateTimeOffset.Parse("2025-12-01T00:05:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
Aliases: new[] { "CVE-2025-2222" },
Purls: Array.Empty<string>(),
Cpes: Array.Empty<string>(),
@@ -82,5 +83,6 @@ public sealed class AdvisoryLinksetDeterminismTests
conflicts[0].Field.Should().Be("aliases");
conflicts[0].Reason.Should().Be("alias-inconsistency");
conflicts[0].SourceIds.Should().ContainInOrder("nvd", "vendor");
conflicts[0].Values.Should().ContainInOrder("nvd:CVE-2025-1111", "vendor:CVE-2025-2222");
}
}

View File

@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using StellaOps.Concelier.Core.Linksets;
using Xunit;
@@ -16,17 +18,17 @@ public sealed class AdvisoryLinksetQueryServiceTests
ImmutableArray.Create("obs-003"),
new AdvisoryLinksetNormalized(new[]{"pkg:npm/a"}, null, new[]{"1.0.0"}, null, null),
null, null, null,
DateTimeOffset.Parse("2025-11-10T12:00:00Z"), null),
DateTimeOffset.Parse("2025-11-10T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), null),
new("tenant", "ghsa", "adv-002",
ImmutableArray.Create("obs-002"),
new AdvisoryLinksetNormalized(new[]{"pkg:npm/b"}, null, new[]{"2.0.0"}, null, null),
null, null, null,
DateTimeOffset.Parse("2025-11-09T12:00:00Z"), null),
DateTimeOffset.Parse("2025-11-09T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), null),
new("tenant", "ghsa", "adv-001",
ImmutableArray.Create("obs-001"),
new AdvisoryLinksetNormalized(new[]{"pkg:npm/c"}, null, new[]{"3.0.0"}, null, null),
null, null, null,
DateTimeOffset.Parse("2025-11-08T12:00:00Z"), null),
DateTimeOffset.Parse("2025-11-08T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), null),
};
var lookup = new FakeLinksetLookup(linksets);
@@ -60,6 +62,48 @@ public sealed class AdvisoryLinksetQueryServiceTests
});
}
[Fact]
public async Task QueryAsync_EncodesCursorWithInvariantDigits()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("ar-SA");
CultureInfo.CurrentUICulture = new CultureInfo("ar-SA");
var linksets = new List<AdvisoryLinkset>
{
new("tenant", "ghsa", "adv-001",
ImmutableArray.Create("obs-001"),
null,
null, null, null,
new DateTimeOffset(2025, 11, 8, 12, 0, 0, TimeSpan.Zero), null),
new("tenant", "ghsa", "adv-000",
ImmutableArray.Create("obs-000"),
null,
null, null, null,
new DateTimeOffset(2025, 11, 7, 12, 0, 0, TimeSpan.Zero), null),
};
var lookup = new FakeLinksetLookup(linksets);
var service = new AdvisoryLinksetQueryService(lookup);
var page = await service.QueryAsync(new AdvisoryLinksetQueryOptions("tenant", Limit: 1), TestContext.Current.CancellationToken);
Assert.False(string.IsNullOrWhiteSpace(page.NextCursor));
var payload = Encoding.UTF8.GetString(Convert.FromBase64String(page.NextCursor!));
var ticksText = payload.Split(':')[0];
Assert.All(ticksText, ch => Assert.InRange(ch, '0', '9'));
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
private sealed class FakeLinksetLookup : IAdvisoryLinksetLookup
{
private readonly IReadOnlyList<AdvisoryLinkset> _linksets;

View File

@@ -13,6 +13,8 @@ namespace StellaOps.Concelier.Core.Tests.Linksets;
/// </summary>
public sealed class AdvisoryLinksetUpdatedEventTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 2, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void FromLinkset_NewLinkset_CreatesEventWithCreatedDelta()
{
@@ -97,7 +99,7 @@ public sealed class AdvisoryLinksetUpdatedEventTests
{
// Arrange
var provenance = new AdvisoryLinksetProvenance(
ObservationHashes: new[] { "sha256:abc123", "sha256:def456" },
ObservationHashes: new[] { "sha256:def456", "sha256:abc123" },
ToolVersion: "1.0.0",
PolicyHash: "policy-hash-123");
@@ -112,6 +114,29 @@ public sealed class AdvisoryLinksetUpdatedEventTests
@event.Provenance.PolicyHash.Should().Be("policy-hash-123");
}
[Fact]
public void FromLinkset_ConflictOrderingDoesNotTriggerDeltaChange()
{
var conflictsA = new List<AdvisoryLinksetConflict>
{
new("severity", "severity-mismatch", new[] { "nvd:9.8", "ghsa:8.5" }, new[] { "nvd", "ghsa" }),
new("aliases", "alias-inconsistency", new[] { "CVE-2024-1234", "CVE-2024-5678" }, null)
};
var conflictsB = new List<AdvisoryLinksetConflict>
{
new("aliases", "alias-inconsistency", new[] { "CVE-2024-5678", "CVE-2024-1234" }, null),
new("severity", "severity-mismatch", new[] { "ghsa:8.5", "nvd:9.8" }, new[] { "ghsa", "nvd" })
};
var previousLinkset = CreateLinksetWithConflicts("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1" }, conflictsA);
var currentLinkset = CreateLinksetWithConflicts("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1" }, conflictsB);
var @event = AdvisoryLinksetUpdatedEvent.FromLinkset(currentLinkset, previousLinkset, "linkset-1", null);
@event.Delta.ConflictsChanged.Should().BeFalse();
}
[Fact]
public void FromLinkset_ConfidenceChanged_SetsConfidenceChangedFlag()
{
@@ -152,7 +177,7 @@ public sealed class AdvisoryLinksetUpdatedEventTests
var event2 = AdvisoryLinksetUpdatedEvent.FromLinkset(linkset, null, "linkset-1", null);
// Assert
event1.EventId.Should().NotBe(event2.EventId);
event1.EventId.Should().Be(event2.EventId);
event1.EventId.Should().NotBe(Guid.Empty);
}
@@ -178,7 +203,7 @@ public sealed class AdvisoryLinksetUpdatedEventTests
Provenance: null,
Confidence: null,
Conflicts: null,
CreatedAt: DateTimeOffset.UtcNow,
CreatedAt: FixedNow,
BuiltByJobId: null);
}
@@ -194,7 +219,7 @@ public sealed class AdvisoryLinksetUpdatedEventTests
Provenance: null,
Confidence: null,
Conflicts: conflicts,
CreatedAt: DateTimeOffset.UtcNow,
CreatedAt: FixedNow,
BuiltByJobId: null);
}
@@ -210,7 +235,7 @@ public sealed class AdvisoryLinksetUpdatedEventTests
Provenance: provenance,
Confidence: null,
Conflicts: null,
CreatedAt: DateTimeOffset.UtcNow,
CreatedAt: FixedNow,
BuiltByJobId: null);
}
@@ -226,7 +251,7 @@ public sealed class AdvisoryLinksetUpdatedEventTests
Provenance: null,
Confidence: confidence,
Conflicts: null,
CreatedAt: DateTimeOffset.UtcNow,
CreatedAt: FixedNow,
BuiltByJobId: null);
}
}

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Text.Json.Nodes;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Models.Observations;
@@ -28,14 +30,43 @@ public sealed class AdvisoryObservationEventFactoryTests
Assert.Contains("pkg:npm/foo@1.0.0", evt.LinksetSummary.Purls);
}
[Fact]
public void FromObservation_OrdersRelationshipsAndUsesInvariantCursor()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("ar-SA");
CultureInfo.CurrentUICulture = new CultureInfo("ar-SA");
var observation = CreateObservation();
var evt = AdvisoryObservationUpdatedEvent.FromObservation(
observation,
supersedesId: null,
traceId: null);
var relationshipTypes = evt.LinksetSummary.Relationships.Select(r => r.Type).ToArray();
Assert.Equal(new[] { "affects", "contains" }, relationshipTypes);
var expectedCursor = observation.CreatedAt.ToUniversalTime().Ticks.ToString(CultureInfo.InvariantCulture);
Assert.Equal(expectedCursor, evt.ReplayCursor);
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
private static AdvisoryObservation CreateObservation()
{
var source = new AdvisoryObservationSource("ghsa", "advisories", "https://api");
var upstream = new AdvisoryObservationUpstream(
"adv-1",
"v1",
DateTimeOffset.Parse("2025-11-20T12:00:00Z"),
DateTimeOffset.Parse("2025-11-20T12:00:00Z"),
DateTimeOffset.Parse("2025-11-20T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
DateTimeOffset.Parse("2025-11-20T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
"2f8f568cc1ed3474f0a4564ddb8c64f4b4d176fbe0a2a98a02b88e822a4f5b6d",
new AdvisoryObservationSignature(false, null, null, null));
@@ -52,7 +83,9 @@ public sealed class AdvisoryObservationEventFactoryTests
PackageUrls = ImmutableArray.Create("pkg:npm/foo@1.0.0"),
Cpes = ImmutableArray.Create("cpe:/a:foo:foo:1.0.0"),
Scopes = ImmutableArray.Create("runtime"),
Relationships = ImmutableArray.Create(new RawRelationship("contains", "pkg:npm/foo@1.0.0", "file://dist/foo.js")),
Relationships = ImmutableArray.Create(
new RawRelationship("contains", "pkg:npm/foo@1.0.0", "file://dist/foo.js"),
new RawRelationship("affects", "pkg:npm/foo@1.0.0", "file://dist/bar.js")),
};
return new AdvisoryObservation(
@@ -63,6 +96,6 @@ public sealed class AdvisoryObservationEventFactoryTests
content,
linkset,
rawLinkset,
DateTimeOffset.Parse("2025-11-20T12:01:00Z"));
DateTimeOffset.Parse("2025-11-20T12:01:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind));
}
}

View File

@@ -1,13 +1,18 @@
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Core.Orchestration;
namespace StellaOps.Concelier.Core.Tests.Orchestration;
public sealed class OrchestratorRegistryStoreTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static InMemoryOrchestratorRegistryStore CreateStore()
=> new(new FakeTimeProvider(FixedNow));
[Fact]
public async Task UpsertAsync_CreatesNewRecord()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var record = CreateRegistryRecord("tenant-1", "connector-1");
await store.UpsertAsync(record, TestContext.Current.CancellationToken);
@@ -21,7 +26,7 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task UpsertAsync_UpdatesExistingRecord()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var record1 = CreateRegistryRecord("tenant-1", "connector-1", source: "nvd");
var record2 = CreateRegistryRecord("tenant-1", "connector-1", source: "osv");
@@ -36,7 +41,7 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task GetAsync_ReturnsNullForNonExistentRecord()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var retrieved = await store.GetAsync("tenant-1", "nonexistent", TestContext.Current.CancellationToken);
@@ -46,7 +51,7 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task ListAsync_ReturnsRecordsForTenant()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
await store.UpsertAsync(CreateRegistryRecord("tenant-1", "connector-a"), TestContext.Current.CancellationToken);
await store.UpsertAsync(CreateRegistryRecord("tenant-1", "connector-b"), TestContext.Current.CancellationToken);
await store.UpsertAsync(CreateRegistryRecord("tenant-2", "connector-c"), TestContext.Current.CancellationToken);
@@ -60,7 +65,7 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task ListAsync_ReturnsOrderedByConnectorId()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
await store.UpsertAsync(CreateRegistryRecord("tenant-1", "zzz-connector"), TestContext.Current.CancellationToken);
await store.UpsertAsync(CreateRegistryRecord("tenant-1", "aaa-connector"), TestContext.Current.CancellationToken);
@@ -73,12 +78,12 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task AppendHeartbeatAsync_StoresHeartbeat()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
var heartbeat = new OrchestratorHeartbeatRecord(
"tenant-1", "connector-1", runId, 1,
OrchestratorHeartbeatStatus.Running, 50, 10,
null, null, null, null, DateTimeOffset.UtcNow);
null, null, null, null, FixedNow);
await store.AppendHeartbeatAsync(heartbeat, TestContext.Current.CancellationToken);
@@ -91,9 +96,9 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task GetLatestHeartbeatAsync_ReturnsHighestSequence()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var now = FixedNow;
await store.AppendHeartbeatAsync(CreateHeartbeat("tenant-1", "connector-1", runId, 1, OrchestratorHeartbeatStatus.Starting, now), TestContext.Current.CancellationToken);
await store.AppendHeartbeatAsync(CreateHeartbeat("tenant-1", "connector-1", runId, 3, OrchestratorHeartbeatStatus.Succeeded, now.AddMinutes(2)), TestContext.Current.CancellationToken);
@@ -109,12 +114,12 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task EnqueueCommandAsync_StoresCommand()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
var command = new OrchestratorCommandRecord(
"tenant-1", "connector-1", runId, 1,
OrchestratorCommandKind.Pause, null, null,
DateTimeOffset.UtcNow, null);
FixedNow, null);
await store.EnqueueCommandAsync(command, TestContext.Current.CancellationToken);
@@ -126,9 +131,9 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task GetPendingCommandsAsync_FiltersAfterSequence()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var now = FixedNow;
await store.EnqueueCommandAsync(CreateCommand("tenant-1", "connector-1", runId, 1, OrchestratorCommandKind.Pause, now), TestContext.Current.CancellationToken);
await store.EnqueueCommandAsync(CreateCommand("tenant-1", "connector-1", runId, 2, OrchestratorCommandKind.Resume, now), TestContext.Current.CancellationToken);
@@ -144,9 +149,9 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task GetPendingCommandsAsync_ExcludesExpiredCommands()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var now = FixedNow;
var expired = now.AddMinutes(-5);
var future = now.AddMinutes(5);
@@ -162,14 +167,14 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task StoreManifestAsync_StoresManifest()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
var manifest = new OrchestratorRunManifest(
runId, "connector-1", "tenant-1",
new OrchestratorBackfillRange("cursor-a", "cursor-z"),
["hash1", "hash2"],
"dsse-hash",
DateTimeOffset.UtcNow);
FixedNow);
await store.StoreManifestAsync(manifest, TestContext.Current.CancellationToken);
@@ -183,7 +188,7 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task GetManifestAsync_ReturnsNullForNonExistentManifest()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var manifest = await store.GetManifestAsync("tenant-1", "connector-1", Guid.NewGuid(), TestContext.Current.CancellationToken);
@@ -191,18 +196,18 @@ public sealed class OrchestratorRegistryStoreTests
}
[Fact]
public void Clear_RemovesAllData()
public async Task Clear_RemovesAllData()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
store.UpsertAsync(CreateRegistryRecord("tenant-1", "connector-1"), TestContext.Current.CancellationToken).Wait();
store.AppendHeartbeatAsync(CreateHeartbeat("tenant-1", "connector-1", runId, 1, OrchestratorHeartbeatStatus.Running, DateTimeOffset.UtcNow), TestContext.Current.CancellationToken).Wait();
await store.UpsertAsync(CreateRegistryRecord("tenant-1", "connector-1"), TestContext.Current.CancellationToken);
await store.AppendHeartbeatAsync(CreateHeartbeat("tenant-1", "connector-1", runId, 1, OrchestratorHeartbeatStatus.Running, FixedNow), TestContext.Current.CancellationToken);
store.Clear();
Assert.Null(store.GetAsync("tenant-1", "connector-1", TestContext.Current.CancellationToken).Result);
Assert.Null(store.GetLatestHeartbeatAsync("tenant-1", "connector-1", runId, TestContext.Current.CancellationToken).Result);
Assert.Null(await store.GetAsync("tenant-1", "connector-1", TestContext.Current.CancellationToken));
Assert.Null(await store.GetLatestHeartbeatAsync("tenant-1", "connector-1", runId, TestContext.Current.CancellationToken));
}
private static OrchestratorRegistryRecord CreateRegistryRecord(string tenant, string connectorId, string source = "nvd")
@@ -216,8 +221,8 @@ public sealed class OrchestratorRegistryStoreTests
["raw-advisory"],
$"concelier:{tenant}:{connectorId}",
new OrchestratorEgressGuard(["example.com"], false),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow);
FixedNow,
FixedNow);
}
private static OrchestratorHeartbeatRecord CreateHeartbeat(

View File

@@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Aoc;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Core.Linksets;
@@ -21,6 +22,8 @@ namespace StellaOps.Concelier.Core.Tests.Raw;
public sealed class AdvisoryRawServiceTests
{
private const string GhsaAlias = "GHSA-AAAA-BBBB-CCCC";
private static readonly DateTimeOffset FixedNow = new(2025, 11, 20, 12, 0, 0, TimeSpan.Zero);
private static readonly TimeProvider FixedTimeProvider = new FakeTimeProvider(FixedNow);
[Fact]
public async Task IngestAsync_RemovesClientSupersedesBeforeUpsert()
@@ -177,7 +180,7 @@ public sealed class AdvisoryRawServiceTests
observationFactory,
observationSink,
linksetSink,
TimeProvider.System,
FixedTimeProvider,
NullLogger<AdvisoryRawService>.Instance);
}
@@ -210,7 +213,7 @@ public sealed class AdvisoryRawServiceTests
Upstream: new RawUpstreamMetadata(
UpstreamId: GhsaAlias,
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
RetrievedAt: FixedNow,
ContentHash: "sha256:abc",
Signature: new RawSignatureMetadata(
Present: true,
@@ -250,7 +253,7 @@ public sealed class AdvisoryRawServiceTests
return new AdvisoryRawRecord(
Id: "advisory_raw:vendor-x:ghsa-aaaa-bbbb-cccc:sha256-1",
Document: resolvedDocument,
IngestedAt: DateTimeOffset.UtcNow,
IngestedAt: FixedNow,
CreatedAt: document.Upstream.RetrievedAt);
}

View File

@@ -0,0 +1,86 @@
using System;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Core.Risk;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Risk;
public sealed class AdvisoryFieldChangeEmitterTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task EmitChangesAsync_FormatsCvssScoreWithInvariantCulture()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("fr-FR");
CultureInfo.CurrentUICulture = new CultureInfo("fr-FR");
var timeProvider = new FakeTimeProvider(FixedNow);
var publisher = new RecordingPublisher();
var emitter = new AdvisoryFieldChangeEmitter(
publisher,
NullLogger<AdvisoryFieldChangeEmitter>.Instance,
timeProvider);
var previousProvenance = new VendorRiskProvenance("vendor", "source", "hash-prev", FixedNow, null, null);
var currentProvenance = new VendorRiskProvenance("vendor", "source", "hash-cur", FixedNow, null, null);
var previousSignal = new VendorRiskSignal(
TenantId: "tenant-1",
AdvisoryId: "CVE-2025-0001",
ObservationId: "obs-1",
Provenance: previousProvenance,
CvssScores: ImmutableArray.Create(new VendorCvssScore("cvss_v31", 7.5, null, null, previousProvenance)),
KevStatus: null,
FixAvailability: ImmutableArray<VendorFixAvailability>.Empty,
ExtractedAt: FixedNow);
var currentSignal = new VendorRiskSignal(
TenantId: "tenant-1",
AdvisoryId: "CVE-2025-0001",
ObservationId: "obs-1",
Provenance: currentProvenance,
CvssScores: ImmutableArray.Create(new VendorCvssScore("cvss_v31", 8.0, null, null, currentProvenance)),
KevStatus: null,
FixAvailability: ImmutableArray<VendorFixAvailability>.Empty,
ExtractedAt: FixedNow);
var notification = await emitter.EmitChangesAsync(
tenantId: "tenant-1",
observationId: "obs-1",
previousSignal: previousSignal,
currentSignal: currentSignal,
linksetId: null,
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(notification);
var change = notification!.Changes.First(c => c.Field == "cvss_score");
Assert.Equal("7.5", change.PreviousValue);
Assert.Equal("8.0", change.CurrentValue);
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
private sealed class RecordingPublisher : IAdvisoryFieldChangeNotificationPublisher
{
public AdvisoryFieldChangeNotification? LastNotification { get; private set; }
public Task PublishAsync(AdvisoryFieldChangeNotification notification, CancellationToken cancellationToken)
{
LastNotification = notification;
return Task.CompletedTask;
}
}
}

View File

@@ -7,7 +7,8 @@ namespace StellaOps.Concelier.Core.Tests.Signals;
public sealed class AffectedSymbolProviderTests
{
private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.UtcNow);
private static readonly DateTimeOffset FixedNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _timeProvider = new(FixedNow);
[Fact]
public async Task GetByAdvisoryAsync_ReturnsEmptySetForUnknownAdvisory()
@@ -268,52 +269,52 @@ public sealed class AffectedSymbolProviderTests
[Fact]
public void AffectedSymbol_CanonicalId_GeneratesCorrectFormat()
{
var provenance = AffectedSymbolProvenance.FromOsv("sha256:test", DateTimeOffset.UtcNow);
var provenance = AffectedSymbolProvenance.FromOsv("sha256:test", FixedNow);
var function = AffectedSymbol.Function(
"tenant-1", "CVE-2024-0001", "obs-1", "myFunc", provenance, DateTimeOffset.UtcNow,
"tenant-1", "CVE-2024-0001", "obs-1", "myFunc", provenance, FixedNow,
module: "myModule");
Assert.Equal("myModule::myFunc", function.CanonicalId);
var method = AffectedSymbol.Method(
"tenant-1", "CVE-2024-0001", "obs-1", "myMethod", "MyClass", provenance, DateTimeOffset.UtcNow,
"tenant-1", "CVE-2024-0001", "obs-1", "myMethod", "MyClass", provenance, FixedNow,
module: "myModule");
Assert.Equal("myModule::MyClass.myMethod", method.CanonicalId);
var globalFunc = AffectedSymbol.Function(
"tenant-1", "CVE-2024-0001", "obs-1", "globalFunc", provenance, DateTimeOffset.UtcNow);
"tenant-1", "CVE-2024-0001", "obs-1", "globalFunc", provenance, FixedNow);
Assert.Equal("global::globalFunc", globalFunc.CanonicalId);
}
[Fact]
public void AffectedSymbol_HasSourceLocation_ReturnsCorrectValue()
{
var provenance = AffectedSymbolProvenance.FromOsv("sha256:test", DateTimeOffset.UtcNow);
var provenance = AffectedSymbolProvenance.FromOsv("sha256:test", FixedNow);
var withLocation = AffectedSymbol.Function(
"tenant-1", "CVE-2024-0001", "obs-1", "func1", provenance, DateTimeOffset.UtcNow,
"tenant-1", "CVE-2024-0001", "obs-1", "func1", provenance, FixedNow,
filePath: "/src/lib.js", lineNumber: 42);
Assert.True(withLocation.HasSourceLocation);
var withoutLocation = AffectedSymbol.Function(
"tenant-1", "CVE-2024-0001", "obs-1", "func2", provenance, DateTimeOffset.UtcNow);
"tenant-1", "CVE-2024-0001", "obs-1", "func2", provenance, FixedNow);
Assert.False(withoutLocation.HasSourceLocation);
}
[Fact]
public void AffectedSymbolSet_UniqueSymbolCount_CountsDistinctCanonicalIds()
{
var provenance = AffectedSymbolProvenance.FromOsv("sha256:test", DateTimeOffset.UtcNow);
var provenance = AffectedSymbolProvenance.FromOsv("sha256:test", FixedNow);
var symbols = ImmutableArray.Create(
AffectedSymbol.Function("tenant-1", "CVE-2024-0001", "obs-1", "func1", provenance, DateTimeOffset.UtcNow, module: "mod1"),
AffectedSymbol.Function("tenant-1", "CVE-2024-0001", "obs-2", "func1", provenance, DateTimeOffset.UtcNow, module: "mod1"), // duplicate
AffectedSymbol.Function("tenant-1", "CVE-2024-0001", "obs-3", "func2", provenance, DateTimeOffset.UtcNow, module: "mod1")
AffectedSymbol.Function("tenant-1", "CVE-2024-0001", "obs-1", "func1", provenance, FixedNow, module: "mod1"),
AffectedSymbol.Function("tenant-1", "CVE-2024-0001", "obs-2", "func1", provenance, FixedNow, module: "mod1"), // duplicate
AffectedSymbol.Function("tenant-1", "CVE-2024-0001", "obs-3", "func2", provenance, FixedNow, module: "mod1")
);
var set = new AffectedSymbolSet(
"tenant-1", "CVE-2024-0001", symbols,
ImmutableArray<AffectedSymbolSourceSummary>.Empty, DateTimeOffset.UtcNow);
ImmutableArray<AffectedSymbolSourceSummary>.Empty, FixedNow);
Assert.Equal(2, set.UniqueSymbolCount);
}
@@ -321,7 +322,7 @@ public sealed class AffectedSymbolProviderTests
[Fact]
public void AffectedSymbolProvenance_FromOsv_CreatesCorrectProvenance()
{
var now = DateTimeOffset.UtcNow;
var now = FixedNow;
var provenance = AffectedSymbolProvenance.FromOsv(
observationHash: "sha256:abc123",
fetchedAt: now,
@@ -340,7 +341,7 @@ public sealed class AffectedSymbolProviderTests
[Fact]
public void AffectedSymbolProvenance_FromNvd_CreatesCorrectProvenance()
{
var now = DateTimeOffset.UtcNow;
var now = FixedNow;
var provenance = AffectedSymbolProvenance.FromNvd(
observationHash: "sha256:def456",
fetchedAt: now,
@@ -355,7 +356,7 @@ public sealed class AffectedSymbolProviderTests
[Fact]
public void AffectedSymbolProvenance_FromGhsa_CreatesCorrectProvenance()
{
var now = DateTimeOffset.UtcNow;
var now = FixedNow;
var provenance = AffectedSymbolProvenance.FromGhsa(
observationHash: "sha256:ghi789",
fetchedAt: now,

View File

@@ -0,0 +1,545 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Federation.Export;
using StellaOps.Concelier.Federation.Import;
using StellaOps.Concelier.Federation.Models;
using StellaOps.Concelier.WebService.Options;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Concelier.WebService.Tests;
public sealed class FederationEndpointTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationDisabled_ReturnsServiceUnavailable()
{
using var factory = new FederationWebAppFactory(federationEnabled: false, FixedNow);
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/federation/export");
response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable);
var payload = await ReadJsonAsync(response);
payload.GetProperty("error").GetProperty("code").GetString().Should().Be("FEDERATION_DISABLED");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationStatus_ReturnsConfiguration()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/federation/status");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("enabled").GetBoolean().Should().BeTrue();
payload.GetProperty("site_id").GetString().Should().Be("site-a");
payload.GetProperty("default_compression_level").GetInt32().Should().Be(3);
payload.GetProperty("default_max_items").GetInt32().Should().Be(10000);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationExport_ReturnsHeaders()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/federation/export");
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Headers.TryGetValues("X-Bundle-Hash", out var hashValues).Should().BeTrue();
hashValues!.Single().Should().Be("sha256:bundle");
response.Headers.TryGetValues("X-Export-Cursor", out var cursorValues).Should().BeTrue();
cursorValues!.Single().Should().Be("cursor-1");
response.Headers.TryGetValues("X-Items-Count", out var countValues).Should().BeTrue();
countValues!.Single().Should().Be("3");
response.Headers.TryGetValues("Content-Disposition", out var dispositionValues).Should().BeTrue();
dispositionValues!.Single().Should().Contain("feedser-bundle-20250101-000000.zst");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationExportPreview_ReturnsPreview()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/federation/export/preview?since_cursor=cursor-0");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("since_cursor").GetString().Should().Be("cursor-0");
payload.GetProperty("estimated_canonicals").GetInt32().Should().Be(5);
payload.GetProperty("estimated_edges").GetInt32().Should().Be(6);
payload.GetProperty("estimated_deletions").GetInt32().Should().Be(7);
payload.GetProperty("estimated_size_bytes").GetInt64().Should().Be(1024);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationImport_ReturnsSuccess()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
using var content = new ByteArrayContent(Encoding.UTF8.GetBytes("bundle"));
content.Headers.ContentType = new MediaTypeHeaderValue("application/zstd");
var response = await client.PostAsync("/api/v1/federation/import", content);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("success").GetBoolean().Should().BeTrue();
payload.GetProperty("bundle_hash").GetString().Should().Be("sha256:import");
payload.GetProperty("imported_cursor").GetString().Should().Be("cursor-2");
payload.GetProperty("counts").GetProperty("total").GetInt32().Should().Be(3);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationValidate_ReturnsValidation()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
using var content = new ByteArrayContent(Encoding.UTF8.GetBytes("bundle"));
content.Headers.ContentType = new MediaTypeHeaderValue("application/zstd");
var response = await client.PostAsync("/api/v1/federation/import/validate", content);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("is_valid").GetBoolean().Should().BeTrue();
payload.GetProperty("hash_valid").GetBoolean().Should().BeTrue();
payload.GetProperty("signature_valid").GetBoolean().Should().BeTrue();
payload.GetProperty("cursor_valid").GetBoolean().Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationPreview_ReturnsManifest()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
using var content = new ByteArrayContent(Encoding.UTF8.GetBytes("bundle"));
content.Headers.ContentType = new MediaTypeHeaderValue("application/zstd");
var response = await client.PostAsync("/api/v1/federation/import/preview", content);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("is_valid").GetBoolean().Should().BeTrue();
payload.GetProperty("manifest").GetProperty("site_id").GetString().Should().Be("site-a");
payload.GetProperty("manifest").GetProperty("export_cursor").GetString().Should().Be("cursor-1");
payload.GetProperty("manifest").GetProperty("bundle_hash").GetString().Should().Be("sha256:preview");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationSites_ReturnsPolicies()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/federation/sites");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("count").GetInt32().Should().Be(1);
var sites = payload.GetProperty("sites").EnumerateArray().ToList();
sites.Should().HaveCount(1);
sites[0].GetProperty("site_id").GetString().Should().Be("site-a");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationSite_ReturnsDetails()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/federation/sites/site-a");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("site_id").GetString().Should().Be("site-a");
payload.GetProperty("recent_history").EnumerateArray().Should().NotBeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationSitePolicy_UpdatesPolicy()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
var body = new
{
displayName = "Updated Site",
enabled = false,
allowedSources = new[] { "nvd" },
maxBundleSizeBytes = 512L
};
var response = await client.PutAsJsonAsync("/api/v1/federation/sites/site-a/policy", body);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("site_id").GetString().Should().Be("site-a");
payload.GetProperty("display_name").GetString().Should().Be("Updated Site");
payload.GetProperty("enabled").GetBoolean().Should().BeFalse();
payload.GetProperty("max_bundle_size_bytes").GetInt64().Should().Be(512);
}
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response)
{
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}
private sealed class FederationWebAppFactory : WebApplicationFactory<Program>
{
private readonly bool _federationEnabled;
private readonly DateTimeOffset _fixedNow;
public FederationWebAppFactory(bool federationEnabled, DateTimeOffset fixedNow)
{
_federationEnabled = federationEnabled;
_fixedNow = fixedNow;
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-federation");
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((_, config) =>
{
var overrides = new Dictionary<string, string?>
{
{"Concelier:PostgresStorage:Enabled", "true"},
{"Concelier:PostgresStorage:ConnectionString", "Host=localhost;Port=5432;Database=test-federation"},
{"Concelier:PostgresStorage:CommandTimeoutSeconds", "30"},
{"Concelier:Telemetry:Enabled", "false"},
{"Concelier:Federation:Enabled", _federationEnabled ? "true" : "false"},
{"Concelier:Federation:SiteId", "site-a"},
{"Concelier:Federation:DefaultCompressionLevel", "3"},
{"Concelier:Federation:DefaultMaxItems", "10000"}
};
config.AddInMemoryCollection(overrides);
});
builder.ConfigureServices(services =>
{
services.RemoveAll<IBundleExportService>();
services.RemoveAll<IBundleImportService>();
services.RemoveAll<ISyncLedgerRepository>();
services.RemoveAll<TimeProvider>();
services.RemoveAll<IOptions<ConcelierOptions>>();
services.RemoveAll<ConcelierOptions>();
var options = new ConcelierOptions
{
PostgresStorage = new ConcelierOptions.PostgresStorageOptions
{
Enabled = true,
ConnectionString = "Host=localhost;Port=5432;Database=test-federation",
CommandTimeoutSeconds = 30,
SchemaName = "vuln"
},
Telemetry = new ConcelierOptions.TelemetryOptions
{
Enabled = false
},
Federation = new ConcelierOptions.FederationOptions
{
Enabled = _federationEnabled,
SiteId = "site-a",
DefaultCompressionLevel = 3,
DefaultMaxItems = 10000,
RequireSignature = true
}
};
services.AddSingleton(options);
services.AddSingleton<IOptions<ConcelierOptions>>(Options.Create(options));
services.AddSingleton<TimeProvider>(new FixedTimeProvider(_fixedNow));
services.AddSingleton<IBundleExportService>(new FakeBundleExportService());
services.AddSingleton<IBundleImportService>(new FakeBundleImportService(_fixedNow));
services.AddSingleton<ISyncLedgerRepository>(new FakeSyncLedgerRepository(_fixedNow));
});
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public override long GetTimestamp() => 0;
public override TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp) => TimeSpan.Zero;
}
private sealed class FakeBundleExportService : IBundleExportService
{
private readonly byte[] _payload = Encoding.UTF8.GetBytes("bundle");
public Task<BundleExportResult> ExportAsync(string? sinceCursor = null, BundleExportOptions? options = null, CancellationToken ct = default)
{
return Task.FromResult(CreateResult(sinceCursor));
}
public async Task<BundleExportResult> ExportToStreamAsync(Stream output, string? sinceCursor = null, BundleExportOptions? options = null, CancellationToken ct = default)
{
await output.WriteAsync(_payload, ct);
return CreateResult(sinceCursor);
}
public Task<BundleExportPreview> PreviewAsync(string? sinceCursor = null, CancellationToken ct = default)
{
return Task.FromResult(new BundleExportPreview
{
EstimatedCanonicals = 5,
EstimatedEdges = 6,
EstimatedDeletions = 7,
EstimatedSizeBytes = 1024
});
}
private static BundleExportResult CreateResult(string? sinceCursor) => new()
{
BundleHash = "sha256:bundle",
ExportCursor = "cursor-1",
SinceCursor = sinceCursor,
Counts = new BundleCounts
{
Canonicals = 1,
Edges = 1,
Deletions = 1
},
CompressedSizeBytes = 3,
Duration = TimeSpan.FromSeconds(1)
};
}
private sealed class FakeBundleImportService : IBundleImportService
{
private readonly DateTimeOffset _now;
public FakeBundleImportService(DateTimeOffset now) => _now = now;
public Task<BundleImportResult> ImportAsync(Stream bundleStream, BundleImportOptions? options = null, CancellationToken cancellationToken = default)
{
return Task.FromResult(BundleImportResult.Succeeded(
"sha256:import",
"cursor-2",
new ImportCounts
{
CanonicalCreated = 1,
CanonicalUpdated = 1,
EdgesAdded = 1
},
duration: TimeSpan.FromSeconds(1)));
}
public Task<BundleImportResult> ImportFromFileAsync(string filePath, BundleImportOptions? options = null, CancellationToken cancellationToken = default)
{
return ImportAsync(Stream.Null, options, cancellationToken);
}
public Task<BundleValidationResult> ValidateAsync(Stream bundleStream, CancellationToken cancellationToken = default)
{
var manifest = CreateManifest("cursor-1", "sha256:preview");
return Task.FromResult(new BundleValidationResult
{
IsValid = true,
HashValid = true,
SignatureValid = true,
CursorValid = true,
Manifest = manifest
});
}
public Task<BundleImportPreview> PreviewAsync(Stream bundleStream, CancellationToken cancellationToken = default)
{
return Task.FromResult(new BundleImportPreview
{
Manifest = CreateManifest("cursor-1", "sha256:preview"),
IsValid = true,
IsDuplicate = false,
CurrentCursor = "cursor-0"
});
}
private BundleManifest CreateManifest(string exportCursor, string bundleHash) => new()
{
SiteId = "site-a",
ExportCursor = exportCursor,
BundleHash = bundleHash,
ExportedAt = _now,
Counts = new BundleCounts
{
Canonicals = 1,
Edges = 1,
Deletions = 1
}
};
}
private sealed class FakeSyncLedgerRepository : ISyncLedgerRepository
{
private readonly List<SitePolicy> _policies;
private readonly List<SyncLedgerEntry> _entries;
public FakeSyncLedgerRepository(DateTimeOffset now)
{
_policies =
[
new SitePolicy
{
SiteId = "site-a",
DisplayName = "Site A",
Enabled = true,
LastSyncAt = now,
LastCursor = "cursor-1",
TotalImports = 1,
AllowedSources = ["nvd", "osv"],
MaxBundleSizeBytes = 1024
}
];
_entries =
[
new SyncLedgerEntry
{
SiteId = "site-a",
Cursor = "cursor-1",
BundleHash = "sha256:bundle",
ItemCount = 3,
ExportedAt = now.AddMinutes(-10),
ImportedAt = now.AddMinutes(-9)
}
];
}
public Task<string?> GetCursorAsync(string siteId, CancellationToken ct = default)
{
var policy = _policies.FirstOrDefault(p => p.SiteId == siteId);
return Task.FromResult(policy?.LastCursor);
}
public Task<SyncLedgerEntry?> GetByBundleHashAsync(string bundleHash, CancellationToken ct = default)
{
var entry = _entries.FirstOrDefault(e => e.BundleHash == bundleHash);
return Task.FromResult(entry);
}
public Task AdvanceCursorAsync(string siteId, string cursor, string bundleHash, int itemCount, DateTimeOffset exportedAt, CancellationToken ct = default)
{
_entries.Add(new SyncLedgerEntry
{
SiteId = siteId,
Cursor = cursor,
BundleHash = bundleHash,
ItemCount = itemCount,
ExportedAt = exportedAt,
ImportedAt = exportedAt
});
var policyIndex = _policies.FindIndex(p => p.SiteId == siteId);
if (policyIndex >= 0)
{
var current = _policies[policyIndex];
_policies[policyIndex] = current with
{
LastCursor = cursor,
LastSyncAt = exportedAt,
TotalImports = current.TotalImports + 1
};
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<SitePolicy>> GetAllPoliciesAsync(bool enabledOnly = true, CancellationToken ct = default)
{
IReadOnlyList<SitePolicy> policies = enabledOnly
? _policies.Where(p => p.Enabled).ToList()
: _policies.ToList();
return Task.FromResult(policies);
}
public Task<SitePolicy?> GetPolicyAsync(string siteId, CancellationToken ct = default)
{
var policy = _policies.FirstOrDefault(p => p.SiteId == siteId);
return Task.FromResult(policy);
}
public Task UpsertPolicyAsync(SitePolicy policy, CancellationToken ct = default)
{
var index = _policies.FindIndex(p => p.SiteId == policy.SiteId);
if (index >= 0)
{
_policies[index] = policy;
}
else
{
_policies.Add(policy);
}
return Task.CompletedTask;
}
public Task<SyncLedgerEntry?> GetLatestAsync(string siteId, CancellationToken ct = default)
{
var latest = _entries.LastOrDefault(e => e.SiteId == siteId);
return Task.FromResult(latest);
}
public async IAsyncEnumerable<SyncLedgerEntry> GetHistoryAsync(
string siteId,
int limit,
[EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var entry in _entries.Where(e => e.SiteId == siteId).Take(limit))
{
yield return entry;
await Task.Yield();
}
}
}
}