notify doctors work, audit work, new product advisory sprints
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GreyQueueEndpoints.cs
|
||||
// Description: Minimal API endpoints for Grey Queue management.
|
||||
// Implements signed, replayable evidence pipeline for ambiguous unknowns.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Core.Repositories;
|
||||
|
||||
namespace StellaOps.Unknowns.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal API endpoints for Grey Queue service.
|
||||
/// </summary>
|
||||
public static class GreyQueueEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps all Grey Queue endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapGreyQueueEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/grey-queue")
|
||||
.WithTags("GreyQueue")
|
||||
.WithOpenApi();
|
||||
|
||||
// List and query
|
||||
group.MapGet("/", ListEntries)
|
||||
.WithName("ListGreyQueueEntries")
|
||||
.WithSummary("List grey queue entries with pagination")
|
||||
.WithDescription("Returns paginated list of grey queue entries. Supports filtering by status and reason.");
|
||||
|
||||
group.MapGet("/{id:guid}", GetEntryById)
|
||||
.WithName("GetGreyQueueEntry")
|
||||
.WithSummary("Get grey queue entry by ID")
|
||||
.WithDescription("Returns a single grey queue entry with full evidence bundle.");
|
||||
|
||||
group.MapGet("/by-unknown/{unknownId:guid}", GetByUnknownId)
|
||||
.WithName("GetGreyQueueByUnknownId")
|
||||
.WithSummary("Get grey queue entry by unknown ID")
|
||||
.WithDescription("Returns the grey queue entry for a specific unknown.");
|
||||
|
||||
group.MapGet("/ready", GetReadyForProcessing)
|
||||
.WithName("GetReadyForProcessing")
|
||||
.WithSummary("Get entries ready for processing")
|
||||
.WithDescription("Returns entries that are ready to be processed (pending, not exhausted, past next processing time).");
|
||||
|
||||
// Triggers
|
||||
group.MapGet("/triggers/feed/{feedId}", GetByFeedTrigger)
|
||||
.WithName("GetByFeedTrigger")
|
||||
.WithSummary("Get entries triggered by feed update")
|
||||
.WithDescription("Returns entries that should be reprocessed due to a feed update.");
|
||||
|
||||
group.MapGet("/triggers/tool/{toolId}", GetByToolTrigger)
|
||||
.WithName("GetByToolTrigger")
|
||||
.WithSummary("Get entries triggered by tool update")
|
||||
.WithDescription("Returns entries that should be reprocessed due to a tool update.");
|
||||
|
||||
group.MapGet("/triggers/cve/{cveId}", GetByCveTrigger)
|
||||
.WithName("GetByCveTrigger")
|
||||
.WithSummary("Get entries triggered by CVE update")
|
||||
.WithDescription("Returns entries that should be reprocessed due to a CVE update.");
|
||||
|
||||
// Actions
|
||||
group.MapPost("/", EnqueueEntry)
|
||||
.WithName("EnqueueGreyQueueEntry")
|
||||
.WithSummary("Enqueue a new grey queue entry")
|
||||
.WithDescription("Creates a new grey queue entry with evidence bundle and trigger conditions.");
|
||||
|
||||
group.MapPost("/{id:guid}/process", StartProcessing)
|
||||
.WithName("StartGreyQueueProcessing")
|
||||
.WithSummary("Mark entry as processing")
|
||||
.WithDescription("Marks an entry as currently being processed.");
|
||||
|
||||
group.MapPost("/{id:guid}/result", RecordResult)
|
||||
.WithName("RecordGreyQueueResult")
|
||||
.WithSummary("Record processing result")
|
||||
.WithDescription("Records the result of a processing attempt.");
|
||||
|
||||
group.MapPost("/{id:guid}/resolve", ResolveEntry)
|
||||
.WithName("ResolveGreyQueueEntry")
|
||||
.WithSummary("Resolve a grey queue entry")
|
||||
.WithDescription("Marks an entry as resolved with resolution type and reference.");
|
||||
|
||||
group.MapPost("/{id:guid}/dismiss", DismissEntry)
|
||||
.WithName("DismissGreyQueueEntry")
|
||||
.WithSummary("Dismiss a grey queue entry")
|
||||
.WithDescription("Manually dismisses an entry from the queue.");
|
||||
|
||||
// Maintenance
|
||||
group.MapPost("/expire", ExpireOldEntries)
|
||||
.WithName("ExpireGreyQueueEntries")
|
||||
.WithSummary("Expire old entries")
|
||||
.WithDescription("Expires entries that have exceeded their TTL.");
|
||||
|
||||
// Statistics
|
||||
group.MapGet("/summary", GetSummary)
|
||||
.WithName("GetGreyQueueSummary")
|
||||
.WithSummary("Get grey queue summary statistics")
|
||||
.WithDescription("Returns summary counts by status, reason, and performance metrics.");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
// List entries with pagination
|
||||
private static async Task<Ok<GreyQueueListResponse>> ListEntries(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50,
|
||||
[FromQuery] GreyQueueStatus? status = null,
|
||||
[FromQuery] GreyQueueReason? reason = null,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
IReadOnlyList<GreyQueueEntry> entries;
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
entries = await repository.GetByStatusAsync(tenantId, status.Value, take, skip, ct);
|
||||
}
|
||||
else if (reason.HasValue)
|
||||
{
|
||||
entries = await repository.GetByReasonAsync(tenantId, reason.Value, take, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
entries = await repository.GetByStatusAsync(tenantId, GreyQueueStatus.Pending, take, skip, ct);
|
||||
}
|
||||
|
||||
var total = await repository.CountPendingAsync(tenantId, ct);
|
||||
|
||||
var response = new GreyQueueListResponse
|
||||
{
|
||||
Items = entries.Select(MapToDto).ToList(),
|
||||
Total = total,
|
||||
Skip = skip,
|
||||
Take = take
|
||||
};
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
// Get entry by ID
|
||||
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> GetEntryById(
|
||||
Guid id,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entry = await repository.GetByIdAsync(tenantId, id, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
return TypedResults.Ok(MapToDto(entry));
|
||||
}
|
||||
|
||||
// Get by unknown ID
|
||||
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> GetByUnknownId(
|
||||
Guid unknownId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entry = await repository.GetByUnknownIdAsync(tenantId, unknownId, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
return TypedResults.Ok(MapToDto(entry));
|
||||
}
|
||||
|
||||
// Get ready for processing
|
||||
private static async Task<Ok<GreyQueueListResponse>> GetReadyForProcessing(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
[FromQuery] int limit = 50,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entries = await repository.GetReadyForProcessingAsync(tenantId, limit, ct);
|
||||
|
||||
var response = new GreyQueueListResponse
|
||||
{
|
||||
Items = entries.Select(MapToDto).ToList(),
|
||||
Total = entries.Count,
|
||||
Skip = 0,
|
||||
Take = limit
|
||||
};
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
// Get by feed trigger
|
||||
private static async Task<Ok<GreyQueueListResponse>> GetByFeedTrigger(
|
||||
string feedId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
[FromQuery] string? version = null,
|
||||
[FromQuery] int limit = 50,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entries = await repository.GetByFeedTriggerAsync(tenantId, feedId, version, limit, ct);
|
||||
|
||||
var response = new GreyQueueListResponse
|
||||
{
|
||||
Items = entries.Select(MapToDto).ToList(),
|
||||
Total = entries.Count,
|
||||
Skip = 0,
|
||||
Take = limit
|
||||
};
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
// Get by tool trigger
|
||||
private static async Task<Ok<GreyQueueListResponse>> GetByToolTrigger(
|
||||
string toolId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
[FromQuery] string? version = null,
|
||||
[FromQuery] int limit = 50,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entries = await repository.GetByToolTriggerAsync(tenantId, toolId, version, limit, ct);
|
||||
|
||||
var response = new GreyQueueListResponse
|
||||
{
|
||||
Items = entries.Select(MapToDto).ToList(),
|
||||
Total = entries.Count,
|
||||
Skip = 0,
|
||||
Take = limit
|
||||
};
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
// Get by CVE trigger
|
||||
private static async Task<Ok<GreyQueueListResponse>> GetByCveTrigger(
|
||||
string cveId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
[FromQuery] int limit = 50,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entries = await repository.GetByCveTriggerAsync(tenantId, cveId, limit, ct);
|
||||
|
||||
var response = new GreyQueueListResponse
|
||||
{
|
||||
Items = entries.Select(MapToDto).ToList(),
|
||||
Total = entries.Count,
|
||||
Skip = 0,
|
||||
Take = limit
|
||||
};
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
// Enqueue new entry
|
||||
private static async Task<Created<GreyQueueEntryDto>> EnqueueEntry(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
[FromBody] EnqueueGreyQueueRequest request,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var evidence = request.Evidence is not null ? new GreyQueueEvidenceBundle
|
||||
{
|
||||
SbomSliceJson = request.Evidence.SbomSliceJson,
|
||||
AdvisorySnippetJson = request.Evidence.AdvisorySnippetJson,
|
||||
VexEvidenceJson = request.Evidence.VexEvidenceJson,
|
||||
DiffTracesJson = request.Evidence.DiffTracesJson,
|
||||
ReachabilityEvidenceJson = request.Evidence.ReachabilityEvidenceJson
|
||||
} : null;
|
||||
|
||||
var triggers = request.Triggers is not null ? new GreyQueueTriggers
|
||||
{
|
||||
Feeds = request.Triggers.Feeds?.Select(f => new FeedTrigger(f.FeedId, f.MinVersion)).ToList() ?? [],
|
||||
Tools = request.Triggers.Tools?.Select(t => new ToolTrigger(t.ToolId, t.MinVersion)).ToList() ?? [],
|
||||
CveIds = request.Triggers.CveIds ?? [],
|
||||
PurlPatterns = request.Triggers.PurlPatterns ?? []
|
||||
} : null;
|
||||
|
||||
var entry = await repository.EnqueueAsync(
|
||||
tenantId,
|
||||
request.UnknownId,
|
||||
request.Reason,
|
||||
request.ReasonDetail,
|
||||
evidence,
|
||||
triggers,
|
||||
request.Priority,
|
||||
request.CreatedBy,
|
||||
request.CorrelationId,
|
||||
ct);
|
||||
|
||||
return TypedResults.Created($"/api/grey-queue/{entry.Id}", MapToDto(entry));
|
||||
}
|
||||
|
||||
// Start processing
|
||||
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> StartProcessing(
|
||||
Guid id,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = await repository.StartProcessingAsync(tenantId, id, ct);
|
||||
return TypedResults.Ok(MapToDto(entry));
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
// Record result
|
||||
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> RecordResult(
|
||||
Guid id,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
[FromBody] RecordResultRequest request,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = await repository.RecordProcessingResultAsync(
|
||||
tenantId,
|
||||
id,
|
||||
request.Success,
|
||||
request.Result,
|
||||
request.NextProcessingAt,
|
||||
ct);
|
||||
return TypedResults.Ok(MapToDto(entry));
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve entry
|
||||
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> ResolveEntry(
|
||||
Guid id,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
[FromBody] ResolveEntryRequest request,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = await repository.ResolveAsync(
|
||||
tenantId,
|
||||
id,
|
||||
request.Resolution,
|
||||
request.ResolutionRef,
|
||||
ct);
|
||||
return TypedResults.Ok(MapToDto(entry));
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss entry
|
||||
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> DismissEntry(
|
||||
Guid id,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
[FromBody] DismissEntryRequest request,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = await repository.DismissAsync(
|
||||
tenantId,
|
||||
id,
|
||||
request.DismissedBy,
|
||||
request.Reason,
|
||||
ct);
|
||||
return TypedResults.Ok(MapToDto(entry));
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
// Expire old entries
|
||||
private static async Task<Ok<ExpireResultResponse>> ExpireOldEntries(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var count = await repository.ExpireOldEntriesAsync(tenantId, ct);
|
||||
return TypedResults.Ok(new ExpireResultResponse { ExpiredCount = count });
|
||||
}
|
||||
|
||||
// Get summary
|
||||
private static async Task<Ok<GreyQueueSummaryDto>> GetSummary(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
||||
IGreyQueueRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var summary = await repository.GetSummaryAsync(tenantId, ct);
|
||||
|
||||
var response = new GreyQueueSummaryDto
|
||||
{
|
||||
Total = summary.Total,
|
||||
Pending = summary.Pending,
|
||||
Processing = summary.Processing,
|
||||
Retrying = summary.Retrying,
|
||||
Resolved = summary.Resolved,
|
||||
Failed = summary.Failed,
|
||||
Expired = summary.Expired,
|
||||
Dismissed = summary.Dismissed,
|
||||
ByReason = summary.ByReason,
|
||||
AvgAttemptsToResolve = summary.AvgAttemptsToResolve,
|
||||
AvgHoursToResolve = summary.AvgHoursToResolve,
|
||||
OldestPendingHours = summary.OldestPendingHours
|
||||
};
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
// Mapping helpers
|
||||
private static GreyQueueEntryDto MapToDto(GreyQueueEntry e) => new()
|
||||
{
|
||||
Id = e.Id,
|
||||
TenantId = e.TenantId,
|
||||
UnknownId = e.UnknownId,
|
||||
Fingerprint = e.Fingerprint,
|
||||
Status = e.Status.ToString(),
|
||||
Priority = e.Priority,
|
||||
Reason = e.Reason.ToString(),
|
||||
ReasonDetail = e.ReasonDetail,
|
||||
HasSbomSlice = e.SbomSlice is not null,
|
||||
HasAdvisorySnippet = e.AdvisorySnippet is not null,
|
||||
HasVexEvidence = e.VexEvidence is not null,
|
||||
HasDiffTraces = e.DiffTraces is not null,
|
||||
HasReachabilityEvidence = e.ReachabilityEvidence is not null,
|
||||
ProcessingAttempts = e.ProcessingAttempts,
|
||||
MaxAttempts = e.MaxAttempts,
|
||||
LastProcessedAt = e.LastProcessedAt,
|
||||
LastProcessingResult = e.LastProcessingResult,
|
||||
NextProcessingAt = e.NextProcessingAt,
|
||||
TriggerCveIds = e.TriggerOnCveUpdate.ToList(),
|
||||
TriggerPurlPatterns = e.TriggerOnPurlMatch.ToList(),
|
||||
CreatedAt = e.CreatedAt,
|
||||
UpdatedAt = e.UpdatedAt,
|
||||
ExpiresAt = e.ExpiresAt,
|
||||
ResolvedAt = e.ResolvedAt,
|
||||
Resolution = e.Resolution?.ToString(),
|
||||
ResolutionRef = e.ResolutionRef,
|
||||
IsPending = e.IsPending,
|
||||
IsExhausted = e.IsExhausted,
|
||||
IsReadyForProcessing = e.IsReadyForProcessing
|
||||
};
|
||||
}
|
||||
|
||||
// DTOs
|
||||
|
||||
public sealed record GreyQueueListResponse
|
||||
{
|
||||
public required IReadOnlyList<GreyQueueEntryDto> Items { get; init; }
|
||||
public required long Total { get; init; }
|
||||
public required int Skip { get; init; }
|
||||
public required int Take { get; init; }
|
||||
}
|
||||
|
||||
public sealed record GreyQueueEntryDto
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required Guid UnknownId { get; init; }
|
||||
public required string Fingerprint { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required int Priority { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
public string? ReasonDetail { get; init; }
|
||||
public required bool HasSbomSlice { get; init; }
|
||||
public required bool HasAdvisorySnippet { get; init; }
|
||||
public required bool HasVexEvidence { get; init; }
|
||||
public required bool HasDiffTraces { get; init; }
|
||||
public required bool HasReachabilityEvidence { get; init; }
|
||||
public required int ProcessingAttempts { get; init; }
|
||||
public required int MaxAttempts { get; init; }
|
||||
public DateTimeOffset? LastProcessedAt { get; init; }
|
||||
public string? LastProcessingResult { get; init; }
|
||||
public DateTimeOffset? NextProcessingAt { get; init; }
|
||||
public required IReadOnlyList<string> TriggerCveIds { get; init; }
|
||||
public required IReadOnlyList<string> TriggerPurlPatterns { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public string? Resolution { get; init; }
|
||||
public string? ResolutionRef { get; init; }
|
||||
public required bool IsPending { get; init; }
|
||||
public required bool IsExhausted { get; init; }
|
||||
public required bool IsReadyForProcessing { get; init; }
|
||||
}
|
||||
|
||||
public sealed record GreyQueueSummaryDto
|
||||
{
|
||||
public required long Total { get; init; }
|
||||
public required long Pending { get; init; }
|
||||
public required long Processing { get; init; }
|
||||
public required long Retrying { get; init; }
|
||||
public required long Resolved { get; init; }
|
||||
public required long Failed { get; init; }
|
||||
public required long Expired { get; init; }
|
||||
public required long Dismissed { get; init; }
|
||||
public required IReadOnlyDictionary<string, long> ByReason { get; init; }
|
||||
public double AvgAttemptsToResolve { get; init; }
|
||||
public double AvgHoursToResolve { get; init; }
|
||||
public double OldestPendingHours { get; init; }
|
||||
}
|
||||
|
||||
public sealed record EnqueueGreyQueueRequest
|
||||
{
|
||||
public required Guid UnknownId { get; init; }
|
||||
public required GreyQueueReason Reason { get; init; }
|
||||
public string? ReasonDetail { get; init; }
|
||||
public EvidenceBundleDto? Evidence { get; init; }
|
||||
public TriggersDto? Triggers { get; init; }
|
||||
public int Priority { get; init; } = 100;
|
||||
public required string CreatedBy { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record EvidenceBundleDto
|
||||
{
|
||||
public string? SbomSliceJson { get; init; }
|
||||
public string? AdvisorySnippetJson { get; init; }
|
||||
public string? VexEvidenceJson { get; init; }
|
||||
public string? DiffTracesJson { get; init; }
|
||||
public string? ReachabilityEvidenceJson { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TriggersDto
|
||||
{
|
||||
public IReadOnlyList<FeedTriggerDto>? Feeds { get; init; }
|
||||
public IReadOnlyList<ToolTriggerDto>? Tools { get; init; }
|
||||
public IReadOnlyList<string>? CveIds { get; init; }
|
||||
public IReadOnlyList<string>? PurlPatterns { get; init; }
|
||||
}
|
||||
|
||||
public sealed record FeedTriggerDto
|
||||
{
|
||||
public required string FeedId { get; init; }
|
||||
public string? MinVersion { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ToolTriggerDto
|
||||
{
|
||||
public required string ToolId { get; init; }
|
||||
public string? MinVersion { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RecordResultRequest
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required string Result { get; init; }
|
||||
public DateTimeOffset? NextProcessingAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ResolveEntryRequest
|
||||
{
|
||||
public required GreyQueueResolution Resolution { get; init; }
|
||||
public string? ResolutionRef { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DismissEntryRequest
|
||||
{
|
||||
public required string DismissedBy { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ExpireResultResponse
|
||||
{
|
||||
public required int ExpiredCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GreyQueueEntry.cs
|
||||
// Description: Grey Queue entry for unknowns requiring reprocessing when feeds/tools update.
|
||||
// Implements signed, replayable evidence pipeline requirement for handling ambiguous cases.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Unknowns.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an entry in the Grey Queue - unknowns that cannot be definitively resolved
|
||||
/// and are queued for reprocessing when feeds or tools update.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Grey Queue implements the "Grey Queue" pattern from the signed SBOM-VEX-policy pipeline:
|
||||
/// - Persists full DSSE bundle (SBOM slice, advisory snippet, diff traces)
|
||||
/// - Uses deterministic fingerprints for deduplication and replay
|
||||
/// - Reprocesses when feeds/tools update
|
||||
/// </remarks>
|
||||
public sealed record GreyQueueEntry
|
||||
{
|
||||
/// <summary>Unique identifier for this queue entry.</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>Tenant that owns this entry.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Reference to the Unknown this entry relates to.</summary>
|
||||
public required Guid UnknownId { get; init; }
|
||||
|
||||
/// <summary>SHA-256 fingerprint for deterministic deduplication and replay.</summary>
|
||||
public required string Fingerprint { get; init; }
|
||||
|
||||
/// <summary>Current status of the queue entry.</summary>
|
||||
public required GreyQueueStatus Status { get; init; }
|
||||
|
||||
/// <summary>Priority for processing (lower = higher priority).</summary>
|
||||
public required int Priority { get; init; }
|
||||
|
||||
/// <summary>Reason why this entry is in the grey queue.</summary>
|
||||
public required GreyQueueReason Reason { get; init; }
|
||||
|
||||
/// <summary>Human-readable description of why resolution failed.</summary>
|
||||
public string? ReasonDetail { get; init; }
|
||||
|
||||
// Evidence Bundle (DSSE-style)
|
||||
|
||||
/// <summary>SBOM slice relevant to this unknown (JSON).</summary>
|
||||
public JsonDocument? SbomSlice { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of the SBOM slice for integrity.</summary>
|
||||
public string? SbomSliceHash { get; init; }
|
||||
|
||||
/// <summary>Advisory snippet relevant to this unknown (JSON).</summary>
|
||||
public JsonDocument? AdvisorySnippet { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of the advisory snippet for integrity.</summary>
|
||||
public string? AdvisorySnippetHash { get; init; }
|
||||
|
||||
/// <summary>VEX evidence relevant to this unknown (JSON).</summary>
|
||||
public JsonDocument? VexEvidence { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of the VEX evidence for integrity.</summary>
|
||||
public string? VexEvidenceHash { get; init; }
|
||||
|
||||
/// <summary>Diff/trace data for binary analysis (JSON).</summary>
|
||||
public JsonDocument? DiffTraces { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of diff traces for integrity.</summary>
|
||||
public string? DiffTracesHash { get; init; }
|
||||
|
||||
/// <summary>Reachability evidence if applicable (JSON).</summary>
|
||||
public JsonDocument? ReachabilityEvidence { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of reachability evidence for integrity.</summary>
|
||||
public string? ReachabilityEvidenceHash { get; init; }
|
||||
|
||||
// Processing Metadata
|
||||
|
||||
/// <summary>Number of processing attempts.</summary>
|
||||
public int ProcessingAttempts { get; init; }
|
||||
|
||||
/// <summary>Maximum allowed processing attempts before marking as failed.</summary>
|
||||
public int MaxAttempts { get; init; } = 10;
|
||||
|
||||
/// <summary>When the entry was last processed.</summary>
|
||||
public DateTimeOffset? LastProcessedAt { get; init; }
|
||||
|
||||
/// <summary>Result of the last processing attempt.</summary>
|
||||
public string? LastProcessingResult { get; init; }
|
||||
|
||||
/// <summary>When to next attempt processing.</summary>
|
||||
public DateTimeOffset? NextProcessingAt { get; init; }
|
||||
|
||||
// Trigger Conditions
|
||||
|
||||
/// <summary>Feed versions that should trigger reprocessing (JSON array of feed IDs/versions).</summary>
|
||||
public JsonDocument? TriggerOnFeedUpdate { get; init; }
|
||||
|
||||
/// <summary>Tool versions that should trigger reprocessing (JSON array of tool IDs/versions).</summary>
|
||||
public JsonDocument? TriggerOnToolUpdate { get; init; }
|
||||
|
||||
/// <summary>Specific CVE IDs that should trigger reprocessing when updated.</summary>
|
||||
public IReadOnlyList<string> TriggerOnCveUpdate { get; init; } = [];
|
||||
|
||||
/// <summary>Specific PURL patterns that should trigger reprocessing.</summary>
|
||||
public IReadOnlyList<string> TriggerOnPurlMatch { get; init; } = [];
|
||||
|
||||
// Timestamps
|
||||
|
||||
/// <summary>When this entry was created.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>When this entry was last updated.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>When this entry expires (for automatic cleanup).</summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>When this entry was resolved (moved out of grey queue).</summary>
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
|
||||
/// <summary>How this entry was resolved.</summary>
|
||||
public GreyQueueResolution? Resolution { get; init; }
|
||||
|
||||
/// <summary>Reference to resolving entity (e.g., feed update ID, tool version).</summary>
|
||||
public string? ResolutionRef { get; init; }
|
||||
|
||||
// Audit
|
||||
|
||||
/// <summary>Who created this entry.</summary>
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
/// <summary>Correlation ID for tracing.</summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
// Computed Properties
|
||||
|
||||
/// <summary>Whether this entry is still pending processing.</summary>
|
||||
public bool IsPending => Status is GreyQueueStatus.Pending or GreyQueueStatus.Retrying;
|
||||
|
||||
/// <summary>Whether this entry has exceeded max attempts.</summary>
|
||||
public bool IsExhausted => ProcessingAttempts >= MaxAttempts;
|
||||
|
||||
/// <summary>Whether this entry is ready for processing.</summary>
|
||||
public bool IsReadyForProcessing =>
|
||||
IsPending &&
|
||||
!IsExhausted &&
|
||||
(NextProcessingAt is null || NextProcessingAt <= DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Status of a grey queue entry.</summary>
|
||||
public enum GreyQueueStatus
|
||||
{
|
||||
/// <summary>Pending initial processing.</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Currently being processed.</summary>
|
||||
Processing,
|
||||
|
||||
/// <summary>Waiting for retry after failed attempt.</summary>
|
||||
Retrying,
|
||||
|
||||
/// <summary>Successfully resolved - evidence now sufficient.</summary>
|
||||
Resolved,
|
||||
|
||||
/// <summary>Failed after exhausting retries.</summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>Expired without resolution.</summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>Manually dismissed by operator.</summary>
|
||||
Dismissed
|
||||
}
|
||||
|
||||
/// <summary>Reason why an entry is in the grey queue.</summary>
|
||||
public enum GreyQueueReason
|
||||
{
|
||||
/// <summary>Insufficient VEX coverage for verdict.</summary>
|
||||
InsufficientVex,
|
||||
|
||||
/// <summary>Conflicting VEX statements from multiple sources.</summary>
|
||||
ConflictingVex,
|
||||
|
||||
/// <summary>Missing reachability evidence.</summary>
|
||||
MissingReachability,
|
||||
|
||||
/// <summary>Ambiguous package identity.</summary>
|
||||
AmbiguousIdentity,
|
||||
|
||||
/// <summary>Feed not yet available for this component.</summary>
|
||||
FeedNotAvailable,
|
||||
|
||||
/// <summary>Tool does not support this component type.</summary>
|
||||
ToolUnsupported,
|
||||
|
||||
/// <summary>Binary analysis inconclusive.</summary>
|
||||
BinaryAnalysisInconclusive,
|
||||
|
||||
/// <summary>Backport detection uncertain.</summary>
|
||||
BackportUncertain,
|
||||
|
||||
/// <summary>Multiple valid interpretations possible.</summary>
|
||||
MultipleInterpretations,
|
||||
|
||||
/// <summary>Waiting for upstream advisory.</summary>
|
||||
AwaitingUpstreamAdvisory
|
||||
}
|
||||
|
||||
/// <summary>How a grey queue entry was resolved.</summary>
|
||||
public enum GreyQueueResolution
|
||||
{
|
||||
/// <summary>Resolved by feed update providing sufficient evidence.</summary>
|
||||
FeedUpdate,
|
||||
|
||||
/// <summary>Resolved by tool update enabling analysis.</summary>
|
||||
ToolUpdate,
|
||||
|
||||
/// <summary>Resolved by VEX statement from authoritative source.</summary>
|
||||
VexReceived,
|
||||
|
||||
/// <summary>Resolved by manual operator decision.</summary>
|
||||
ManualResolution,
|
||||
|
||||
/// <summary>Superseded by newer unknown/finding.</summary>
|
||||
Superseded,
|
||||
|
||||
/// <summary>Determined to be not applicable.</summary>
|
||||
NotApplicable,
|
||||
|
||||
/// <summary>Entry expired without resolution.</summary>
|
||||
Expired
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IGreyQueueRepository.cs
|
||||
// Description: Repository interface for Grey Queue operations.
|
||||
// Supports the signed, replayable evidence pipeline pattern.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
|
||||
namespace StellaOps.Unknowns.Core.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for Grey Queue operations.
|
||||
/// </summary>
|
||||
public interface IGreyQueueRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Enqueues an unknown into the grey queue with evidence bundle.
|
||||
/// </summary>
|
||||
Task<GreyQueueEntry> EnqueueAsync(
|
||||
string tenantId,
|
||||
Guid unknownId,
|
||||
GreyQueueReason reason,
|
||||
string? reasonDetail,
|
||||
GreyQueueEvidenceBundle? evidence,
|
||||
GreyQueueTriggers? triggers,
|
||||
int priority,
|
||||
string createdBy,
|
||||
string? correlationId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a grey queue entry by ID.
|
||||
/// </summary>
|
||||
Task<GreyQueueEntry?> GetByIdAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a grey queue entry by unknown ID.
|
||||
/// </summary>
|
||||
Task<GreyQueueEntry?> GetByUnknownIdAsync(
|
||||
string tenantId,
|
||||
Guid unknownId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a grey queue entry by fingerprint (for deduplication).
|
||||
/// </summary>
|
||||
Task<GreyQueueEntry?> GetByFingerprintAsync(
|
||||
string tenantId,
|
||||
string fingerprint,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets pending entries ready for processing.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GreyQueueEntry>> GetReadyForProcessingAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets entries that should be triggered by a feed update.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GreyQueueEntry>> GetByFeedTriggerAsync(
|
||||
string tenantId,
|
||||
string feedId,
|
||||
string? feedVersion,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets entries that should be triggered by a tool update.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GreyQueueEntry>> GetByToolTriggerAsync(
|
||||
string tenantId,
|
||||
string toolId,
|
||||
string? toolVersion,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets entries that should be triggered by a CVE update.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GreyQueueEntry>> GetByCveTriggerAsync(
|
||||
string tenantId,
|
||||
string cveId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets entries filtered by status.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GreyQueueEntry>> GetByStatusAsync(
|
||||
string tenantId,
|
||||
GreyQueueStatus status,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets entries filtered by reason.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GreyQueueEntry>> GetByReasonAsync(
|
||||
string tenantId,
|
||||
GreyQueueReason reason,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Marks an entry as processing.
|
||||
/// </summary>
|
||||
Task<GreyQueueEntry> StartProcessingAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Records a processing attempt result.
|
||||
/// </summary>
|
||||
Task<GreyQueueEntry> RecordProcessingResultAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
bool success,
|
||||
string result,
|
||||
DateTimeOffset? nextProcessingAt,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a grey queue entry.
|
||||
/// </summary>
|
||||
Task<GreyQueueEntry> ResolveAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
GreyQueueResolution resolution,
|
||||
string? resolutionRef,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Dismisses a grey queue entry (manual operator action).
|
||||
/// </summary>
|
||||
Task<GreyQueueEntry> DismissAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string dismissedBy,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Expires old entries that have exceeded their TTL.
|
||||
/// </summary>
|
||||
Task<int> ExpireOldEntriesAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets summary statistics for the grey queue.
|
||||
/// </summary>
|
||||
Task<GreyQueueSummary> GetSummaryAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Counts entries by status.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<GreyQueueStatus, long>> CountByStatusAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Counts entries by reason.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<GreyQueueReason, long>> CountByReasonAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total count of pending entries.
|
||||
/// </summary>
|
||||
Task<long> CountPendingAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence bundle for grey queue entries.
|
||||
/// </summary>
|
||||
public sealed record GreyQueueEvidenceBundle
|
||||
{
|
||||
/// <summary>SBOM slice JSON.</summary>
|
||||
public string? SbomSliceJson { get; init; }
|
||||
|
||||
/// <summary>Advisory snippet JSON.</summary>
|
||||
public string? AdvisorySnippetJson { get; init; }
|
||||
|
||||
/// <summary>VEX evidence JSON.</summary>
|
||||
public string? VexEvidenceJson { get; init; }
|
||||
|
||||
/// <summary>Diff traces JSON.</summary>
|
||||
public string? DiffTracesJson { get; init; }
|
||||
|
||||
/// <summary>Reachability evidence JSON.</summary>
|
||||
public string? ReachabilityEvidenceJson { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger conditions for reprocessing.
|
||||
/// </summary>
|
||||
public sealed record GreyQueueTriggers
|
||||
{
|
||||
/// <summary>Feed IDs/versions that should trigger reprocessing.</summary>
|
||||
public IReadOnlyList<FeedTrigger> Feeds { get; init; } = [];
|
||||
|
||||
/// <summary>Tool IDs/versions that should trigger reprocessing.</summary>
|
||||
public IReadOnlyList<ToolTrigger> Tools { get; init; } = [];
|
||||
|
||||
/// <summary>CVE IDs that should trigger reprocessing.</summary>
|
||||
public IReadOnlyList<string> CveIds { get; init; } = [];
|
||||
|
||||
/// <summary>PURL patterns that should trigger reprocessing.</summary>
|
||||
public IReadOnlyList<string> PurlPatterns { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed trigger specification.
|
||||
/// </summary>
|
||||
public sealed record FeedTrigger(string FeedId, string? MinVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Tool trigger specification.
|
||||
/// </summary>
|
||||
public sealed record ToolTrigger(string ToolId, string? MinVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for the grey queue.
|
||||
/// </summary>
|
||||
public sealed record GreyQueueSummary
|
||||
{
|
||||
/// <summary>Total entries in the queue.</summary>
|
||||
public required long Total { get; init; }
|
||||
|
||||
/// <summary>Pending entries awaiting processing.</summary>
|
||||
public required long Pending { get; init; }
|
||||
|
||||
/// <summary>Entries currently being processed.</summary>
|
||||
public required long Processing { get; init; }
|
||||
|
||||
/// <summary>Entries waiting for retry.</summary>
|
||||
public required long Retrying { get; init; }
|
||||
|
||||
/// <summary>Successfully resolved entries.</summary>
|
||||
public required long Resolved { get; init; }
|
||||
|
||||
/// <summary>Failed entries (exhausted retries).</summary>
|
||||
public required long Failed { get; init; }
|
||||
|
||||
/// <summary>Expired entries.</summary>
|
||||
public required long Expired { get; init; }
|
||||
|
||||
/// <summary>Dismissed entries.</summary>
|
||||
public required long Dismissed { get; init; }
|
||||
|
||||
/// <summary>Entries by reason breakdown.</summary>
|
||||
public required IReadOnlyDictionary<string, long> ByReason { get; init; }
|
||||
|
||||
/// <summary>Average processing attempts for resolved entries.</summary>
|
||||
public double AvgAttemptsToResolve { get; init; }
|
||||
|
||||
/// <summary>Average time to resolution in hours.</summary>
|
||||
public double AvgHoursToResolve { get; init; }
|
||||
|
||||
/// <summary>Oldest pending entry age in hours.</summary>
|
||||
public double OldestPendingHours { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GreyQueueEntryTests.cs
|
||||
// Description: Unit tests for Grey Queue entry model.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Unknowns.Core.Tests.Models;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "GreyQueue")]
|
||||
public sealed class GreyQueueEntryTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsPending_WhenStatusPending_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(GreyQueueStatus.Pending);
|
||||
|
||||
// Act & Assert
|
||||
entry.IsPending.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPending_WhenStatusRetrying_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(GreyQueueStatus.Retrying);
|
||||
|
||||
// Act & Assert
|
||||
entry.IsPending.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(GreyQueueStatus.Processing)]
|
||||
[InlineData(GreyQueueStatus.Resolved)]
|
||||
[InlineData(GreyQueueStatus.Failed)]
|
||||
[InlineData(GreyQueueStatus.Expired)]
|
||||
[InlineData(GreyQueueStatus.Dismissed)]
|
||||
public void IsPending_WhenStatusNotPendingOrRetrying_ReturnsFalse(GreyQueueStatus status)
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(status);
|
||||
|
||||
// Act & Assert
|
||||
entry.IsPending.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExhausted_WhenAttemptsEqualMax_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(GreyQueueStatus.Pending) with
|
||||
{
|
||||
ProcessingAttempts = 10,
|
||||
MaxAttempts = 10
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
entry.IsExhausted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExhausted_WhenAttemptsExceedMax_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(GreyQueueStatus.Pending) with
|
||||
{
|
||||
ProcessingAttempts = 15,
|
||||
MaxAttempts = 10
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
entry.IsExhausted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExhausted_WhenAttemptsBelowMax_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(GreyQueueStatus.Pending) with
|
||||
{
|
||||
ProcessingAttempts = 5,
|
||||
MaxAttempts = 10
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
entry.IsExhausted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReadyForProcessing_WhenPendingNotExhaustedAndPastTime_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(GreyQueueStatus.Pending) with
|
||||
{
|
||||
ProcessingAttempts = 3,
|
||||
MaxAttempts = 10,
|
||||
NextProcessingAt = DateTimeOffset.UtcNow.AddHours(-1)
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
entry.IsReadyForProcessing.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReadyForProcessing_WhenPendingNotExhaustedAndNullTime_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(GreyQueueStatus.Pending) with
|
||||
{
|
||||
ProcessingAttempts = 3,
|
||||
MaxAttempts = 10,
|
||||
NextProcessingAt = null
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
entry.IsReadyForProcessing.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReadyForProcessing_WhenFutureNextProcessingTime_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(GreyQueueStatus.Pending) with
|
||||
{
|
||||
ProcessingAttempts = 3,
|
||||
MaxAttempts = 10,
|
||||
NextProcessingAt = DateTimeOffset.UtcNow.AddHours(1)
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
entry.IsReadyForProcessing.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReadyForProcessing_WhenExhausted_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(GreyQueueStatus.Pending) with
|
||||
{
|
||||
ProcessingAttempts = 10,
|
||||
MaxAttempts = 10,
|
||||
NextProcessingAt = DateTimeOffset.UtcNow.AddHours(-1)
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
entry.IsReadyForProcessing.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReadyForProcessing_WhenNotPending_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(GreyQueueStatus.Resolved) with
|
||||
{
|
||||
ProcessingAttempts = 3,
|
||||
MaxAttempts = 10,
|
||||
NextProcessingAt = DateTimeOffset.UtcNow.AddHours(-1)
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
entry.IsReadyForProcessing.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanSerializeToJson_WithAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var entry = CreateEntry(GreyQueueStatus.Pending) with
|
||||
{
|
||||
ReasonDetail = "Insufficient VEX coverage from vendors",
|
||||
TriggerOnCveUpdate = ["CVE-2025-12345", "CVE-2025-12346"],
|
||||
TriggerOnPurlMatch = ["pkg:npm/*"]
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(entry);
|
||||
var deserialized = JsonSerializer.Deserialize<GreyQueueEntry>(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.Id.Should().Be(entry.Id);
|
||||
deserialized.Reason.Should().Be(entry.Reason);
|
||||
deserialized.TriggerOnCveUpdate.Should().BeEquivalentTo(entry.TriggerOnCveUpdate);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(GreyQueueReason.InsufficientVex)]
|
||||
[InlineData(GreyQueueReason.ConflictingVex)]
|
||||
[InlineData(GreyQueueReason.MissingReachability)]
|
||||
[InlineData(GreyQueueReason.AmbiguousIdentity)]
|
||||
[InlineData(GreyQueueReason.FeedNotAvailable)]
|
||||
[InlineData(GreyQueueReason.ToolUnsupported)]
|
||||
[InlineData(GreyQueueReason.BinaryAnalysisInconclusive)]
|
||||
[InlineData(GreyQueueReason.BackportUncertain)]
|
||||
[InlineData(GreyQueueReason.MultipleInterpretations)]
|
||||
[InlineData(GreyQueueReason.AwaitingUpstreamAdvisory)]
|
||||
public void AllGreyQueueReasons_AreValid(GreyQueueReason reason)
|
||||
{
|
||||
// Arrange & Act
|
||||
var entry = CreateEntry(GreyQueueStatus.Pending) with { Reason = reason };
|
||||
|
||||
// Assert
|
||||
entry.Reason.Should().Be(reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(GreyQueueResolution.FeedUpdate)]
|
||||
[InlineData(GreyQueueResolution.ToolUpdate)]
|
||||
[InlineData(GreyQueueResolution.VexReceived)]
|
||||
[InlineData(GreyQueueResolution.ManualResolution)]
|
||||
[InlineData(GreyQueueResolution.Superseded)]
|
||||
[InlineData(GreyQueueResolution.NotApplicable)]
|
||||
[InlineData(GreyQueueResolution.Expired)]
|
||||
public void AllGreyQueueResolutions_AreValid(GreyQueueResolution resolution)
|
||||
{
|
||||
// Arrange & Act
|
||||
var entry = CreateEntry(GreyQueueStatus.Resolved) with { Resolution = resolution };
|
||||
|
||||
// Assert
|
||||
entry.Resolution.Should().Be(resolution);
|
||||
}
|
||||
|
||||
private static GreyQueueEntry CreateEntry(GreyQueueStatus status) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "test-tenant",
|
||||
UnknownId = Guid.NewGuid(),
|
||||
Fingerprint = "sha256:abc123",
|
||||
Status = status,
|
||||
Priority = 100,
|
||||
Reason = GreyQueueReason.InsufficientVex,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedBy = "test-user"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user