audit, advisories and doctors/setup work
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}'")
|
||||
{
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (<0.5), unknown (null).</param>
|
||||
/// <param name="Tier">Confidence tier: high (>=0.9), medium (>=0.7), low (>=0.5), very-low (<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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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 CI1–CI10 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user