Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
This commit is contained in:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using StellaOps.AdvisoryAI.Orchestration;
namespace StellaOps.AdvisoryAI.WebService.Contracts;
public sealed class AdvisoryExecuteRequest
{
[Required]
[MinLength(1)]
public string AdvisoryKey { get; set; } = string.Empty;
public string? ArtifactId { get; set; }
public string? ArtifactPurl { get; set; }
public string? PolicyVersion { get; set; }
public string Profile { get; set; } = "default";
public IReadOnlyCollection<string>? PreferredSections { get; set; }
public bool ForceRefresh { get; set; }
public AdvisoryTaskRequest ToTaskRequest(AdvisoryTaskType taskType)
=> new(
taskType,
AdvisoryKey,
ArtifactId,
ArtifactPurl,
PolicyVersion,
Profile,
PreferredSections,
ForceRefresh);
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
using StellaOps.AdvisoryAI.Guardrails;
using StellaOps.AdvisoryAI.Orchestration;
namespace StellaOps.AdvisoryAI.WebService.Contracts;
public sealed record AdvisoryOutputResponse(
string CacheKey,
AdvisoryTaskType TaskType,
string Profile,
string OutputHash,
bool GuardrailBlocked,
IReadOnlyCollection<AdvisoryGuardrailViolationResponse> GuardrailViolations,
IReadOnlyDictionary<string, string> GuardrailMetadata,
string Prompt,
IReadOnlyCollection<AdvisoryCitationResponse> Citations,
IReadOnlyDictionary<string, string> Metadata,
DateTimeOffset GeneratedAtUtc,
bool PlanFromCache);
public sealed record AdvisoryGuardrailViolationResponse(string Code, string Message)
{
public static AdvisoryGuardrailViolationResponse From(AdvisoryGuardrailViolation violation)
=> new(violation.Code, violation.Message);
}
public sealed record AdvisoryCitationResponse(int Index, string DocumentId, string ChunkId);

View File

@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using StellaOps.AdvisoryAI.Orchestration;
namespace StellaOps.AdvisoryAI.WebService.Contracts;
public sealed class AdvisoryPlanRequest
{
[Required]
public AdvisoryTaskType TaskType { get; set; }
[Required]
[MinLength(1)]
public string AdvisoryKey { get; set; } = string.Empty;
public string? ArtifactId { get; set; }
public string? ArtifactPurl { get; set; }
public string? PolicyVersion { get; set; }
public string Profile { get; set; } = "default";
public IReadOnlyCollection<string>? PreferredSections { get; set; }
public bool ForceRefresh { get; set; }
public AdvisoryTaskRequest ToTaskRequest()
=> new(
TaskType,
AdvisoryKey,
ArtifactId,
ArtifactPurl,
PolicyVersion,
Profile,
PreferredSections,
ForceRefresh);
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using StellaOps.AdvisoryAI.Orchestration;
namespace StellaOps.AdvisoryAI.WebService.Contracts;
public sealed record AdvisoryPlanResponse(
string CacheKey,
AdvisoryTaskType TaskType,
string AdvisoryKey,
string Profile,
int StructuredChunkCount,
int VectorMatchCount,
bool IncludesSbom,
IReadOnlyDictionary<string, string> Metadata,
DateTimeOffset CreatedAtUtc);

View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.AdvisoryAI.WebService.Contracts;
public sealed class AdvisoryQueueRequest
{
/// <summary>
/// Optional cache key produced by a prior plan call. When provided the API reuses the cached plan.
/// </summary>
public string? PlanCacheKey { get; set; }
/// <summary>
/// Optional plan request. Required only when a cache key is not provided.
/// </summary>
public AdvisoryPlanRequest? Plan { get; set; }
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using StellaOps.AdvisoryAI.Orchestration;
namespace StellaOps.AdvisoryAI.WebService.Contracts;
public sealed record AdvisoryQueueResponse(
string PlanCacheKey,
AdvisoryTaskType TaskType,
IReadOnlyDictionary<string, string> Metadata,
string Message);

View File

@@ -0,0 +1,289 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.DependencyInjection;
using StellaOps.AdvisoryAI.Metrics;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.WebService.Contracts;
using StellaOps.AdvisoryAI.Execution;
using StellaOps.AdvisoryAI.Outputs;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddMetrics();
builder.Services.AddAdvisoryPipeline(options => builder.Configuration.GetSection("AdvisoryAI:Pipeline").Bind(options));
builder.Services.AddAdvisoryPipelineInfrastructure();
builder.Services.Configure<AdvisoryPlanCacheOptions>(builder.Configuration.GetSection("AdvisoryAI:PlanCache"));
builder.Services.Configure<AdvisoryTaskQueueOptions>(builder.Configuration.GetSection("AdvisoryAI:TaskQueue"));
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseSwagger();
app.UseSwaggerUI();
app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" }));
app.MapPost("/api/v1/advisory/plan", async Task<Results<Ok<AdvisoryPlanResponse>, ValidationProblem>> (
[FromBody] AdvisoryPlanRequest request,
IAdvisoryPipelineOrchestrator orchestrator,
IAdvisoryPlanCache cache,
AdvisoryPipelineMetrics metrics,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!MiniValidator.TryValidate(request, out var errors))
{
return TypedResults.ValidationProblem(errors);
}
var taskRequest = request.ToTaskRequest();
var start = timeProvider.GetTimestamp();
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
await cache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
var elapsed = timeProvider.GetElapsedTime(start);
metrics.RecordPlanCreated(elapsed.TotalSeconds, taskRequest.TaskType);
var response = new AdvisoryPlanResponse(
plan.CacheKey,
plan.Request.TaskType,
plan.Request.AdvisoryKey,
plan.Request.Profile,
plan.StructuredChunks.Length,
plan.VectorResults.Sum(result => result.Matches.Length),
plan.SbomContext is not null,
plan.Metadata,
timeProvider.GetUtcNow());
return TypedResults.Ok(response);
});
app.MapPost("/api/v1/advisory/queue", async Task<Results<Accepted<AdvisoryQueueResponse>, ValidationProblem>> (
[FromBody] AdvisoryQueueRequest request,
IAdvisoryPlanCache cache,
IAdvisoryTaskQueue queue,
IAdvisoryPipelineOrchestrator orchestrator,
AdvisoryPipelineMetrics metrics,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["request"] = new[] { "Request payload is required." }
});
}
AdvisoryTaskPlan? plan = null;
if (!string.IsNullOrWhiteSpace(request.PlanCacheKey))
{
plan = await cache.TryGetAsync(request.PlanCacheKey!, cancellationToken).ConfigureAwait(false);
}
if (plan is null)
{
if (request.Plan is null)
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["plan"] = new[] { "Either planCacheKey or plan must be supplied." }
});
}
if (!MiniValidator.TryValidate(request.Plan, out var planErrors))
{
return TypedResults.ValidationProblem(planErrors);
}
var taskRequest = request.Plan.ToTaskRequest();
var start = timeProvider.GetTimestamp();
plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
await cache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
var elapsed = timeProvider.GetElapsedTime(start);
metrics.RecordPlanCreated(elapsed.TotalSeconds, plan.Request.TaskType);
}
await queue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false);
metrics.RecordPlanQueued(plan.Request.TaskType);
var response = new AdvisoryQueueResponse(
plan.CacheKey,
plan.Request.TaskType,
plan.Metadata,
"Plan enqueued for processing.");
return TypedResults.Accepted($"/api/v1/advisory/queue/{plan.CacheKey}", response);
});
app.MapPost("/api/v1/advisory/{taskType}", async Task<Results<Ok<AdvisoryOutputResponse>, ValidationProblem>> (
string taskType,
[FromBody] AdvisoryExecuteRequest request,
IAdvisoryPipelineOrchestrator orchestrator,
IAdvisoryPlanCache cache,
IAdvisoryPipelineExecutor executor,
IAdvisoryOutputStore outputStore,
AdvisoryPipelineMetrics metrics,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryParseTaskType(taskType, out var taskTypeEnum, out var routeError))
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["taskType"] = new[] { routeError }
});
}
if (!MiniValidator.TryValidate(request, out var errors))
{
return TypedResults.ValidationProblem(errors);
}
var taskRequest = request.ToTaskRequest(taskTypeEnum);
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
var existingPlan = await cache.TryGetAsync(plan.CacheKey, cancellationToken).ConfigureAwait(false);
await cache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
var planFromCache = existingPlan is not null && !request.ForceRefresh;
AdvisoryPipelineOutput? output = null;
if (!request.ForceRefresh)
{
output = await outputStore.TryGetAsync(plan.CacheKey, plan.Request.TaskType, plan.Request.Profile, cancellationToken).ConfigureAwait(false);
}
if (output is null)
{
var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request);
await executor.ExecuteAsync(plan, message, planFromCache, cancellationToken).ConfigureAwait(false);
output = await outputStore.TryGetAsync(plan.CacheKey, plan.Request.TaskType, plan.Request.Profile, cancellationToken).ConfigureAwait(false);
}
if (output is null)
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["execution"] = new[] { "Failed to generate advisory output." }
});
}
metrics.RecordPlanProcessed(plan.Request.TaskType, planFromCache);
var response = ToOutputResponse(output);
return TypedResults.Ok(response);
});
app.MapGet("/api/v1/advisory/outputs/{cacheKey}", async Task<Results<Ok<AdvisoryOutputResponse>, ValidationProblem, NotFound>> (
string cacheKey,
[FromQuery] AdvisoryTaskType? taskType,
[FromQuery] string? profile,
IAdvisoryOutputStore outputStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["cacheKey"] = new[] { "Cache key is required." }
});
}
if (taskType is null)
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["taskType"] = new[] { "Task type query parameter is required." }
});
}
if (string.IsNullOrWhiteSpace(profile))
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["profile"] = new[] { "Profile query parameter is required." }
});
}
var output = await outputStore.TryGetAsync(cacheKey, taskType.Value, profile!, cancellationToken).ConfigureAwait(false);
if (output is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(ToOutputResponse(output));
});
app.Run();
static bool TryParseTaskType(string routeValue, out AdvisoryTaskType taskType, out string error)
{
if (Enum.TryParse(routeValue, ignoreCase: true, out taskType))
{
error = string.Empty;
return true;
}
error = $"Unsupported advisory task type {routeValue}. Expected summary, conflict, or remediation.";
return false;
}
static AdvisoryOutputResponse ToOutputResponse(AdvisoryPipelineOutput output)
{
var violations = output.Guardrail.Violations
.Select(AdvisoryGuardrailViolationResponse.From)
.ToImmutableArray();
var citations = output.Citations
.Select(citation => new AdvisoryCitationResponse(citation.Index, citation.DocumentId, citation.ChunkId))
.ToImmutableArray();
return new AdvisoryOutputResponse(
output.CacheKey,
output.TaskType,
output.Profile,
output.Provenance.OutputHash,
output.Guardrail.Blocked,
violations,
output.Guardrail.Metadata,
output.Prompt,
citations,
output.Metadata,
output.GeneratedAtUtc,
output.PlanFromCache);
}
internal static class MiniValidator
{
public static bool TryValidate(object instance, out Dictionary<string, string[]> errors)
{
var context = new ValidationContext(instance);
var results = new List<ValidationResult>();
if (!Validator.TryValidateObject(instance, context, results, validateAllProperties: true))
{
errors = results
.GroupBy(result => result.MemberNames.FirstOrDefault() ?? string.Empty)
.ToDictionary(
group => group.Key,
group => group.Select(result => result.ErrorMessage ?? "Invalid value.").ToArray(),
StringComparer.Ordinal);
return false;
}
errors = new Dictionary<string, string[]>(0);
return true;
}
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
</ItemGroup>
</Project>