sprints work
This commit is contained in:
@@ -118,6 +118,10 @@ public static class ServiceCollectionExtensions
|
||||
services.TryAddSingleton<GroundingValidator>();
|
||||
services.TryAddSingleton<ActionProposalParser>();
|
||||
|
||||
// Object link resolvers (SPRINT_20260109_011_002 OMCI-005)
|
||||
services.TryAddSingleton<ITypedLinkResolver, OpsMemoryLinkResolver>();
|
||||
services.TryAddSingleton<IObjectLinkResolver, CompositeObjectLinkResolver>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,890 @@
|
||||
// <copyright file="EvidencePackEndpoints.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Evidence.Pack;
|
||||
using StellaOps.Evidence.Pack.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for Evidence Packs.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-010
|
||||
/// </summary>
|
||||
public static class EvidencePackEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps all Evidence Pack endpoints.
|
||||
/// </summary>
|
||||
public static void MapEvidencePackEndpoints(this WebApplication app)
|
||||
{
|
||||
// POST /v1/evidence-packs - Create Evidence Pack
|
||||
app.MapPost("/v1/evidence-packs", HandleCreateEvidencePack)
|
||||
.WithName("evidence-packs.create")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<EvidencePackResponse>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// GET /v1/evidence-packs/{packId} - Get Evidence Pack
|
||||
app.MapGet("/v1/evidence-packs/{packId}", HandleGetEvidencePack)
|
||||
.WithName("evidence-packs.get")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<EvidencePackResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// POST /v1/evidence-packs/{packId}/sign - Sign Evidence Pack
|
||||
app.MapPost("/v1/evidence-packs/{packId}/sign", HandleSignEvidencePack)
|
||||
.WithName("evidence-packs.sign")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<SignedEvidencePackResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// POST /v1/evidence-packs/{packId}/verify - Verify Evidence Pack
|
||||
app.MapPost("/v1/evidence-packs/{packId}/verify", HandleVerifyEvidencePack)
|
||||
.WithName("evidence-packs.verify")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<EvidencePackVerificationResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// GET /v1/evidence-packs/{packId}/export - Export Evidence Pack
|
||||
app.MapGet("/v1/evidence-packs/{packId}/export", HandleExportEvidencePack)
|
||||
.WithName("evidence-packs.export")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<byte[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// GET /v1/runs/{runId}/evidence-packs - List Evidence Packs for Run
|
||||
app.MapGet("/v1/runs/{runId}/evidence-packs", HandleListRunEvidencePacks)
|
||||
.WithName("evidence-packs.list-by-run")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<EvidencePackListResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// GET /v1/evidence-packs - List Evidence Packs
|
||||
app.MapGet("/v1/evidence-packs", HandleListEvidencePacks)
|
||||
.WithName("evidence-packs.list")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<EvidencePackListResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleCreateEvidencePack(
|
||||
CreateEvidencePackRequest request,
|
||||
IEvidencePackService evidencePackService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (request.Claims is null || request.Claims.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one claim is required" });
|
||||
}
|
||||
|
||||
if (request.Evidence is null || request.Evidence.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one evidence item is required" });
|
||||
}
|
||||
|
||||
var claims = request.Claims.Select(c => new EvidenceClaim
|
||||
{
|
||||
ClaimId = c.ClaimId ?? $"claim-{Guid.NewGuid():N}"[..16],
|
||||
Text = c.Text,
|
||||
Type = Enum.TryParse<ClaimType>(c.Type, true, out var ct) ? ct : ClaimType.Custom,
|
||||
Status = c.Status,
|
||||
Confidence = c.Confidence,
|
||||
EvidenceIds = c.EvidenceIds?.ToImmutableArray() ?? [],
|
||||
Source = c.Source
|
||||
}).ToArray();
|
||||
|
||||
var evidence = request.Evidence.Select(e => new EvidenceItem
|
||||
{
|
||||
EvidenceId = e.EvidenceId ?? $"ev-{Guid.NewGuid():N}"[..12],
|
||||
Type = Enum.TryParse<EvidenceType>(e.Type, true, out var et) ? et : EvidenceType.Custom,
|
||||
Uri = e.Uri,
|
||||
Digest = e.Digest ?? "sha256:unknown",
|
||||
CollectedAt = e.CollectedAt ?? DateTimeOffset.UtcNow,
|
||||
Snapshot = EvidenceSnapshot.Custom(e.SnapshotType ?? "custom", (e.SnapshotData ?? new Dictionary<string, object>()).ToImmutableDictionary(x => x.Key, x => (object?)x.Value))
|
||||
}).ToArray();
|
||||
|
||||
var subject = new EvidenceSubject
|
||||
{
|
||||
Type = Enum.TryParse<EvidenceSubjectType>(request.Subject?.Type, true, out var st)
|
||||
? st
|
||||
: EvidenceSubjectType.Custom,
|
||||
FindingId = request.Subject?.FindingId,
|
||||
CveId = request.Subject?.CveId,
|
||||
Component = request.Subject?.Component,
|
||||
ImageDigest = request.Subject?.ImageDigest
|
||||
};
|
||||
|
||||
var context = new EvidencePackContext
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RunId = request.RunId,
|
||||
ConversationId = request.ConversationId,
|
||||
UserId = GetUserId(httpContext),
|
||||
GeneratedBy = "API"
|
||||
};
|
||||
|
||||
var pack = await evidencePackService.CreateAsync(claims, evidence, subject, context, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var response = EvidencePackResponse.FromPack(pack);
|
||||
return Results.Created($"/v1/evidence-packs/{pack.PackId}", response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleGetEvidencePack(
|
||||
string packId,
|
||||
IEvidencePackService evidencePackService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var pack = await evidencePackService.GetAsync(tenantId, packId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Evidence pack not found", packId });
|
||||
}
|
||||
|
||||
return Results.Ok(EvidencePackResponse.FromPack(pack));
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleSignEvidencePack(
|
||||
string packId,
|
||||
IEvidencePackService evidencePackService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var pack = await evidencePackService.GetAsync(tenantId, packId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Evidence pack not found", packId });
|
||||
}
|
||||
|
||||
var signedPack = await evidencePackService.SignAsync(pack, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(SignedEvidencePackResponse.FromSignedPack(signedPack));
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleVerifyEvidencePack(
|
||||
string packId,
|
||||
IEvidencePackService evidencePackService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var pack = await evidencePackService.GetAsync(tenantId, packId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Evidence pack not found", packId });
|
||||
}
|
||||
|
||||
// Get signed version from store
|
||||
var store = httpContext.RequestServices.GetService<IEvidencePackStore>();
|
||||
var signedPack = store is not null
|
||||
? await store.GetSignedByIdAsync(tenantId, packId, cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
if (signedPack is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Pack is not signed", packId });
|
||||
}
|
||||
|
||||
var result = await evidencePackService.VerifyAsync(signedPack, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new EvidencePackVerificationResponse
|
||||
{
|
||||
PackId = packId,
|
||||
Valid = result.Valid,
|
||||
PackDigest = result.PackDigest,
|
||||
SignatureKeyId = result.SignatureKeyId,
|
||||
Issues = result.Issues.ToList(),
|
||||
EvidenceResolutions = result.EvidenceResolutions.Select(r => new EvidenceResolutionApiResponse
|
||||
{
|
||||
EvidenceId = r.EvidenceId,
|
||||
Uri = r.Uri,
|
||||
Resolved = r.Resolved,
|
||||
DigestMatches = r.DigestMatches,
|
||||
Error = r.Error
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleExportEvidencePack(
|
||||
string packId,
|
||||
string? format,
|
||||
IEvidencePackService evidencePackService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var pack = await evidencePackService.GetAsync(tenantId, packId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pack is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Evidence pack not found", packId });
|
||||
}
|
||||
|
||||
var exportFormat = format?.ToLowerInvariant() switch
|
||||
{
|
||||
"markdown" or "md" => EvidencePackExportFormat.Markdown,
|
||||
"html" => EvidencePackExportFormat.Html,
|
||||
"pdf" => EvidencePackExportFormat.Pdf,
|
||||
"signedjson" => EvidencePackExportFormat.SignedJson,
|
||||
_ => EvidencePackExportFormat.Json
|
||||
};
|
||||
|
||||
var export = await evidencePackService.ExportAsync(packId, exportFormat, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.File(export.Content, export.ContentType, export.FileName);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleListRunEvidencePacks(
|
||||
string runId,
|
||||
IEvidencePackService evidencePackService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var store = httpContext.RequestServices.GetService<IEvidencePackStore>();
|
||||
if (store is null)
|
||||
{
|
||||
return Results.Ok(new EvidencePackListResponse { Count = 0, Packs = [] });
|
||||
}
|
||||
|
||||
var packs = await store.GetByRunIdAsync(runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Filter by tenant
|
||||
var filtered = packs.Where(p => p.TenantId == tenantId).ToList();
|
||||
|
||||
return Results.Ok(new EvidencePackListResponse
|
||||
{
|
||||
Count = filtered.Count,
|
||||
Packs = filtered.Select(EvidencePackSummary.FromPack).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleListEvidencePacks(
|
||||
string? cveId,
|
||||
string? runId,
|
||||
int? limit,
|
||||
IEvidencePackService evidencePackService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = GetTenantId(httpContext);
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var store = httpContext.RequestServices.GetService<IEvidencePackStore>();
|
||||
if (store is null)
|
||||
{
|
||||
return Results.Ok(new EvidencePackListResponse { Count = 0, Packs = [] });
|
||||
}
|
||||
|
||||
var query = new EvidencePackQuery
|
||||
{
|
||||
CveId = cveId,
|
||||
RunId = runId,
|
||||
Limit = Math.Min(limit ?? 50, 100)
|
||||
};
|
||||
|
||||
var packs = await store.ListAsync(tenantId, query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new EvidencePackListResponse
|
||||
{
|
||||
Count = packs.Count,
|
||||
Packs = packs.Select(EvidencePackSummary.FromPack).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Tenant", out var tenant))
|
||||
{
|
||||
return tenant.ToString();
|
||||
}
|
||||
|
||||
var tenantClaim = context.User?.FindFirst("tenant_id")?.Value;
|
||||
return tenantClaim;
|
||||
}
|
||||
|
||||
private static string GetUserId(HttpContext context)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-User", out var user))
|
||||
{
|
||||
return user.ToString();
|
||||
}
|
||||
|
||||
return context.User?.FindFirst("sub")?.Value ?? "anonymous";
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an Evidence Pack.
|
||||
/// </summary>
|
||||
public sealed record CreateEvidencePackRequest
|
||||
{
|
||||
/// <summary>Subject of the evidence pack.</summary>
|
||||
public EvidenceSubjectRequest? Subject { get; init; }
|
||||
|
||||
/// <summary>Claims in the pack.</summary>
|
||||
public IReadOnlyList<EvidenceClaimRequest>? Claims { get; init; }
|
||||
|
||||
/// <summary>Evidence items.</summary>
|
||||
public IReadOnlyList<EvidenceItemRequest>? Evidence { get; init; }
|
||||
|
||||
/// <summary>Optional Run ID to link to.</summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>Optional conversation ID.</summary>
|
||||
public string? ConversationId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence subject in request.
|
||||
/// </summary>
|
||||
public sealed record EvidenceSubjectRequest
|
||||
{
|
||||
/// <summary>Subject type.</summary>
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>Finding ID if applicable.</summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>CVE ID if applicable.</summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>Component if applicable.</summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>Image digest if applicable.</summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence claim in request.
|
||||
/// </summary>
|
||||
public sealed record EvidenceClaimRequest
|
||||
{
|
||||
/// <summary>Optional claim ID (auto-generated if not provided).</summary>
|
||||
public string? ClaimId { get; init; }
|
||||
|
||||
/// <summary>Claim text.</summary>
|
||||
public required string Text { get; init; }
|
||||
|
||||
/// <summary>Claim type.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Status.</summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>Confidence score 0-1.</summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>Evidence IDs supporting this claim.</summary>
|
||||
public IReadOnlyList<string>? EvidenceIds { get; init; }
|
||||
|
||||
/// <summary>Source of the claim.</summary>
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence item in request.
|
||||
/// </summary>
|
||||
public sealed record EvidenceItemRequest
|
||||
{
|
||||
/// <summary>Optional evidence ID (auto-generated if not provided).</summary>
|
||||
public string? EvidenceId { get; init; }
|
||||
|
||||
/// <summary>Evidence type.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>URI to the evidence.</summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>Content digest.</summary>
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>When the evidence was collected.</summary>
|
||||
public DateTimeOffset? CollectedAt { get; init; }
|
||||
|
||||
/// <summary>Snapshot type.</summary>
|
||||
public string? SnapshotType { get; init; }
|
||||
|
||||
/// <summary>Snapshot data.</summary>
|
||||
public Dictionary<string, object>? SnapshotData { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence Pack response.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackResponse
|
||||
{
|
||||
/// <summary>Pack ID.</summary>
|
||||
public required string PackId { get; init; }
|
||||
|
||||
/// <summary>Version.</summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>Tenant ID.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Created timestamp.</summary>
|
||||
public required string CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Content digest.</summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>Subject.</summary>
|
||||
public required EvidenceSubjectResponse Subject { get; init; }
|
||||
|
||||
/// <summary>Claims.</summary>
|
||||
public required IReadOnlyList<EvidenceClaimResponse> Claims { get; init; }
|
||||
|
||||
/// <summary>Evidence items.</summary>
|
||||
public required IReadOnlyList<EvidenceItemResponse> Evidence { get; init; }
|
||||
|
||||
/// <summary>Context.</summary>
|
||||
public EvidencePackContextResponse? Context { get; init; }
|
||||
|
||||
/// <summary>Related links.</summary>
|
||||
public EvidencePackLinks? Links { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates response from domain model.
|
||||
/// </summary>
|
||||
public static EvidencePackResponse FromPack(EvidencePack pack) => new()
|
||||
{
|
||||
PackId = pack.PackId,
|
||||
Version = pack.Version,
|
||||
TenantId = pack.TenantId,
|
||||
CreatedAt = pack.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
ContentDigest = pack.ComputeContentDigest(),
|
||||
Subject = EvidenceSubjectResponse.FromSubject(pack.Subject),
|
||||
Claims = pack.Claims.Select(EvidenceClaimResponse.FromClaim).ToList(),
|
||||
Evidence = pack.Evidence.Select(EvidenceItemResponse.FromItem).ToList(),
|
||||
Context = pack.Context is not null ? EvidencePackContextResponse.FromContext(pack.Context) : null,
|
||||
Links = new EvidencePackLinks
|
||||
{
|
||||
Self = $"/v1/evidence-packs/{pack.PackId}",
|
||||
Sign = $"/v1/evidence-packs/{pack.PackId}/sign",
|
||||
Verify = $"/v1/evidence-packs/{pack.PackId}/verify",
|
||||
Export = $"/v1/evidence-packs/{pack.PackId}/export"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence subject response.
|
||||
/// </summary>
|
||||
public sealed record EvidenceSubjectResponse
|
||||
{
|
||||
/// <summary>Subject type.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Finding ID.</summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>CVE ID.</summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>Component.</summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>Image digest.</summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates response from domain model.
|
||||
/// </summary>
|
||||
public static EvidenceSubjectResponse FromSubject(EvidenceSubject subject) => new()
|
||||
{
|
||||
Type = subject.Type.ToString(),
|
||||
FindingId = subject.FindingId,
|
||||
CveId = subject.CveId,
|
||||
Component = subject.Component,
|
||||
ImageDigest = subject.ImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence claim response.
|
||||
/// </summary>
|
||||
public sealed record EvidenceClaimResponse
|
||||
{
|
||||
/// <summary>Claim ID.</summary>
|
||||
public required string ClaimId { get; init; }
|
||||
|
||||
/// <summary>Claim text.</summary>
|
||||
public required string Text { get; init; }
|
||||
|
||||
/// <summary>Claim type.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Status.</summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>Confidence score.</summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>Evidence IDs.</summary>
|
||||
public required IReadOnlyList<string> EvidenceIds { get; init; }
|
||||
|
||||
/// <summary>Source.</summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates response from domain model.
|
||||
/// </summary>
|
||||
public static EvidenceClaimResponse FromClaim(EvidenceClaim claim) => new()
|
||||
{
|
||||
ClaimId = claim.ClaimId,
|
||||
Text = claim.Text,
|
||||
Type = claim.Type.ToString(),
|
||||
Status = claim.Status,
|
||||
Confidence = claim.Confidence,
|
||||
EvidenceIds = claim.EvidenceIds.ToList(),
|
||||
Source = claim.Source
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence item response.
|
||||
/// </summary>
|
||||
public sealed record EvidenceItemResponse
|
||||
{
|
||||
/// <summary>Evidence ID.</summary>
|
||||
public required string EvidenceId { get; init; }
|
||||
|
||||
/// <summary>Evidence type.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>URI.</summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>Digest.</summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>Collection timestamp.</summary>
|
||||
public required string CollectedAt { get; init; }
|
||||
|
||||
/// <summary>Snapshot.</summary>
|
||||
public required EvidenceSnapshotResponse Snapshot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates response from domain model.
|
||||
/// </summary>
|
||||
public static EvidenceItemResponse FromItem(EvidenceItem item) => new()
|
||||
{
|
||||
EvidenceId = item.EvidenceId,
|
||||
Type = item.Type.ToString(),
|
||||
Uri = item.Uri,
|
||||
Digest = item.Digest,
|
||||
CollectedAt = item.CollectedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
Snapshot = EvidenceSnapshotResponse.FromSnapshot(item.Snapshot)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence snapshot response.
|
||||
/// </summary>
|
||||
public sealed record EvidenceSnapshotResponse
|
||||
{
|
||||
/// <summary>Snapshot type.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Snapshot data.</summary>
|
||||
public required IReadOnlyDictionary<string, object?> Data { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates response from domain model.
|
||||
/// </summary>
|
||||
public static EvidenceSnapshotResponse FromSnapshot(EvidenceSnapshot snapshot) => new()
|
||||
{
|
||||
Type = snapshot.Type,
|
||||
Data = snapshot.Data
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence pack context response.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackContextResponse
|
||||
{
|
||||
/// <summary>Tenant ID.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Run ID.</summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>Conversation ID.</summary>
|
||||
public string? ConversationId { get; init; }
|
||||
|
||||
/// <summary>User ID.</summary>
|
||||
public string? UserId { get; init; }
|
||||
|
||||
/// <summary>Generator.</summary>
|
||||
public string? GeneratedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates response from domain model.
|
||||
/// </summary>
|
||||
public static EvidencePackContextResponse FromContext(EvidencePackContext context) => new()
|
||||
{
|
||||
TenantId = context.TenantId,
|
||||
RunId = context.RunId,
|
||||
ConversationId = context.ConversationId,
|
||||
UserId = context.UserId,
|
||||
GeneratedBy = context.GeneratedBy
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence pack links.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackLinks
|
||||
{
|
||||
/// <summary>Self link.</summary>
|
||||
public string? Self { get; init; }
|
||||
|
||||
/// <summary>Sign link.</summary>
|
||||
public string? Sign { get; init; }
|
||||
|
||||
/// <summary>Verify link.</summary>
|
||||
public string? Verify { get; init; }
|
||||
|
||||
/// <summary>Export link.</summary>
|
||||
public string? Export { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signed evidence pack response.
|
||||
/// </summary>
|
||||
public sealed record SignedEvidencePackResponse
|
||||
{
|
||||
/// <summary>Pack ID.</summary>
|
||||
public required string PackId { get; init; }
|
||||
|
||||
/// <summary>Signed timestamp.</summary>
|
||||
public required string SignedAt { get; init; }
|
||||
|
||||
/// <summary>Pack content.</summary>
|
||||
public required EvidencePackResponse Pack { get; init; }
|
||||
|
||||
/// <summary>DSSE envelope.</summary>
|
||||
public required DsseEnvelopeResponse Envelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates response from domain model.
|
||||
/// </summary>
|
||||
public static SignedEvidencePackResponse FromSignedPack(SignedEvidencePack signedPack) => new()
|
||||
{
|
||||
PackId = signedPack.Pack.PackId,
|
||||
SignedAt = signedPack.SignedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
Pack = EvidencePackResponse.FromPack(signedPack.Pack),
|
||||
Envelope = DsseEnvelopeResponse.FromEnvelope(signedPack.Envelope)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope response.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelopeResponse
|
||||
{
|
||||
/// <summary>Payload type.</summary>
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>Payload digest.</summary>
|
||||
public required string PayloadDigest { get; init; }
|
||||
|
||||
/// <summary>Signatures.</summary>
|
||||
public required IReadOnlyList<DsseSignatureResponse> Signatures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates response from domain model.
|
||||
/// </summary>
|
||||
public static DsseEnvelopeResponse FromEnvelope(DsseEnvelope envelope) => new()
|
||||
{
|
||||
PayloadType = envelope.PayloadType,
|
||||
PayloadDigest = envelope.PayloadDigest,
|
||||
Signatures = envelope.Signatures.Select(s => new DsseSignatureResponse
|
||||
{
|
||||
KeyId = s.KeyId,
|
||||
Sig = s.Sig
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature response.
|
||||
/// </summary>
|
||||
public sealed record DsseSignatureResponse
|
||||
{
|
||||
/// <summary>Key ID.</summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>Signature.</summary>
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence pack verification response.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackVerificationResponse
|
||||
{
|
||||
/// <summary>Pack ID.</summary>
|
||||
public required string PackId { get; init; }
|
||||
|
||||
/// <summary>Whether verification passed.</summary>
|
||||
public bool Valid { get; init; }
|
||||
|
||||
/// <summary>Pack digest.</summary>
|
||||
public string? PackDigest { get; init; }
|
||||
|
||||
/// <summary>Signing key ID.</summary>
|
||||
public string? SignatureKeyId { get; init; }
|
||||
|
||||
/// <summary>Issues found.</summary>
|
||||
public IReadOnlyList<string>? Issues { get; init; }
|
||||
|
||||
/// <summary>Evidence resolution results.</summary>
|
||||
public IReadOnlyList<EvidenceResolutionApiResponse>? EvidenceResolutions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence resolution result in API response.
|
||||
/// </summary>
|
||||
public sealed record EvidenceResolutionApiResponse
|
||||
{
|
||||
/// <summary>Evidence ID.</summary>
|
||||
public required string EvidenceId { get; init; }
|
||||
|
||||
/// <summary>URI.</summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>Whether resolved.</summary>
|
||||
public bool Resolved { get; init; }
|
||||
|
||||
/// <summary>Whether digest matches.</summary>
|
||||
public bool DigestMatches { get; init; }
|
||||
|
||||
/// <summary>Error message.</summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence pack list response.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackListResponse
|
||||
{
|
||||
/// <summary>Total count.</summary>
|
||||
public int Count { get; init; }
|
||||
|
||||
/// <summary>Pack summaries.</summary>
|
||||
public required IReadOnlyList<EvidencePackSummary> Packs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence pack summary.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackSummary
|
||||
{
|
||||
/// <summary>Pack ID.</summary>
|
||||
public required string PackId { get; init; }
|
||||
|
||||
/// <summary>Tenant ID.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Created timestamp.</summary>
|
||||
public required string CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Subject type.</summary>
|
||||
public required string SubjectType { get; init; }
|
||||
|
||||
/// <summary>CVE ID if applicable.</summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>Number of claims.</summary>
|
||||
public int ClaimCount { get; init; }
|
||||
|
||||
/// <summary>Number of evidence items.</summary>
|
||||
public int EvidenceCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates summary from domain model.
|
||||
/// </summary>
|
||||
public static EvidencePackSummary FromPack(EvidencePack pack) => new()
|
||||
{
|
||||
PackId = pack.PackId,
|
||||
TenantId = pack.TenantId,
|
||||
CreatedAt = pack.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
SubjectType = pack.Subject.Type.ToString(),
|
||||
CveId = pack.Subject.CveId,
|
||||
ClaimCount = pack.Claims.Length,
|
||||
EvidenceCount = pack.Evidence.Length
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,904 @@
|
||||
// <copyright file="RunEndpoints.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for AI investigation runs.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-006
|
||||
/// </summary>
|
||||
public static class RunEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps run endpoints to the route builder.
|
||||
/// </summary>
|
||||
/// <param name="builder">The endpoint route builder.</param>
|
||||
/// <returns>The route group builder.</returns>
|
||||
public static RouteGroupBuilder MapRunEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/api/v1/runs")
|
||||
.WithTags("Runs");
|
||||
|
||||
group.MapPost("/", CreateRunAsync)
|
||||
.WithName("CreateRun")
|
||||
.WithSummary("Creates a new AI investigation run")
|
||||
.Produces<RunDto>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
group.MapGet("/{runId}", GetRunAsync)
|
||||
.WithName("GetRun")
|
||||
.WithSummary("Gets a run by ID")
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/", QueryRunsAsync)
|
||||
.WithName("QueryRuns")
|
||||
.WithSummary("Queries runs with filters")
|
||||
.Produces<RunQueryResultDto>();
|
||||
|
||||
group.MapGet("/{runId}/timeline", GetTimelineAsync)
|
||||
.WithName("GetRunTimeline")
|
||||
.WithSummary("Gets the event timeline for a run")
|
||||
.Produces<ImmutableArray<RunEventDto>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/events", AddEventAsync)
|
||||
.WithName("AddRunEvent")
|
||||
.WithSummary("Adds an event to a run")
|
||||
.Produces<RunEventDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/turns/user", AddUserTurnAsync)
|
||||
.WithName("AddUserTurn")
|
||||
.WithSummary("Adds a user turn to the run")
|
||||
.Produces<RunEventDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/turns/assistant", AddAssistantTurnAsync)
|
||||
.WithName("AddAssistantTurn")
|
||||
.WithSummary("Adds an assistant turn to the run")
|
||||
.Produces<RunEventDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/actions", ProposeActionAsync)
|
||||
.WithName("ProposeAction")
|
||||
.WithSummary("Proposes an action in the run")
|
||||
.Produces<RunEventDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/approval/request", RequestApprovalAsync)
|
||||
.WithName("RequestApproval")
|
||||
.WithSummary("Requests approval for pending actions")
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/approval/decide", ApproveAsync)
|
||||
.WithName("ApproveRun")
|
||||
.WithSummary("Approves or rejects a run")
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/{runId}/actions/{actionEventId}/execute", ExecuteActionAsync)
|
||||
.WithName("ExecuteAction")
|
||||
.WithSummary("Executes an approved action")
|
||||
.Produces<RunEventDto>()
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/{runId}/artifacts", AddArtifactAsync)
|
||||
.WithName("AddArtifact")
|
||||
.WithSummary("Adds an artifact to the run")
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/complete", CompleteRunAsync)
|
||||
.WithName("CompleteRun")
|
||||
.WithSummary("Completes a run")
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/{runId}/cancel", CancelRunAsync)
|
||||
.WithName("CancelRun")
|
||||
.WithSummary("Cancels a run")
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/{runId}/handoff", HandOffRunAsync)
|
||||
.WithName("HandOffRun")
|
||||
.WithSummary("Hands off a run to another user")
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/attest", AttestRunAsync)
|
||||
.WithName("AttestRun")
|
||||
.WithSummary("Creates an attestation for a completed run")
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapGet("/active", GetActiveRunsAsync)
|
||||
.WithName("GetActiveRuns")
|
||||
.WithSummary("Gets active runs for the current user")
|
||||
.Produces<ImmutableArray<RunDto>>();
|
||||
|
||||
group.MapGet("/pending-approval", GetPendingApprovalAsync)
|
||||
.WithName("GetPendingApproval")
|
||||
.WithSummary("Gets runs pending approval")
|
||||
.Produces<ImmutableArray<RunDto>>();
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRunAsync(
|
||||
[FromBody] CreateRunRequestDto request,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-User-Id")] string? userId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
userId ??= "anonymous";
|
||||
|
||||
var run = await runService.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
InitiatedBy = userId,
|
||||
Title = request.Title,
|
||||
Objective = request.Objective,
|
||||
Context = request.Context is not null ? MapToContext(request.Context) : null,
|
||||
Metadata = request.Metadata?.ToImmutableDictionary()
|
||||
}, ct);
|
||||
|
||||
return Results.Created($"/api/v1/runs/{run.RunId}", MapToDto(run));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRunAsync(
|
||||
string runId,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
var run = await runService.GetAsync(tenantId, runId, ct);
|
||||
if (run is null)
|
||||
{
|
||||
return Results.NotFound(new { message = $"Run {runId} not found" });
|
||||
}
|
||||
|
||||
return Results.Ok(MapToDto(run));
|
||||
}
|
||||
|
||||
private static async Task<IResult> QueryRunsAsync(
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromQuery] string? initiatedBy,
|
||||
[FromQuery] string? cveId,
|
||||
[FromQuery] string? component,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
ImmutableArray<RunStatus>? statuses = null;
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<RunStatus>(status, true, out var parsedStatus))
|
||||
{
|
||||
statuses = [parsedStatus];
|
||||
}
|
||||
|
||||
var result = await runService.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
InitiatedBy = initiatedBy,
|
||||
CveId = cveId,
|
||||
Component = component,
|
||||
Statuses = statuses,
|
||||
Skip = skip,
|
||||
Take = take
|
||||
}, ct);
|
||||
|
||||
return Results.Ok(new RunQueryResultDto
|
||||
{
|
||||
Runs = result.Runs.Select(MapToDto).ToImmutableArray(),
|
||||
TotalCount = result.TotalCount,
|
||||
HasMore = result.HasMore
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTimelineAsync(
|
||||
string runId,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
var events = await runService.GetTimelineAsync(tenantId, runId, skip, take, ct);
|
||||
return Results.Ok(events.Select(MapEventToDto).ToImmutableArray());
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddEventAsync(
|
||||
string runId,
|
||||
[FromBody] AddEventRequestDto request,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-User-Id")] string? userId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
try
|
||||
{
|
||||
var evt = await runService.AddEventAsync(tenantId, runId, new AddRunEventRequest
|
||||
{
|
||||
Type = request.Type,
|
||||
ActorId = userId,
|
||||
Content = request.Content,
|
||||
EvidenceLinks = request.EvidenceLinks,
|
||||
ParentEventId = request.ParentEventId,
|
||||
Metadata = request.Metadata?.ToImmutableDictionary()
|
||||
}, ct);
|
||||
|
||||
return Results.Created($"/api/v1/runs/{runId}/events/{evt.EventId}", MapEventToDto(evt));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddUserTurnAsync(
|
||||
string runId,
|
||||
[FromBody] AddTurnRequestDto request,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-User-Id")] string? userId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
userId ??= "anonymous";
|
||||
|
||||
try
|
||||
{
|
||||
var evt = await runService.AddUserTurnAsync(
|
||||
tenantId, runId, request.Message, userId, request.EvidenceLinks, ct);
|
||||
|
||||
return Results.Created($"/api/v1/runs/{runId}/events/{evt.EventId}", MapEventToDto(evt));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddAssistantTurnAsync(
|
||||
string runId,
|
||||
[FromBody] AddTurnRequestDto request,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
try
|
||||
{
|
||||
var evt = await runService.AddAssistantTurnAsync(
|
||||
tenantId, runId, request.Message, request.EvidenceLinks, ct);
|
||||
|
||||
return Results.Created($"/api/v1/runs/{runId}/events/{evt.EventId}", MapEventToDto(evt));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProposeActionAsync(
|
||||
string runId,
|
||||
[FromBody] ProposeActionRequestDto request,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
try
|
||||
{
|
||||
var evt = await runService.ProposeActionAsync(tenantId, runId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = request.ActionType,
|
||||
Subject = request.Subject,
|
||||
Rationale = request.Rationale,
|
||||
RequiresApproval = request.RequiresApproval,
|
||||
Parameters = request.Parameters?.ToImmutableDictionary(),
|
||||
EvidenceLinks = request.EvidenceLinks
|
||||
}, ct);
|
||||
|
||||
return Results.Created($"/api/v1/runs/{runId}/events/{evt.EventId}", MapEventToDto(evt));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> RequestApprovalAsync(
|
||||
string runId,
|
||||
[FromBody] RequestApprovalDto request,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
try
|
||||
{
|
||||
var run = await runService.RequestApprovalAsync(
|
||||
tenantId, runId, [.. request.Approvers], request.Reason, ct);
|
||||
|
||||
return Results.Ok(MapToDto(run));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ApproveAsync(
|
||||
string runId,
|
||||
[FromBody] ApprovalDecisionDto request,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-User-Id")] string? userId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
userId ??= "anonymous";
|
||||
|
||||
try
|
||||
{
|
||||
var run = await runService.ApproveAsync(
|
||||
tenantId, runId, request.Approved, userId, request.Reason, ct);
|
||||
|
||||
return Results.Ok(MapToDto(run));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExecuteActionAsync(
|
||||
string runId,
|
||||
string actionEventId,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
try
|
||||
{
|
||||
var evt = await runService.ExecuteActionAsync(tenantId, runId, actionEventId, ct);
|
||||
return Results.Ok(MapEventToDto(evt));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddArtifactAsync(
|
||||
string runId,
|
||||
[FromBody] AddArtifactRequestDto request,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
try
|
||||
{
|
||||
var run = await runService.AddArtifactAsync(tenantId, runId, new RunArtifact
|
||||
{
|
||||
ArtifactId = request.ArtifactId ?? Guid.NewGuid().ToString("N"),
|
||||
Type = request.Type,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ContentDigest = request.ContentDigest,
|
||||
ContentSize = request.ContentSize,
|
||||
MediaType = request.MediaType,
|
||||
StorageUri = request.StorageUri,
|
||||
IsInline = request.IsInline,
|
||||
InlineContent = request.InlineContent,
|
||||
Metadata = request.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
|
||||
}, ct);
|
||||
|
||||
return Results.Ok(MapToDto(run));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CompleteRunAsync(
|
||||
string runId,
|
||||
[FromBody] CompleteRunRequestDto? request,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
try
|
||||
{
|
||||
var run = await runService.CompleteAsync(tenantId, runId, request?.Summary, ct);
|
||||
return Results.Ok(MapToDto(run));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> CancelRunAsync(
|
||||
string runId,
|
||||
[FromBody] CancelRunRequestDto? request,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
try
|
||||
{
|
||||
var run = await runService.CancelAsync(tenantId, runId, request?.Reason, ct);
|
||||
return Results.Ok(MapToDto(run));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandOffRunAsync(
|
||||
string runId,
|
||||
[FromBody] HandOffRequestDto request,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
try
|
||||
{
|
||||
var run = await runService.HandOffAsync(tenantId, runId, request.ToUserId, request.Message, ct);
|
||||
return Results.Ok(MapToDto(run));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> AttestRunAsync(
|
||||
string runId,
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
try
|
||||
{
|
||||
var run = await runService.AttestAsync(tenantId, runId, ct);
|
||||
return Results.Ok(MapToDto(run));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetActiveRunsAsync(
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-User-Id")] string? userId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
userId ??= "anonymous";
|
||||
|
||||
var result = await runService.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
InitiatedBy = userId,
|
||||
Statuses = [RunStatus.Created, RunStatus.Active, RunStatus.PendingApproval],
|
||||
Take = 50
|
||||
}, ct);
|
||||
|
||||
return Results.Ok(result.Runs.Select(MapToDto).ToImmutableArray());
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPendingApprovalAsync(
|
||||
[FromServices] IRunService runService,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
tenantId ??= "default";
|
||||
|
||||
var result = await runService.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Statuses = [RunStatus.PendingApproval],
|
||||
Take = 50
|
||||
}, ct);
|
||||
|
||||
return Results.Ok(result.Runs.Select(MapToDto).ToImmutableArray());
|
||||
}
|
||||
|
||||
private static RunDto MapToDto(Run run) => new()
|
||||
{
|
||||
RunId = run.RunId,
|
||||
TenantId = run.TenantId,
|
||||
InitiatedBy = run.InitiatedBy,
|
||||
Title = run.Title,
|
||||
Objective = run.Objective,
|
||||
Status = run.Status.ToString(),
|
||||
CreatedAt = run.CreatedAt,
|
||||
UpdatedAt = run.UpdatedAt,
|
||||
CompletedAt = run.CompletedAt,
|
||||
EventCount = run.Events.Length,
|
||||
ArtifactCount = run.Artifacts.Length,
|
||||
ContentDigest = run.ContentDigest,
|
||||
IsAttested = run.Attestation is not null,
|
||||
Context = MapContextToDto(run.Context),
|
||||
Approval = run.Approval is not null ? MapApprovalToDto(run.Approval) : null,
|
||||
Metadata = run.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
||||
};
|
||||
|
||||
private static RunEventDto MapEventToDto(RunEvent evt) => new()
|
||||
{
|
||||
EventId = evt.EventId,
|
||||
Type = evt.Type.ToString(),
|
||||
Timestamp = evt.Timestamp,
|
||||
ActorId = evt.ActorId,
|
||||
SequenceNumber = evt.SequenceNumber,
|
||||
ParentEventId = evt.ParentEventId,
|
||||
EvidenceLinkCount = evt.EvidenceLinks.Length,
|
||||
Metadata = evt.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
||||
};
|
||||
|
||||
private static RunContextDto MapContextToDto(RunContext context) => new()
|
||||
{
|
||||
FocusedCveId = context.FocusedCveId,
|
||||
FocusedComponent = context.FocusedComponent,
|
||||
SbomDigest = context.SbomDigest,
|
||||
ImageReference = context.ImageReference,
|
||||
Tags = [.. context.Tags],
|
||||
IsOpsMemoryEnriched = context.OpsMemory?.IsEnriched ?? false
|
||||
};
|
||||
|
||||
private static ApprovalInfoDto MapApprovalToDto(ApprovalInfo approval) => new()
|
||||
{
|
||||
Required = approval.Required,
|
||||
Approvers = [.. approval.Approvers],
|
||||
Approved = approval.Approved,
|
||||
ApprovedBy = approval.ApprovedBy,
|
||||
ApprovedAt = approval.ApprovedAt,
|
||||
Reason = approval.Reason
|
||||
};
|
||||
|
||||
private static RunContext MapToContext(RunContextDto dto) => new()
|
||||
{
|
||||
FocusedCveId = dto.FocusedCveId,
|
||||
FocusedComponent = dto.FocusedComponent,
|
||||
SbomDigest = dto.SbomDigest,
|
||||
ImageReference = dto.ImageReference,
|
||||
Tags = [.. dto.Tags ?? []]
|
||||
};
|
||||
}
|
||||
|
||||
// DTOs
|
||||
|
||||
/// <summary>DTO for creating a run.</summary>
|
||||
public sealed record CreateRunRequestDto
|
||||
{
|
||||
/// <summary>Gets the run title.</summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>Gets the run objective.</summary>
|
||||
public string? Objective { get; init; }
|
||||
|
||||
/// <summary>Gets the context.</summary>
|
||||
public RunContextDto? Context { get; init; }
|
||||
|
||||
/// <summary>Gets metadata.</summary>
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for run context.</summary>
|
||||
public sealed record RunContextDto
|
||||
{
|
||||
/// <summary>Gets the focused CVE ID.</summary>
|
||||
public string? FocusedCveId { get; init; }
|
||||
|
||||
/// <summary>Gets the focused component.</summary>
|
||||
public string? FocusedComponent { get; init; }
|
||||
|
||||
/// <summary>Gets the SBOM digest.</summary>
|
||||
public string? SbomDigest { get; init; }
|
||||
|
||||
/// <summary>Gets the image reference.</summary>
|
||||
public string? ImageReference { get; init; }
|
||||
|
||||
/// <summary>Gets the tags.</summary>
|
||||
public List<string>? Tags { get; init; }
|
||||
|
||||
/// <summary>Gets whether OpsMemory enrichment was applied.</summary>
|
||||
public bool IsOpsMemoryEnriched { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for a run.</summary>
|
||||
public sealed record RunDto
|
||||
{
|
||||
/// <summary>Gets the run ID.</summary>
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>Gets the tenant ID.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Gets the initiator.</summary>
|
||||
public required string InitiatedBy { get; init; }
|
||||
|
||||
/// <summary>Gets the title.</summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>Gets the objective.</summary>
|
||||
public string? Objective { get; init; }
|
||||
|
||||
/// <summary>Gets the status.</summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>Gets the created timestamp.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Gets the updated timestamp.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>Gets the completed timestamp.</summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>Gets the event count.</summary>
|
||||
public int EventCount { get; init; }
|
||||
|
||||
/// <summary>Gets the artifact count.</summary>
|
||||
public int ArtifactCount { get; init; }
|
||||
|
||||
/// <summary>Gets the content digest.</summary>
|
||||
public string? ContentDigest { get; init; }
|
||||
|
||||
/// <summary>Gets whether the run is attested.</summary>
|
||||
public bool IsAttested { get; init; }
|
||||
|
||||
/// <summary>Gets the context.</summary>
|
||||
public RunContextDto? Context { get; init; }
|
||||
|
||||
/// <summary>Gets the approval info.</summary>
|
||||
public ApprovalInfoDto? Approval { get; init; }
|
||||
|
||||
/// <summary>Gets metadata.</summary>
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for run event.</summary>
|
||||
public sealed record RunEventDto
|
||||
{
|
||||
/// <summary>Gets the event ID.</summary>
|
||||
public required string EventId { get; init; }
|
||||
|
||||
/// <summary>Gets the event type.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Gets the timestamp.</summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>Gets the actor ID.</summary>
|
||||
public string? ActorId { get; init; }
|
||||
|
||||
/// <summary>Gets the sequence number.</summary>
|
||||
public int SequenceNumber { get; init; }
|
||||
|
||||
/// <summary>Gets the parent event ID.</summary>
|
||||
public string? ParentEventId { get; init; }
|
||||
|
||||
/// <summary>Gets the evidence link count.</summary>
|
||||
public int EvidenceLinkCount { get; init; }
|
||||
|
||||
/// <summary>Gets metadata.</summary>
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for approval info.</summary>
|
||||
public sealed record ApprovalInfoDto
|
||||
{
|
||||
/// <summary>Gets whether approval is required.</summary>
|
||||
public bool Required { get; init; }
|
||||
|
||||
/// <summary>Gets the approvers.</summary>
|
||||
public List<string> Approvers { get; init; } = [];
|
||||
|
||||
/// <summary>Gets whether approved.</summary>
|
||||
public bool? Approved { get; init; }
|
||||
|
||||
/// <summary>Gets who approved.</summary>
|
||||
public string? ApprovedBy { get; init; }
|
||||
|
||||
/// <summary>Gets when approved.</summary>
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>Gets the reason.</summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for query results.</summary>
|
||||
public sealed record RunQueryResultDto
|
||||
{
|
||||
/// <summary>Gets the runs.</summary>
|
||||
public required ImmutableArray<RunDto> Runs { get; init; }
|
||||
|
||||
/// <summary>Gets the total count.</summary>
|
||||
public required int TotalCount { get; init; }
|
||||
|
||||
/// <summary>Gets whether there are more results.</summary>
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for adding an event.</summary>
|
||||
public sealed record AddEventRequestDto
|
||||
{
|
||||
/// <summary>Gets the event type.</summary>
|
||||
public required RunEventType Type { get; init; }
|
||||
|
||||
/// <summary>Gets the content.</summary>
|
||||
public RunEventContent? Content { get; init; }
|
||||
|
||||
/// <summary>Gets evidence links.</summary>
|
||||
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
|
||||
|
||||
/// <summary>Gets the parent event ID.</summary>
|
||||
public string? ParentEventId { get; init; }
|
||||
|
||||
/// <summary>Gets metadata.</summary>
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for adding a turn.</summary>
|
||||
public sealed record AddTurnRequestDto
|
||||
{
|
||||
/// <summary>Gets the message.</summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>Gets evidence links.</summary>
|
||||
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for proposing an action.</summary>
|
||||
public sealed record ProposeActionRequestDto
|
||||
{
|
||||
/// <summary>Gets the action type.</summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>Gets the subject.</summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>Gets the rationale.</summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>Gets whether approval is required.</summary>
|
||||
public bool RequiresApproval { get; init; } = true;
|
||||
|
||||
/// <summary>Gets the parameters.</summary>
|
||||
public Dictionary<string, string>? Parameters { get; init; }
|
||||
|
||||
/// <summary>Gets evidence links.</summary>
|
||||
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for requesting approval.</summary>
|
||||
public sealed record RequestApprovalDto
|
||||
{
|
||||
/// <summary>Gets the approvers.</summary>
|
||||
public required List<string> Approvers { get; init; }
|
||||
|
||||
/// <summary>Gets the reason.</summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for approval decision.</summary>
|
||||
public sealed record ApprovalDecisionDto
|
||||
{
|
||||
/// <summary>Gets whether approved.</summary>
|
||||
public required bool Approved { get; init; }
|
||||
|
||||
/// <summary>Gets the reason.</summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for adding an artifact.</summary>
|
||||
public sealed record AddArtifactRequestDto
|
||||
{
|
||||
/// <summary>Gets the artifact ID.</summary>
|
||||
public string? ArtifactId { get; init; }
|
||||
|
||||
/// <summary>Gets the artifact type.</summary>
|
||||
public required ArtifactType Type { get; init; }
|
||||
|
||||
/// <summary>Gets the name.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Gets the description.</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Gets the content digest.</summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>Gets the content size.</summary>
|
||||
public long ContentSize { get; init; }
|
||||
|
||||
/// <summary>Gets the media type.</summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>Gets the storage URI.</summary>
|
||||
public string? StorageUri { get; init; }
|
||||
|
||||
/// <summary>Gets whether inline.</summary>
|
||||
public bool IsInline { get; init; }
|
||||
|
||||
/// <summary>Gets inline content.</summary>
|
||||
public string? InlineContent { get; init; }
|
||||
|
||||
/// <summary>Gets metadata.</summary>
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for completing a run.</summary>
|
||||
public sealed record CompleteRunRequestDto
|
||||
{
|
||||
/// <summary>Gets the summary.</summary>
|
||||
public string? Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for canceling a run.</summary>
|
||||
public sealed record CancelRunRequestDto
|
||||
{
|
||||
/// <summary>Gets the reason.</summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for hand off.</summary>
|
||||
public sealed record HandOffRequestDto
|
||||
{
|
||||
/// <summary>Gets the target user ID.</summary>
|
||||
public required string ToUserId { get; init; }
|
||||
|
||||
/// <summary>Gets the message.</summary>
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Attestation;
|
||||
using StellaOps.AdvisoryAI.Caching;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
using StellaOps.Evidence.Pack;
|
||||
using StellaOps.AdvisoryAI.Diagnostics;
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using StellaOps.AdvisoryAI.Hosting;
|
||||
@@ -56,6 +57,9 @@ builder.Services.AddSingleton<IAiJustificationGenerator, DefaultAiJustificationG
|
||||
builder.Services.AddAiAttestationServices();
|
||||
builder.Services.AddInMemoryAiAttestationStore();
|
||||
|
||||
// Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
|
||||
builder.Services.AddEvidencePack();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddProblemDetails();
|
||||
@@ -188,6 +192,9 @@ app.MapGet("/v1/advisory-ai/conversations", HandleListConversations)
|
||||
// AI Attestations endpoints (Sprint: SPRINT_20260109_011_001 Task: AIAT-009)
|
||||
app.MapAttestationEndpoints();
|
||||
|
||||
// Evidence Pack endpoints (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
|
||||
app.MapEvidencePackEndpoints();
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
|
||||
@@ -15,5 +15,7 @@
|
||||
<ProjectReference Include="..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<!-- AI Attestations (Sprint: SPRINT_20260109_011_001) -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
|
||||
<!-- Evidence Packs (Sprint: SPRINT_20260109_011_005) -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
151
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionAuditLedger.cs
Normal file
151
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionAuditLedger.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
// <copyright file="ActionAuditLedger.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory action audit ledger for development and testing.
|
||||
/// In production, this would use PostgreSQL with proper indexing.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-006
|
||||
/// </summary>
|
||||
internal sealed class ActionAuditLedger : IActionAuditLedger
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ActionAuditEntry> _entries = new();
|
||||
private readonly ILogger<ActionAuditLedger> _logger;
|
||||
private readonly AuditLedgerOptions _options;
|
||||
|
||||
public ActionAuditLedger(
|
||||
IOptions<AuditLedgerOptions> options,
|
||||
ILogger<ActionAuditLedger> logger)
|
||||
{
|
||||
_options = options?.Value ?? new AuditLedgerOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordAsync(ActionAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
_entries[entry.EntryId] = entry;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Recorded audit entry {EntryId}: {ActionType} by {Actor} -> {Outcome}",
|
||||
entry.EntryId, entry.ActionType, entry.Actor, entry.Outcome);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<ActionAuditEntry>> QueryAsync(
|
||||
ActionAuditQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var entries = _entries.Values.AsEnumerable();
|
||||
|
||||
// Apply filters
|
||||
if (!string.IsNullOrEmpty(query.TenantId))
|
||||
{
|
||||
entries = entries.Where(e => e.TenantId.Equals(query.TenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ActionType))
|
||||
{
|
||||
entries = entries.Where(e => e.ActionType.Equals(query.ActionType, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Actor))
|
||||
{
|
||||
entries = entries.Where(e => e.Actor.Equals(query.Actor, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.Outcome.HasValue)
|
||||
{
|
||||
entries = entries.Where(e => e.Outcome == query.Outcome.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.RunId))
|
||||
{
|
||||
entries = entries.Where(e => e.RunId != null && e.RunId.Equals(query.RunId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.CveId))
|
||||
{
|
||||
entries = entries.Where(e => e.CveId != null && e.CveId.Equals(query.CveId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ImageDigest))
|
||||
{
|
||||
entries = entries.Where(e => e.ImageDigest != null && e.ImageDigest.Equals(query.ImageDigest, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.FromTimestamp.HasValue)
|
||||
{
|
||||
entries = entries.Where(e => e.Timestamp >= query.FromTimestamp.Value);
|
||||
}
|
||||
|
||||
if (query.ToTimestamp.HasValue)
|
||||
{
|
||||
entries = entries.Where(e => e.Timestamp < query.ToTimestamp.Value);
|
||||
}
|
||||
|
||||
// Order by timestamp descending, apply pagination
|
||||
var result = entries
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ActionAuditEntry?> GetAsync(
|
||||
string entryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(entryId);
|
||||
|
||||
_entries.TryGetValue(entryId, out var entry);
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<ActionAuditEntry>> GetByRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(runId);
|
||||
|
||||
var entries = _entries.Values
|
||||
.Where(e => e.RunId != null && e.RunId.Equals(runId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(e => e.Timestamp)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(entries);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the audit ledger.
|
||||
/// </summary>
|
||||
public sealed class AuditLedgerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Days to retain audit entries.
|
||||
/// </summary>
|
||||
public int RetentionDays { get; set; } = 365;
|
||||
|
||||
/// <summary>
|
||||
/// Whether audit logging is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
135
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionDefinition.cs
Normal file
135
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionDefinition.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
// <copyright file="ActionDefinition.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the metadata and constraints for an action type.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-003
|
||||
/// </summary>
|
||||
public sealed record ActionDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique action type identifier (e.g., "approve", "quarantine").
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of what this action does.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Role required to execute this action.
|
||||
/// </summary>
|
||||
public required string RequiredRole { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk level of this action for policy decisions.
|
||||
/// </summary>
|
||||
public required ActionRiskLevel RiskLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action is idempotent (safe to retry).
|
||||
/// </summary>
|
||||
public required bool IsIdempotent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action supports rollback/compensation.
|
||||
/// </summary>
|
||||
public required bool HasCompensation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action type for compensation/rollback, if supported.
|
||||
/// </summary>
|
||||
public string? CompensationActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameters accepted by this action.
|
||||
/// </summary>
|
||||
public ImmutableArray<ActionParameterDefinition> Parameters { get; init; } =
|
||||
ImmutableArray<ActionParameterDefinition>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Environments where this action can be executed.
|
||||
/// Empty means all environments.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AllowedEnvironments { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tags for categorization.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risk levels for actions, affecting policy decisions and approval requirements.
|
||||
/// </summary>
|
||||
public enum ActionRiskLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only, informational actions.
|
||||
/// </summary>
|
||||
Low = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Creates records, sends notifications.
|
||||
/// </summary>
|
||||
Medium = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Modifies security posture.
|
||||
/// </summary>
|
||||
High = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Production blockers, quarantine operations.
|
||||
/// </summary>
|
||||
Critical = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Definition of an action parameter.
|
||||
/// </summary>
|
||||
public sealed record ActionParameterDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Parameter name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameter type (string, int, bool, etc.).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this parameter is required.
|
||||
/// </summary>
|
||||
public required bool Required { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the parameter.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default value if not provided.
|
||||
/// </summary>
|
||||
public string? DefaultValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation regex pattern.
|
||||
/// </summary>
|
||||
public string? ValidationPattern { get; init; }
|
||||
}
|
||||
456
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionExecutor.cs
Normal file
456
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionExecutor.cs
Normal file
@@ -0,0 +1,456 @@
|
||||
// <copyright file="ActionExecutor.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Executes AI-proposed actions with policy gate integration, idempotency, and audit logging.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-007
|
||||
/// </summary>
|
||||
internal sealed class ActionExecutor : IActionExecutor
|
||||
{
|
||||
private readonly IActionPolicyGate _policyGate;
|
||||
private readonly IActionRegistry _actionRegistry;
|
||||
private readonly IIdempotencyHandler _idempotencyHandler;
|
||||
private readonly IApprovalWorkflowAdapter _approvalAdapter;
|
||||
private readonly IActionAuditLedger _auditLedger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidGenerator _guidGenerator;
|
||||
private readonly ILogger<ActionExecutor> _logger;
|
||||
private readonly ActionExecutorOptions _options;
|
||||
|
||||
public ActionExecutor(
|
||||
IActionPolicyGate policyGate,
|
||||
IActionRegistry actionRegistry,
|
||||
IIdempotencyHandler idempotencyHandler,
|
||||
IApprovalWorkflowAdapter approvalAdapter,
|
||||
IActionAuditLedger auditLedger,
|
||||
TimeProvider timeProvider,
|
||||
IGuidGenerator guidGenerator,
|
||||
IOptions<ActionExecutorOptions> options,
|
||||
ILogger<ActionExecutor> logger)
|
||||
{
|
||||
_policyGate = policyGate ?? throw new ArgumentNullException(nameof(policyGate));
|
||||
_actionRegistry = actionRegistry ?? throw new ArgumentNullException(nameof(actionRegistry));
|
||||
_idempotencyHandler = idempotencyHandler ?? throw new ArgumentNullException(nameof(idempotencyHandler));
|
||||
_approvalAdapter = approvalAdapter ?? throw new ArgumentNullException(nameof(approvalAdapter));
|
||||
_auditLedger = auditLedger ?? throw new ArgumentNullException(nameof(auditLedger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidGenerator = guidGenerator ?? throw new ArgumentNullException(nameof(guidGenerator));
|
||||
_options = options?.Value ?? new ActionExecutorOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ActionExecutionResult> ExecuteAsync(
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
ArgumentNullException.ThrowIfNull(decision);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var executionId = _guidGenerator.NewGuid().ToString();
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Executing action {ActionType} (execution: {ExecutionId}) for user {UserId}",
|
||||
proposal.ActionType, executionId, context.UserId);
|
||||
|
||||
// 1. Check idempotency first
|
||||
if (_options.EnableIdempotency)
|
||||
{
|
||||
var idempotencyKey = _idempotencyHandler.GenerateKey(proposal, context);
|
||||
var idempotencyCheck = await _idempotencyHandler.CheckAsync(idempotencyKey, cancellationToken);
|
||||
|
||||
if (idempotencyCheck.AlreadyExecuted)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Action {ActionType} skipped due to idempotency (previous execution: {PreviousId})",
|
||||
proposal.ActionType, idempotencyCheck.PreviousResult?.ExecutionId);
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.IdempotentSkipped,
|
||||
null, null, cancellationToken);
|
||||
|
||||
return idempotencyCheck.PreviousResult!;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Evaluate policy if not already evaluated
|
||||
var policyDecision = decision;
|
||||
if (decision.Decision == PolicyDecisionKind.Indeterminate)
|
||||
{
|
||||
policyDecision = await _policyGate.EvaluateAsync(proposal, context, cancellationToken);
|
||||
}
|
||||
|
||||
// 3. Handle based on policy decision
|
||||
ActionExecutionResult result;
|
||||
switch (policyDecision.Decision)
|
||||
{
|
||||
case PolicyDecisionKind.Allow:
|
||||
result = await ExecuteImmediatelyAsync(executionId, proposal, context, policyDecision, startedAt, cancellationToken);
|
||||
break;
|
||||
|
||||
case PolicyDecisionKind.AllowWithApproval:
|
||||
result = await ExecuteWithApprovalAsync(executionId, proposal, context, policyDecision, startedAt, cancellationToken);
|
||||
break;
|
||||
|
||||
case PolicyDecisionKind.Deny:
|
||||
result = CreateDeniedResult(executionId, proposal, policyDecision, startedAt);
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.DeniedByPolicy,
|
||||
policyDecision, null, cancellationToken);
|
||||
break;
|
||||
|
||||
case PolicyDecisionKind.DenyWithOverride:
|
||||
result = CreateDeniedWithOverrideResult(executionId, proposal, policyDecision, startedAt);
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.DeniedByPolicy,
|
||||
policyDecision, null, cancellationToken);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Unexpected policy decision: {policyDecision.Decision}");
|
||||
}
|
||||
|
||||
// 4. Record idempotency if execution was successful
|
||||
if (_options.EnableIdempotency && result.Outcome == ActionExecutionOutcome.Success)
|
||||
{
|
||||
var idempotencyKey = _idempotencyHandler.GenerateKey(proposal, context);
|
||||
await _idempotencyHandler.RecordExecutionAsync(idempotencyKey, result, context, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ActionRollbackResult> RollbackAsync(
|
||||
string executionId,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(executionId);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// In a real implementation, this would:
|
||||
// 1. Look up the original execution
|
||||
// 2. Find the compensation action
|
||||
// 3. Execute the compensation
|
||||
// 4. Record the rollback
|
||||
|
||||
_logger.LogInformation(
|
||||
"Rollback requested for execution {ExecutionId}",
|
||||
executionId);
|
||||
|
||||
// Stub implementation
|
||||
return new ActionRollbackResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Rollback not yet implemented",
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "NOT_IMPLEMENTED",
|
||||
Message = "Rollback functionality is not yet implemented"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ActionExecutionStatus?> GetStatusAsync(
|
||||
string executionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// In a real implementation, this would look up execution status from storage
|
||||
return Task.FromResult<ActionExecutionStatus?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<ActionTypeInfo> GetSupportedActionTypes()
|
||||
{
|
||||
return _actionRegistry.GetAllActions()
|
||||
.Select(a => new ActionTypeInfo
|
||||
{
|
||||
Type = a.ActionType,
|
||||
DisplayName = a.DisplayName,
|
||||
Description = a.Description,
|
||||
Category = GetActionCategory(a),
|
||||
Parameters = a.Parameters.Select(p => new ActionParameterInfo
|
||||
{
|
||||
Name = p.Name,
|
||||
DisplayName = p.Name,
|
||||
Description = p.Description,
|
||||
IsRequired = p.Required,
|
||||
Type = p.Type,
|
||||
DefaultValue = p.DefaultValue
|
||||
}).ToImmutableArray(),
|
||||
RequiredPermission = a.RequiredRole,
|
||||
SupportsRollback = a.HasCompensation,
|
||||
IsDestructive = a.RiskLevel >= ActionRiskLevel.High
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<ActionExecutionResult> ExecuteImmediatelyAsync(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
ActionPolicyDecision decision,
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Executing action {ActionType} immediately (policy: {PolicyId})",
|
||||
proposal.ActionType, decision.PolicyId);
|
||||
|
||||
try
|
||||
{
|
||||
// Perform the actual action execution
|
||||
// In a real implementation, this would dispatch to specific action handlers
|
||||
var result = await PerformActionAsync(executionId, proposal, context, startedAt, cancellationToken);
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context,
|
||||
result.Outcome == ActionExecutionOutcome.Success
|
||||
? ActionAuditOutcome.Executed
|
||||
: ActionAuditOutcome.ExecutionFailed,
|
||||
decision, result.Error?.Message, cancellationToken);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Action execution failed for {ActionType}", proposal.ActionType);
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.ExecutionFailed,
|
||||
decision, ex.Message, cancellationToken);
|
||||
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Failed,
|
||||
Message = $"Execution failed: {ex.Message}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false,
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "EXECUTION_FAILED",
|
||||
Message = ex.Message,
|
||||
IsRetryable = true
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ActionExecutionResult> ExecuteWithApprovalAsync(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
ActionPolicyDecision decision,
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Action {ActionType} requires approval (policy: {PolicyId})",
|
||||
proposal.ActionType, decision.PolicyId);
|
||||
|
||||
// Create approval request
|
||||
var approvalRequest = await _approvalAdapter.CreateApprovalRequestAsync(
|
||||
proposal, decision, context, cancellationToken);
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.ApprovalRequested,
|
||||
decision, null, cancellationToken, approvalRequest.RequestId);
|
||||
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.PendingApproval,
|
||||
Message = $"Approval required from: {string.Join(", ", decision.RequiredApprovers.Select(a => a.Identifier))}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = null, // Not completed yet
|
||||
CanRollback = false,
|
||||
OutputData = new Dictionary<string, string>
|
||||
{
|
||||
["approvalRequestId"] = approvalRequest.RequestId,
|
||||
["approvalWorkflowId"] = approvalRequest.WorkflowId
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ActionExecutionResult> PerformActionAsync(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Get action definition for rollback capability
|
||||
var actionDef = _actionRegistry.GetAction(proposal.ActionType);
|
||||
var canRollback = actionDef?.HasCompensation ?? false;
|
||||
|
||||
// In a real implementation, this would dispatch to specific action handlers
|
||||
// For now, simulate successful execution
|
||||
_logger.LogInformation(
|
||||
"Performed action {ActionType} with parameters: {Parameters}",
|
||||
proposal.ActionType,
|
||||
string.Join(", ", proposal.Parameters.Select(p => $"{p.Key}={p.Value}")));
|
||||
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Success,
|
||||
Message = $"Action {proposal.ActionType} executed successfully",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = canRollback,
|
||||
OutputData = new Dictionary<string, string>
|
||||
{
|
||||
["actionType"] = proposal.ActionType,
|
||||
["status"] = "completed"
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
private ActionExecutionResult CreateDeniedResult(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
DateTimeOffset startedAt)
|
||||
{
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Failed,
|
||||
Message = $"Action denied by policy: {decision.Reason}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false,
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "POLICY_DENIED",
|
||||
Message = decision.Reason ?? "Action denied by policy",
|
||||
IsRetryable = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private ActionExecutionResult CreateDeniedWithOverrideResult(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
DateTimeOffset startedAt)
|
||||
{
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Failed,
|
||||
Message = $"Action denied (override available): {decision.Reason}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false,
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "POLICY_DENIED_OVERRIDE_AVAILABLE",
|
||||
Message = decision.Reason ?? "Action denied by policy",
|
||||
Details = "An administrator can override this decision",
|
||||
IsRetryable = false
|
||||
},
|
||||
OutputData = new Dictionary<string, string>
|
||||
{
|
||||
["overrideAvailable"] = "true",
|
||||
["policyId"] = decision.PolicyId ?? ""
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task RecordAuditEntryAsync(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
ActionAuditOutcome outcome,
|
||||
ActionPolicyDecision? decision,
|
||||
string? errorMessage,
|
||||
CancellationToken cancellationToken,
|
||||
string? approvalRequestId = null)
|
||||
{
|
||||
var entry = new ActionAuditEntry
|
||||
{
|
||||
EntryId = _guidGenerator.NewGuid().ToString(),
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
ActionType = proposal.ActionType,
|
||||
Actor = context.UserId,
|
||||
Outcome = outcome,
|
||||
RunId = context.RunId,
|
||||
FindingId = context.FindingId,
|
||||
CveId = context.CveId,
|
||||
ImageDigest = context.ImageDigest,
|
||||
PolicyId = decision?.PolicyId,
|
||||
PolicyResult = decision?.Decision,
|
||||
ApprovalRequestId = approvalRequestId,
|
||||
Parameters = proposal.Parameters,
|
||||
ExecutionId = executionId,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
|
||||
await _auditLedger.RecordAsync(entry, cancellationToken);
|
||||
}
|
||||
|
||||
private static string? GetActionCategory(ActionDefinition action)
|
||||
{
|
||||
if (action.Tags.Contains("cve", StringComparer.OrdinalIgnoreCase) ||
|
||||
action.Tags.Contains("vex", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Vulnerability Management";
|
||||
}
|
||||
|
||||
if (action.Tags.Contains("container", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Container Security";
|
||||
}
|
||||
|
||||
if (action.Tags.Contains("report", StringComparer.OrdinalIgnoreCase) ||
|
||||
action.Tags.Contains("export", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Reporting";
|
||||
}
|
||||
|
||||
if (action.Tags.Contains("notification", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Communication";
|
||||
}
|
||||
|
||||
return "General";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for action execution.
|
||||
/// </summary>
|
||||
public sealed class ActionExecutorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether idempotency checking is enabled.
|
||||
/// </summary>
|
||||
public bool EnableIdempotency { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether audit logging is enabled.
|
||||
/// </summary>
|
||||
public bool EnableAuditLogging { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default timeout for action execution.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
352
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionPolicyGate.cs
Normal file
352
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionPolicyGate.cs
Normal file
@@ -0,0 +1,352 @@
|
||||
// <copyright file="ActionPolicyGate.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates action proposals against K4 lattice policy rules.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-002
|
||||
/// </summary>
|
||||
internal sealed class ActionPolicyGate : IActionPolicyGate
|
||||
{
|
||||
private readonly IActionRegistry _actionRegistry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ActionPolicyGate> _logger;
|
||||
private readonly ActionPolicyOptions _options;
|
||||
|
||||
public ActionPolicyGate(
|
||||
IActionRegistry actionRegistry,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<ActionPolicyOptions> options,
|
||||
ILogger<ActionPolicyGate> logger)
|
||||
{
|
||||
_actionRegistry = actionRegistry ?? throw new ArgumentNullException(nameof(actionRegistry));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options?.Value ?? new ActionPolicyOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ActionPolicyDecision> EvaluateAsync(
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Evaluating policy for action {ActionType} by user {UserId} in tenant {TenantId}",
|
||||
proposal.ActionType, context.UserId, context.TenantId);
|
||||
|
||||
// Get action definition
|
||||
var actionDef = _actionRegistry.GetAction(proposal.ActionType);
|
||||
if (actionDef is null)
|
||||
{
|
||||
return Task.FromResult(CreateDenyDecision(
|
||||
$"Unknown action type: {proposal.ActionType}",
|
||||
"action-validation",
|
||||
allowOverride: false));
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
var paramValidation = _actionRegistry.ValidateParameters(proposal.ActionType, proposal.Parameters);
|
||||
if (!paramValidation.IsValid)
|
||||
{
|
||||
return Task.FromResult(CreateDenyDecision(
|
||||
$"Invalid parameters: {string.Join(", ", paramValidation.Errors)}",
|
||||
"parameter-validation",
|
||||
allowOverride: false));
|
||||
}
|
||||
|
||||
// Check required role
|
||||
if (!HasRequiredRole(context.UserRoles, actionDef.RequiredRole))
|
||||
{
|
||||
return Task.FromResult(CreateDenyDecision(
|
||||
$"Missing required role: {actionDef.RequiredRole}",
|
||||
"role-check",
|
||||
allowOverride: false));
|
||||
}
|
||||
|
||||
// Check environment restrictions
|
||||
if (actionDef.AllowedEnvironments.Length > 0 &&
|
||||
!actionDef.AllowedEnvironments.Contains(context.Environment, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(CreateDenyDecision(
|
||||
$"Action not allowed in environment: {context.Environment}",
|
||||
"environment-check",
|
||||
allowOverride: true));
|
||||
}
|
||||
|
||||
// Evaluate based on risk level and K4 context
|
||||
var decision = EvaluateRiskLevel(actionDef, context);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Policy decision for {ActionType}: {Decision} (policy: {PolicyId})",
|
||||
proposal.ActionType, decision.Decision, decision.PolicyId);
|
||||
|
||||
return Task.FromResult(decision);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PolicyExplanation> ExplainAsync(
|
||||
ActionPolicyDecision decision,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var details = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(decision.Reason))
|
||||
{
|
||||
details.Add(decision.Reason);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(decision.K4Position))
|
||||
{
|
||||
details.Add($"K4 lattice position: {decision.K4Position}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(decision.VexStatus))
|
||||
{
|
||||
details.Add($"VEX status: {decision.VexStatus}");
|
||||
}
|
||||
|
||||
var suggestedActions = new List<string>();
|
||||
switch (decision.Decision)
|
||||
{
|
||||
case PolicyDecisionKind.Deny:
|
||||
suggestedActions.Add("Contact your security administrator to request elevated permissions");
|
||||
break;
|
||||
case PolicyDecisionKind.DenyWithOverride:
|
||||
suggestedActions.Add("Request an admin override if you have business justification");
|
||||
break;
|
||||
case PolicyDecisionKind.AllowWithApproval:
|
||||
suggestedActions.Add($"Request approval from: {string.Join(", ", decision.RequiredApprovers.Select(a => a.Identifier))}");
|
||||
break;
|
||||
}
|
||||
|
||||
var explanation = new PolicyExplanation
|
||||
{
|
||||
Summary = GenerateSummary(decision),
|
||||
Details = details.ToImmutableArray(),
|
||||
PolicyReferences = decision.PolicyId is not null
|
||||
? ImmutableArray.Create(new PolicyReference
|
||||
{
|
||||
PolicyId = decision.PolicyId,
|
||||
Name = GetPolicyName(decision.PolicyId)
|
||||
})
|
||||
: ImmutableArray<PolicyReference>.Empty,
|
||||
SuggestedActions = suggestedActions.ToImmutableArray()
|
||||
};
|
||||
|
||||
return Task.FromResult(explanation);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IdempotencyCheckResult> CheckIdempotencyAsync(
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Delegate to IdempotencyHandler - this is a stub for interface compliance
|
||||
// The actual check is done in ActionExecutor
|
||||
return Task.FromResult(new IdempotencyCheckResult
|
||||
{
|
||||
WasExecuted = false
|
||||
});
|
||||
}
|
||||
|
||||
private ActionPolicyDecision EvaluateRiskLevel(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
// Map risk level to policy decision
|
||||
return actionDef.RiskLevel switch
|
||||
{
|
||||
ActionRiskLevel.Low => CreateAllowDecision(actionDef, context),
|
||||
ActionRiskLevel.Medium => EvaluateMediumRisk(actionDef, context),
|
||||
ActionRiskLevel.High => EvaluateHighRisk(actionDef, context),
|
||||
ActionRiskLevel.Critical => EvaluateCriticalRisk(actionDef, context),
|
||||
_ => CreateDenyDecision("Unknown risk level", "risk-evaluation", allowOverride: true)
|
||||
};
|
||||
}
|
||||
|
||||
private ActionPolicyDecision CreateAllowDecision(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "low-risk-auto-allow",
|
||||
Reason = "Low-risk action allowed automatically"
|
||||
};
|
||||
}
|
||||
|
||||
private ActionPolicyDecision EvaluateMediumRisk(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
// Check if user has elevated role
|
||||
if (HasRequiredRole(context.UserRoles, "security-lead") ||
|
||||
HasRequiredRole(context.UserRoles, "admin"))
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "medium-risk-elevated-role",
|
||||
Reason = "Medium-risk action allowed for elevated role"
|
||||
};
|
||||
}
|
||||
|
||||
// Require team lead approval
|
||||
return CreateApprovalDecision(
|
||||
actionDef,
|
||||
context,
|
||||
"medium-risk-approval",
|
||||
"Medium-risk action requires team lead approval",
|
||||
[new RequiredApprover { Type = ApproverType.Role, Identifier = "team-lead" }]);
|
||||
}
|
||||
|
||||
private ActionPolicyDecision EvaluateHighRisk(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
// Check if admin
|
||||
if (HasRequiredRole(context.UserRoles, "admin"))
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "high-risk-admin",
|
||||
Reason = "High-risk action allowed for admin"
|
||||
};
|
||||
}
|
||||
|
||||
// Require security lead approval
|
||||
return CreateApprovalDecision(
|
||||
actionDef,
|
||||
context,
|
||||
"high-risk-approval",
|
||||
"High-risk action requires security lead approval",
|
||||
[new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }]);
|
||||
}
|
||||
|
||||
private ActionPolicyDecision EvaluateCriticalRisk(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
// Critical actions always require multi-party approval
|
||||
// Even admins need CISO sign-off for critical actions in production
|
||||
if (context.Environment.Equals("production", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreateApprovalDecision(
|
||||
actionDef,
|
||||
context,
|
||||
"critical-risk-production",
|
||||
"Critical action in production requires CISO and security lead approval",
|
||||
[
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "ciso" },
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }
|
||||
]);
|
||||
}
|
||||
|
||||
// Non-production: just security lead
|
||||
return CreateApprovalDecision(
|
||||
actionDef,
|
||||
context,
|
||||
"critical-risk-non-prod",
|
||||
"Critical action requires security lead approval",
|
||||
[new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }]);
|
||||
}
|
||||
|
||||
private ActionPolicyDecision CreateApprovalDecision(
|
||||
ActionDefinition actionDef,
|
||||
ActionContext context,
|
||||
string policyId,
|
||||
string reason,
|
||||
ImmutableArray<RequiredApprover> approvers)
|
||||
{
|
||||
var timeout = actionDef.RiskLevel == ActionRiskLevel.Critical
|
||||
? TimeSpan.FromHours(_options.CriticalTimeoutHours)
|
||||
: TimeSpan.FromHours(_options.DefaultTimeoutHours);
|
||||
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.AllowWithApproval,
|
||||
PolicyId = policyId,
|
||||
Reason = reason,
|
||||
RequiredApprovers = approvers,
|
||||
ApprovalWorkflowId = $"action-approval-{actionDef.RiskLevel.ToString().ToLowerInvariant()}",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().Add(timeout)
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionPolicyDecision CreateDenyDecision(string reason, string policyId, bool allowOverride)
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = allowOverride ? PolicyDecisionKind.DenyWithOverride : PolicyDecisionKind.Deny,
|
||||
PolicyId = policyId,
|
||||
Reason = reason
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasRequiredRole(ImmutableArray<string> userRoles, string requiredRole)
|
||||
{
|
||||
// Admin role can do everything
|
||||
if (userRoles.Contains("admin", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return userRoles.Contains(requiredRole, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GenerateSummary(ActionPolicyDecision decision)
|
||||
{
|
||||
return decision.Decision switch
|
||||
{
|
||||
PolicyDecisionKind.Allow => "Action is allowed and can proceed immediately.",
|
||||
PolicyDecisionKind.AllowWithApproval => "Action requires approval before execution.",
|
||||
PolicyDecisionKind.Deny => "Action is denied by policy.",
|
||||
PolicyDecisionKind.DenyWithOverride => "Action is denied but can be overridden by an administrator.",
|
||||
PolicyDecisionKind.Indeterminate => "Unable to determine policy decision.",
|
||||
_ => "Unknown policy decision."
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetPolicyName(string policyId)
|
||||
{
|
||||
return policyId switch
|
||||
{
|
||||
"low-risk-auto-allow" => "Low Risk Auto-Allow Policy",
|
||||
"medium-risk-elevated-role" => "Medium Risk Elevated Role Policy",
|
||||
"medium-risk-approval" => "Medium Risk Approval Policy",
|
||||
"high-risk-admin" => "High Risk Admin Policy",
|
||||
"high-risk-approval" => "High Risk Approval Policy",
|
||||
"critical-risk-production" => "Critical Risk Production Policy",
|
||||
"critical-risk-non-prod" => "Critical Risk Non-Production Policy",
|
||||
"role-check" => "Role Authorization Policy",
|
||||
"environment-check" => "Environment Restriction Policy",
|
||||
"action-validation" => "Action Validation Policy",
|
||||
"parameter-validation" => "Parameter Validation Policy",
|
||||
_ => policyId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for action policy evaluation.
|
||||
/// </summary>
|
||||
public sealed class ActionPolicyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default timeout in hours for approval requests.
|
||||
/// </summary>
|
||||
public int DefaultTimeoutHours { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in hours for critical risk approval requests.
|
||||
/// </summary>
|
||||
public int CriticalTimeoutHours { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable K4 lattice integration.
|
||||
/// </summary>
|
||||
public bool EnableK4Integration { get; set; } = true;
|
||||
}
|
||||
433
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionRegistry.cs
Normal file
433
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionRegistry.cs
Normal file
@@ -0,0 +1,433 @@
|
||||
// <copyright file="ActionRegistry.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of action registry with built-in action definitions.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-003
|
||||
/// </summary>
|
||||
internal sealed partial class ActionRegistry : IActionRegistry
|
||||
{
|
||||
private readonly FrozenDictionary<string, ActionDefinition> _actions;
|
||||
|
||||
public ActionRegistry()
|
||||
{
|
||||
_actions = CreateBuiltInActions().ToFrozenDictionary(a => a.ActionType, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ActionDefinition? GetAction(string actionType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionType);
|
||||
return _actions.GetValueOrDefault(actionType);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<ActionDefinition> GetAllActions() =>
|
||||
_actions.Values.ToImmutableArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<ActionDefinition> GetActionsByRiskLevel(ActionRiskLevel riskLevel) =>
|
||||
_actions.Values.Where(a => a.RiskLevel == riskLevel).ToImmutableArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<ActionDefinition> GetActionsByTag(string tag) =>
|
||||
_actions.Values.Where(a => a.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)).ToImmutableArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ActionParameterValidationResult ValidateParameters(
|
||||
string actionType,
|
||||
ImmutableDictionary<string, string> parameters)
|
||||
{
|
||||
var definition = GetAction(actionType);
|
||||
if (definition is null)
|
||||
{
|
||||
return ActionParameterValidationResult.Failure($"Unknown action type: {actionType}");
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Check required parameters
|
||||
foreach (var param in definition.Parameters.Where(p => p.Required))
|
||||
{
|
||||
if (!parameters.ContainsKey(param.Name) || string.IsNullOrWhiteSpace(parameters[param.Name]))
|
||||
{
|
||||
errors.Add($"Missing required parameter: {param.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parameter patterns
|
||||
foreach (var param in definition.Parameters.Where(p => p.ValidationPattern is not null))
|
||||
{
|
||||
if (parameters.TryGetValue(param.Name, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
if (!Regex.IsMatch(value, param.ValidationPattern!))
|
||||
{
|
||||
errors.Add($"Parameter '{param.Name}' does not match pattern: {param.ValidationPattern}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? ActionParameterValidationResult.Success
|
||||
: ActionParameterValidationResult.Failure(errors.ToArray());
|
||||
}
|
||||
|
||||
private static IEnumerable<ActionDefinition> CreateBuiltInActions()
|
||||
{
|
||||
// CVE/Finding Actions
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "approve",
|
||||
DisplayName = "Approve Risk",
|
||||
Description = "Accept the risk for a CVE finding with documented justification",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.High,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = true,
|
||||
CompensationActionType = "revoke_approval",
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "cve_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "CVE identifier",
|
||||
ValidationPattern = CveIdPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "justification",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Risk acceptance justification"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "expires_days",
|
||||
Type = "int",
|
||||
Required = false,
|
||||
Description = "Days until approval expires",
|
||||
DefaultValue = "90"
|
||||
}
|
||||
],
|
||||
Tags = ["cve", "risk", "vex"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "revoke_approval",
|
||||
DisplayName = "Revoke Risk Approval",
|
||||
Description = "Revoke a previously approved risk acceptance",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Medium,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "cve_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "CVE identifier",
|
||||
ValidationPattern = CveIdPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "reason",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Reason for revocation"
|
||||
}
|
||||
],
|
||||
Tags = ["cve", "risk", "vex"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "quarantine",
|
||||
DisplayName = "Quarantine Image",
|
||||
Description = "Block an image from deployment due to critical vulnerability",
|
||||
RequiredRole = "security-lead",
|
||||
RiskLevel = ActionRiskLevel.Critical,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = true,
|
||||
CompensationActionType = "release_quarantine",
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "image_digest",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Image digest to quarantine",
|
||||
ValidationPattern = ImageDigestPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "reason",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Reason for quarantine"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "cve_ids",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Comma-separated CVE IDs"
|
||||
}
|
||||
],
|
||||
Tags = ["container", "security", "deployment"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "release_quarantine",
|
||||
DisplayName = "Release from Quarantine",
|
||||
Description = "Release a previously quarantined image",
|
||||
RequiredRole = "security-lead",
|
||||
RiskLevel = ActionRiskLevel.High,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "image_digest",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Image digest to release",
|
||||
ValidationPattern = ImageDigestPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "justification",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Justification for release"
|
||||
}
|
||||
],
|
||||
Tags = ["container", "security", "deployment"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "defer",
|
||||
DisplayName = "Defer Finding",
|
||||
Description = "Defer remediation of a finding to a later date",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Low,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = true,
|
||||
CompensationActionType = "undefer",
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "finding_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Finding identifier"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "defer_days",
|
||||
Type = "int",
|
||||
Required = true,
|
||||
Description = "Days to defer"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "reason",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Reason for deferral"
|
||||
}
|
||||
],
|
||||
Tags = ["finding", "triage"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "undefer",
|
||||
DisplayName = "Undefer Finding",
|
||||
Description = "Remove deferral from a finding",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Low,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "finding_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Finding identifier"
|
||||
}
|
||||
],
|
||||
Tags = ["finding", "triage"]
|
||||
};
|
||||
|
||||
// VEX Actions
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "create_vex",
|
||||
DisplayName = "Create VEX Statement",
|
||||
Description = "Create a VEX statement for a CVE",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Medium,
|
||||
IsIdempotent = false,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "cve_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "CVE identifier",
|
||||
ValidationPattern = CveIdPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "status",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "VEX status (not_affected, affected, under_investigation, fixed)"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "justification",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Justification for not_affected status"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "impact_statement",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Impact statement for affected status"
|
||||
}
|
||||
],
|
||||
Tags = ["vex", "compliance"]
|
||||
};
|
||||
|
||||
// Report Actions
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "generate_manifest",
|
||||
DisplayName = "Generate Security Manifest",
|
||||
Description = "Generate a security manifest for an image",
|
||||
RequiredRole = "viewer",
|
||||
RiskLevel = ActionRiskLevel.Low,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "image_digest",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Image digest",
|
||||
ValidationPattern = ImageDigestPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "format",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Output format (json, pdf)",
|
||||
DefaultValue = "json"
|
||||
}
|
||||
],
|
||||
Tags = ["report", "compliance"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "export_sbom",
|
||||
DisplayName = "Export SBOM",
|
||||
Description = "Export SBOM in specified format",
|
||||
RequiredRole = "viewer",
|
||||
RiskLevel = ActionRiskLevel.Low,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "image_digest",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Image digest",
|
||||
ValidationPattern = ImageDigestPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "format",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "SBOM format (spdx-json, cyclonedx-json)",
|
||||
DefaultValue = "spdx-json"
|
||||
}
|
||||
],
|
||||
Tags = ["sbom", "export", "compliance"]
|
||||
};
|
||||
|
||||
// Notification Actions
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "notify_team",
|
||||
DisplayName = "Notify Team",
|
||||
Description = "Send notification to a team channel",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Medium,
|
||||
IsIdempotent = false,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "channel",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Notification channel"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "message",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Message content"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "priority",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Priority (low, medium, high, critical)",
|
||||
DefaultValue = "medium"
|
||||
}
|
||||
],
|
||||
Tags = ["notification", "communication"]
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^CVE-\d{4}-\d{4,}$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CveIdPattern();
|
||||
|
||||
[GeneratedRegex(@"^sha256:[a-fA-F0-9]{64}$")]
|
||||
private static partial Regex ImageDigestPattern();
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// <copyright file="ApprovalWorkflowAdapter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory approval workflow adapter for development and testing.
|
||||
/// In production, this would integrate with ReviewWorkflowService.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-004
|
||||
/// </summary>
|
||||
internal sealed class ApprovalWorkflowAdapter : IApprovalWorkflowAdapter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ApprovalRequestState> _requests = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidGenerator _guidGenerator;
|
||||
private readonly ILogger<ApprovalWorkflowAdapter> _logger;
|
||||
|
||||
public ApprovalWorkflowAdapter(
|
||||
TimeProvider timeProvider,
|
||||
IGuidGenerator guidGenerator,
|
||||
ILogger<ApprovalWorkflowAdapter> logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidGenerator = guidGenerator ?? throw new ArgumentNullException(nameof(guidGenerator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApprovalRequest> CreateApprovalRequestAsync(
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
ArgumentNullException.ThrowIfNull(decision);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var requestId = _guidGenerator.NewGuid().ToString();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var timeout = decision.ExpiresAt.HasValue
|
||||
? decision.ExpiresAt.Value - now
|
||||
: TimeSpan.FromHours(4);
|
||||
|
||||
var request = new ApprovalRequest
|
||||
{
|
||||
RequestId = requestId,
|
||||
WorkflowId = decision.ApprovalWorkflowId ?? "default",
|
||||
TenantId = context.TenantId,
|
||||
RequesterId = context.UserId,
|
||||
RequiredApprovers = decision.RequiredApprovers,
|
||||
Timeout = timeout,
|
||||
Payload = new ApprovalPayload
|
||||
{
|
||||
ActionType = proposal.ActionType,
|
||||
ActionLabel = proposal.Label,
|
||||
Parameters = proposal.Parameters,
|
||||
RunId = context.RunId,
|
||||
FindingId = context.FindingId,
|
||||
PolicyReason = decision.Reason
|
||||
},
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
var state = new ApprovalRequestState
|
||||
{
|
||||
Request = request,
|
||||
State = ApprovalState.Pending,
|
||||
Approvals = ImmutableArray<ApprovalEntry>.Empty
|
||||
};
|
||||
|
||||
_requests[requestId] = state;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created approval request {RequestId} for action {ActionType} by user {UserId}",
|
||||
requestId, proposal.ActionType, context.UserId);
|
||||
|
||||
return Task.FromResult(request);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApprovalStatus?> GetApprovalStatusAsync(
|
||||
string requestId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_requests.TryGetValue(requestId, out var state))
|
||||
{
|
||||
return Task.FromResult<ApprovalStatus?>(null);
|
||||
}
|
||||
|
||||
// Check for expiration
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (state.State == ApprovalState.Pending && now >= state.Request.ExpiresAt)
|
||||
{
|
||||
state.State = ApprovalState.Expired;
|
||||
state.UpdatedAt = now;
|
||||
}
|
||||
|
||||
var status = new ApprovalStatus
|
||||
{
|
||||
RequestId = requestId,
|
||||
State = state.State,
|
||||
Approvals = state.Approvals,
|
||||
CreatedAt = state.Request.CreatedAt,
|
||||
UpdatedAt = state.UpdatedAt,
|
||||
ExpiresAt = state.Request.ExpiresAt
|
||||
};
|
||||
|
||||
return Task.FromResult<ApprovalStatus?>(status);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApprovalResult> WaitForApprovalAsync(
|
||||
string requestId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
var status = await GetApprovalStatusAsync(requestId, cts.Token);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
DenialReason = "Approval request not found"
|
||||
};
|
||||
}
|
||||
|
||||
switch (status.State)
|
||||
{
|
||||
case ApprovalState.Approved:
|
||||
var approvalEntry = status.Approvals.LastOrDefault();
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = true,
|
||||
ApproverId = approvalEntry?.ApproverId,
|
||||
DecidedAt = approvalEntry?.DecidedAt,
|
||||
Comments = approvalEntry?.Comments
|
||||
};
|
||||
|
||||
case ApprovalState.Denied:
|
||||
var denialEntry = status.Approvals.LastOrDefault(a => !a.Approved);
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
ApproverId = denialEntry?.ApproverId,
|
||||
DecidedAt = denialEntry?.DecidedAt,
|
||||
Comments = denialEntry?.Comments,
|
||||
DenialReason = denialEntry?.Comments ?? "Request denied"
|
||||
};
|
||||
|
||||
case ApprovalState.Expired:
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
TimedOut = true,
|
||||
DenialReason = "Approval request expired"
|
||||
};
|
||||
|
||||
case ApprovalState.Cancelled:
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
Cancelled = true,
|
||||
DenialReason = "Approval request was cancelled"
|
||||
};
|
||||
|
||||
case ApprovalState.Pending:
|
||||
// Continue waiting
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Timeout or cancellation
|
||||
}
|
||||
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
TimedOut = true,
|
||||
DenialReason = "Timed out waiting for approval"
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CancelApprovalRequestAsync(
|
||||
string requestId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_requests.TryGetValue(requestId, out var state) && state.State == ApprovalState.Pending)
|
||||
{
|
||||
state.State = ApprovalState.Cancelled;
|
||||
state.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Cancelled approval request {RequestId}: {Reason}",
|
||||
requestId, reason);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an approval decision (used for testing and external approval callbacks).
|
||||
/// </summary>
|
||||
public void RecordApproval(string requestId, string approverId, bool approved, string? comments = null)
|
||||
{
|
||||
if (!_requests.TryGetValue(requestId, out var state))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval request not found: {requestId}");
|
||||
}
|
||||
|
||||
if (state.State != ApprovalState.Pending)
|
||||
{
|
||||
throw new InvalidOperationException($"Request {requestId} is not pending");
|
||||
}
|
||||
|
||||
var entry = new ApprovalEntry
|
||||
{
|
||||
ApproverId = approverId,
|
||||
Approved = approved,
|
||||
Comments = comments,
|
||||
DecidedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
state.Approvals = state.Approvals.Add(entry);
|
||||
state.UpdatedAt = entry.DecidedAt;
|
||||
|
||||
if (!approved)
|
||||
{
|
||||
state.State = ApprovalState.Denied;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if all required approvals are met
|
||||
var approvedRoles = state.Approvals
|
||||
.Where(a => a.Approved)
|
||||
.Select(a => a.ApproverId)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Simplified check: any required approver approving is sufficient
|
||||
// In production, would check against actual role membership
|
||||
state.State = ApprovalState.Approved;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded {Decision} for request {RequestId} by {ApproverId}",
|
||||
approved ? "approval" : "denial", requestId, approverId);
|
||||
}
|
||||
|
||||
private sealed class ApprovalRequestState
|
||||
{
|
||||
public required ApprovalRequest Request { get; init; }
|
||||
public ApprovalState State { get; set; }
|
||||
public ImmutableArray<ApprovalEntry> Approvals { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// <copyright file="IActionAuditLedger.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Audit ledger for recording all action attempts and outcomes.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-006
|
||||
/// </summary>
|
||||
public interface IActionAuditLedger
|
||||
{
|
||||
/// <summary>
|
||||
/// Records an audit entry for an action.
|
||||
/// </summary>
|
||||
/// <param name="entry">The audit entry to record.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RecordAsync(ActionAuditEntry entry, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Queries audit entries.
|
||||
/// </summary>
|
||||
/// <param name="query">The query criteria.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Matching audit entries.</returns>
|
||||
Task<ImmutableArray<ActionAuditEntry>> QueryAsync(
|
||||
ActionAuditQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific audit entry by ID.
|
||||
/// </summary>
|
||||
/// <param name="entryId">The entry ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The audit entry or null if not found.</returns>
|
||||
Task<ActionAuditEntry?> GetAsync(
|
||||
string entryId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets audit entries for a specific run.
|
||||
/// </summary>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Audit entries for the run.</returns>
|
||||
Task<ImmutableArray<ActionAuditEntry>> GetByRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An audit entry for an action attempt.
|
||||
/// </summary>
|
||||
public sealed record ActionAuditEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique entry identifier.
|
||||
/// </summary>
|
||||
public required string EntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the action was attempted.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of action attempted.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who attempted the action.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the action attempt.
|
||||
/// </summary>
|
||||
public required ActionAuditOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated AI run ID.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated finding ID.
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated CVE ID.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated image digest.
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy that evaluated the action.
|
||||
/// </summary>
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy decision result.
|
||||
/// </summary>
|
||||
public PolicyDecisionKind? PolicyResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Approval request ID if approval was required.
|
||||
/// </summary>
|
||||
public string? ApprovalRequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who approved the action.
|
||||
/// </summary>
|
||||
public string? ApproverId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Execution result ID if executed.
|
||||
/// </summary>
|
||||
public string? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result digest for verification.
|
||||
/// </summary>
|
||||
public string? ResultDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Attestation digest if attested.
|
||||
/// </summary>
|
||||
public string? AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of an action attempt.
|
||||
/// </summary>
|
||||
public enum ActionAuditOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// Action was successfully executed.
|
||||
/// </summary>
|
||||
Executed,
|
||||
|
||||
/// <summary>
|
||||
/// Action was denied by policy.
|
||||
/// </summary>
|
||||
DeniedByPolicy,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was requested.
|
||||
/// </summary>
|
||||
ApprovalRequested,
|
||||
|
||||
/// <summary>
|
||||
/// Action was approved and executed.
|
||||
/// </summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was denied.
|
||||
/// </summary>
|
||||
ApprovalDenied,
|
||||
|
||||
/// <summary>
|
||||
/// Approval request timed out.
|
||||
/// </summary>
|
||||
ApprovalTimedOut,
|
||||
|
||||
/// <summary>
|
||||
/// Execution failed.
|
||||
/// </summary>
|
||||
ExecutionFailed,
|
||||
|
||||
/// <summary>
|
||||
/// Action was skipped due to idempotency.
|
||||
/// </summary>
|
||||
IdempotentSkipped,
|
||||
|
||||
/// <summary>
|
||||
/// Action was rolled back.
|
||||
/// </summary>
|
||||
RolledBack,
|
||||
|
||||
/// <summary>
|
||||
/// Action validation failed.
|
||||
/// </summary>
|
||||
ValidationFailed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query criteria for audit entries.
|
||||
/// </summary>
|
||||
public sealed record ActionAuditQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by tenant.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by action type.
|
||||
/// </summary>
|
||||
public string? ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by actor (user).
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by outcome.
|
||||
/// </summary>
|
||||
public ActionAuditOutcome? Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by run ID.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by CVE ID.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by image digest.
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of time range (inclusive).
|
||||
/// </summary>
|
||||
public DateTimeOffset? FromTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of time range (exclusive).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ToTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of results.
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Offset for pagination.
|
||||
/// </summary>
|
||||
public int Offset { get; init; } = 0;
|
||||
}
|
||||
349
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/IActionExecutor.cs
Normal file
349
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/IActionExecutor.cs
Normal file
@@ -0,0 +1,349 @@
|
||||
// <copyright file="IActionExecutor.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Executes AI-proposed actions after policy gate approval.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-002
|
||||
/// </summary>
|
||||
public interface IActionExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes an action after policy gate approval.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The approved action proposal.</param>
|
||||
/// <param name="decision">The policy decision that approved the action.</param>
|
||||
/// <param name="context">The execution context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The execution result.</returns>
|
||||
Task<ActionExecutionResult> ExecuteAsync(
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Rolls back a previously executed action if supported.
|
||||
/// </summary>
|
||||
/// <param name="executionId">The execution ID to rollback.</param>
|
||||
/// <param name="context">The context for rollback.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The rollback result.</returns>
|
||||
Task<ActionRollbackResult> RollbackAsync(
|
||||
string executionId,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of an action execution.
|
||||
/// </summary>
|
||||
/// <param name="executionId">The execution ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The current execution status.</returns>
|
||||
Task<ActionExecutionStatus?> GetStatusAsync(
|
||||
string executionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists available action types supported by this executor.
|
||||
/// </summary>
|
||||
/// <returns>The available action types.</returns>
|
||||
ImmutableArray<ActionTypeInfo> GetSupportedActionTypes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of action execution.
|
||||
/// </summary>
|
||||
public sealed record ActionExecutionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this execution.
|
||||
/// </summary>
|
||||
public required string ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the execution.
|
||||
/// </summary>
|
||||
public required ActionExecutionOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message about the execution.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output data from the action.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> OutputData { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When execution started.
|
||||
/// </summary>
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When execution completed (null if still running).
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the execution.
|
||||
/// </summary>
|
||||
public TimeSpan? Duration => CompletedAt.HasValue
|
||||
? CompletedAt.Value - StartedAt
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action can be rolled back.
|
||||
/// </summary>
|
||||
public bool CanRollback { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error details if execution failed.
|
||||
/// </summary>
|
||||
public ActionError? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related artifact IDs created or modified by this action.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AffectedArtifacts { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of action execution.
|
||||
/// </summary>
|
||||
public enum ActionExecutionOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// Action executed successfully.
|
||||
/// </summary>
|
||||
Success,
|
||||
|
||||
/// <summary>
|
||||
/// Action partially completed.
|
||||
/// </summary>
|
||||
PartialSuccess,
|
||||
|
||||
/// <summary>
|
||||
/// Action failed.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// Action was cancelled.
|
||||
/// </summary>
|
||||
Cancelled,
|
||||
|
||||
/// <summary>
|
||||
/// Action execution timed out.
|
||||
/// </summary>
|
||||
Timeout,
|
||||
|
||||
/// <summary>
|
||||
/// Action was skipped due to idempotency (already executed).
|
||||
/// </summary>
|
||||
Skipped,
|
||||
|
||||
/// <summary>
|
||||
/// Action is pending approval.
|
||||
/// </summary>
|
||||
PendingApproval,
|
||||
|
||||
/// <summary>
|
||||
/// Action is currently executing.
|
||||
/// </summary>
|
||||
InProgress
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current status of an action execution.
|
||||
/// </summary>
|
||||
public sealed record ActionExecutionStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The execution ID.
|
||||
/// </summary>
|
||||
public required string ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current outcome/state.
|
||||
/// </summary>
|
||||
public required ActionExecutionOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Progress percentage if known (0-100).
|
||||
/// </summary>
|
||||
public int? ProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status message.
|
||||
/// </summary>
|
||||
public string? StatusMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When status was last updated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Estimated completion time if known.
|
||||
/// </summary>
|
||||
public DateTimeOffset? EstimatedCompletionAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes an error that occurred during action execution.
|
||||
/// </summary>
|
||||
public sealed record ActionError
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code for programmatic handling.
|
||||
/// </summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed error information.
|
||||
/// </summary>
|
||||
public string? Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the error is retryable.
|
||||
/// </summary>
|
||||
public bool IsRetryable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested wait time before retry.
|
||||
/// </summary>
|
||||
public TimeSpan? RetryAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner error if this is a wrapper.
|
||||
/// </summary>
|
||||
public ActionError? InnerError { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of rolling back an action.
|
||||
/// </summary>
|
||||
public sealed record ActionRollbackResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the rollback was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Message about the rollback.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error if rollback failed.
|
||||
/// </summary>
|
||||
public ActionError? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When rollback completed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available action type.
|
||||
/// </summary>
|
||||
public sealed record ActionTypeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// The action type identifier.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name.
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of what this action does.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Category for grouping actions.
|
||||
/// </summary>
|
||||
public string? Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required parameters for this action type.
|
||||
/// </summary>
|
||||
public ImmutableArray<ActionParameterInfo> Parameters { get; init; } =
|
||||
ImmutableArray<ActionParameterInfo>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Required permission to execute this action.
|
||||
/// </summary>
|
||||
public string? RequiredPermission { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action supports rollback.
|
||||
/// </summary>
|
||||
public bool SupportsRollback { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action is destructive (requires extra confirmation).
|
||||
/// </summary>
|
||||
public bool IsDestructive { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an action parameter.
|
||||
/// </summary>
|
||||
public sealed record ActionParameterInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Parameter name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the parameter.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this parameter is required.
|
||||
/// </summary>
|
||||
public bool IsRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameter type (string, integer, boolean, etc.).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default value if not specified.
|
||||
/// </summary>
|
||||
public string? DefaultValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Valid values for enum-like parameters.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AllowedValues { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
358
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/IActionPolicyGate.cs
Normal file
358
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/IActionPolicyGate.cs
Normal file
@@ -0,0 +1,358 @@
|
||||
// <copyright file="IActionPolicyGate.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether AI-proposed actions are allowed by policy.
|
||||
/// Integrates with K4 lattice for VEX-aware decisions and approval workflows.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-001
|
||||
/// </summary>
|
||||
public interface IActionPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates whether an action is allowed by policy.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The action proposal from the AI.</param>
|
||||
/// <param name="context">The execution context including tenant, user, roles, environment.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The policy decision with any required approvals.</returns>
|
||||
Task<ActionPolicyDecision> EvaluateAsync(
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable explanation for a policy decision.
|
||||
/// </summary>
|
||||
/// <param name="decision">The decision to explain.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Human-readable explanation with policy references.</returns>
|
||||
Task<PolicyExplanation> ExplainAsync(
|
||||
ActionPolicyDecision decision,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an action has already been executed (idempotency check).
|
||||
/// </summary>
|
||||
/// <param name="proposal">The action proposal.</param>
|
||||
/// <param name="context">The execution context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the action was already executed with the same parameters.</returns>
|
||||
Task<IdempotencyCheckResult> CheckIdempotencyAsync(
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for action policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record ActionContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant identifier for multi-tenancy.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User identifier who initiated the action.
|
||||
/// </summary>
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User's roles/permissions.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> UserRoles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target environment (production, staging, development, etc.).
|
||||
/// </summary>
|
||||
public required string Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated AI run ID, if any.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated finding ID for remediation actions.
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE ID if this is a vulnerability-related action.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image digest if this is a container-related action.
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for policy evaluation.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An action proposed by the AI system.
|
||||
/// </summary>
|
||||
public sealed record ActionProposal
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this proposal.
|
||||
/// </summary>
|
||||
public required string ProposalId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of action (e.g., "approve", "quarantine", "create_vex").
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable label for the action.
|
||||
/// </summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action parameters.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, string> Parameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the proposal was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the proposal expires (null = never).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Idempotency key for deduplication.
|
||||
/// </summary>
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record ActionPolicyDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// The decision outcome.
|
||||
/// </summary>
|
||||
public required PolicyDecisionKind Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy that made this decision.
|
||||
/// </summary>
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Brief reason for the decision.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required approvers if decision is AllowWithApproval.
|
||||
/// </summary>
|
||||
public ImmutableArray<RequiredApprover> RequiredApprovers { get; init; } =
|
||||
ImmutableArray<RequiredApprover>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Approval workflow ID if approval is required.
|
||||
/// </summary>
|
||||
public string? ApprovalWorkflowId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// K4 lattice position used in the decision.
|
||||
/// </summary>
|
||||
public string? K4Position { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status that influenced the decision, if any.
|
||||
/// </summary>
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity level assigned by policy.
|
||||
/// </summary>
|
||||
public int? SeverityLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this decision expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional decision metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kinds of policy decisions.
|
||||
/// </summary>
|
||||
public enum PolicyDecisionKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Action is allowed and can execute immediately.
|
||||
/// </summary>
|
||||
Allow,
|
||||
|
||||
/// <summary>
|
||||
/// Action is allowed but requires approval workflow.
|
||||
/// </summary>
|
||||
AllowWithApproval,
|
||||
|
||||
/// <summary>
|
||||
/// Action is denied by policy.
|
||||
/// </summary>
|
||||
Deny,
|
||||
|
||||
/// <summary>
|
||||
/// Action is denied but admin can override.
|
||||
/// </summary>
|
||||
DenyWithOverride,
|
||||
|
||||
/// <summary>
|
||||
/// Decision could not be made (missing context).
|
||||
/// </summary>
|
||||
Indeterminate
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a required approver for AllowWithApproval decisions.
|
||||
/// </summary>
|
||||
public sealed record RequiredApprover
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of approver requirement.
|
||||
/// </summary>
|
||||
public required ApproverType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifier (user ID, role name, or group name).
|
||||
/// </summary>
|
||||
public required string Identifier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of approval requirements.
|
||||
/// </summary>
|
||||
public enum ApproverType
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific user must approve.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// Any user with this role can approve.
|
||||
/// </summary>
|
||||
Role,
|
||||
|
||||
/// <summary>
|
||||
/// Any member of this group can approve.
|
||||
/// </summary>
|
||||
Group
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable explanation of a policy decision.
|
||||
/// </summary>
|
||||
public sealed record PolicyExplanation
|
||||
{
|
||||
/// <summary>
|
||||
/// Natural language summary of the decision.
|
||||
/// </summary>
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed explanation points.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// References to policies that were evaluated.
|
||||
/// </summary>
|
||||
public ImmutableArray<PolicyReference> PolicyReferences { get; init; } =
|
||||
ImmutableArray<PolicyReference>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Suggested next steps for the user.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> SuggestedActions { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a specific policy.
|
||||
/// </summary>
|
||||
public sealed record PolicyReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule within the policy that matched.
|
||||
/// </summary>
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to policy documentation.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of idempotency check.
|
||||
/// </summary>
|
||||
public sealed record IdempotencyCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the action was previously executed.
|
||||
/// </summary>
|
||||
public required bool WasExecuted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous execution ID if executed.
|
||||
/// </summary>
|
||||
public string? PreviousExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the action was previously executed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExecutedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result of the previous execution.
|
||||
/// </summary>
|
||||
public string? PreviousResult { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// <copyright file="IActionRegistry.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of available action types and their definitions.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-003
|
||||
/// </summary>
|
||||
public interface IActionRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the definition for an action type.
|
||||
/// </summary>
|
||||
/// <param name="actionType">The action type identifier.</param>
|
||||
/// <returns>The definition or null if not found.</returns>
|
||||
ActionDefinition? GetAction(string actionType);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered action definitions.
|
||||
/// </summary>
|
||||
/// <returns>All action definitions.</returns>
|
||||
ImmutableArray<ActionDefinition> GetAllActions();
|
||||
|
||||
/// <summary>
|
||||
/// Gets actions by risk level.
|
||||
/// </summary>
|
||||
/// <param name="riskLevel">The risk level to filter by.</param>
|
||||
/// <returns>Actions matching the risk level.</returns>
|
||||
ImmutableArray<ActionDefinition> GetActionsByRiskLevel(ActionRiskLevel riskLevel);
|
||||
|
||||
/// <summary>
|
||||
/// Gets actions by tag.
|
||||
/// </summary>
|
||||
/// <param name="tag">The tag to filter by.</param>
|
||||
/// <returns>Actions with the specified tag.</returns>
|
||||
ImmutableArray<ActionDefinition> GetActionsByTag(string tag);
|
||||
|
||||
/// <summary>
|
||||
/// Validates action parameters against the definition.
|
||||
/// </summary>
|
||||
/// <param name="actionType">The action type.</param>
|
||||
/// <param name="parameters">The parameters to validate.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
ActionParameterValidationResult ValidateParameters(
|
||||
string actionType,
|
||||
ImmutableDictionary<string, string> parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parameter validation.
|
||||
/// </summary>
|
||||
public sealed record ActionParameterValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether validation passed.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors if any.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Errors { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful validation result.
|
||||
/// </summary>
|
||||
public static ActionParameterValidationResult Success => new() { IsValid = true };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed validation result.
|
||||
/// </summary>
|
||||
public static ActionParameterValidationResult Failure(params string[] errors) =>
|
||||
new() { IsValid = false, Errors = errors.ToImmutableArray() };
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// <copyright file="IApprovalWorkflowAdapter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter for integrating with approval workflow systems.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-004
|
||||
/// </summary>
|
||||
public interface IApprovalWorkflowAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an approval request for an action.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The action proposal.</param>
|
||||
/// <param name="decision">The policy decision requiring approval.</param>
|
||||
/// <param name="context">The action context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created approval request.</returns>
|
||||
Task<ApprovalRequest> CreateApprovalRequestAsync(
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of an approval request.
|
||||
/// </summary>
|
||||
/// <param name="requestId">The request ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The approval status or null if not found.</returns>
|
||||
Task<ApprovalStatus?> GetApprovalStatusAsync(
|
||||
string requestId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Waits for an approval decision with timeout.
|
||||
/// </summary>
|
||||
/// <param name="requestId">The request ID.</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The approval result.</returns>
|
||||
Task<ApprovalResult> WaitForApprovalAsync(
|
||||
string requestId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a pending approval request.
|
||||
/// </summary>
|
||||
/// <param name="requestId">The request ID.</param>
|
||||
/// <param name="reason">Cancellation reason.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task CancelApprovalRequestAsync(
|
||||
string requestId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An approval request for an action.
|
||||
/// </summary>
|
||||
public sealed record ApprovalRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique request identifier.
|
||||
/// </summary>
|
||||
public required string RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated workflow ID.
|
||||
/// </summary>
|
||||
public required string WorkflowId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who requested the action.
|
||||
/// </summary>
|
||||
public required string RequesterId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required approvers.
|
||||
/// </summary>
|
||||
public required ImmutableArray<RequiredApprover> RequiredApprovers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout.
|
||||
/// </summary>
|
||||
public required TimeSpan Timeout { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payload containing action details.
|
||||
/// </summary>
|
||||
public required ApprovalPayload Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the request was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the request expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt => CreatedAt.Add(Timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for an approval request.
|
||||
/// </summary>
|
||||
public sealed record ApprovalPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Action type being requested.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable action label.
|
||||
/// </summary>
|
||||
public required string ActionLabel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action parameters.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, string> Parameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated run ID.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated finding ID.
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy reason for requiring approval.
|
||||
/// </summary>
|
||||
public string? PolicyReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current status of an approval request.
|
||||
/// </summary>
|
||||
public sealed record ApprovalStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Request ID.
|
||||
/// </summary>
|
||||
public required string RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current state.
|
||||
/// </summary>
|
||||
public required ApprovalState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Approvals received so far.
|
||||
/// </summary>
|
||||
public ImmutableArray<ApprovalEntry> Approvals { get; init; } =
|
||||
ImmutableArray<ApprovalEntry>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When the request was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the state was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the request expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State of an approval request.
|
||||
/// </summary>
|
||||
public enum ApprovalState
|
||||
{
|
||||
/// <summary>
|
||||
/// Waiting for approvals.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// All required approvals received.
|
||||
/// </summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>
|
||||
/// Request was denied.
|
||||
/// </summary>
|
||||
Denied,
|
||||
|
||||
/// <summary>
|
||||
/// Request timed out.
|
||||
/// </summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>
|
||||
/// Request was cancelled.
|
||||
/// </summary>
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An individual approval entry.
|
||||
/// </summary>
|
||||
public sealed record ApprovalEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// User who approved/denied.
|
||||
/// </summary>
|
||||
public required string ApproverId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether they approved.
|
||||
/// </summary>
|
||||
public required bool Approved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Comments from the approver.
|
||||
/// </summary>
|
||||
public string? Comments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was made.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of waiting for approval.
|
||||
/// </summary>
|
||||
public sealed record ApprovalResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the action was approved.
|
||||
/// </summary>
|
||||
public required bool Approved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the request timed out.
|
||||
/// </summary>
|
||||
public bool TimedOut { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the request was cancelled.
|
||||
/// </summary>
|
||||
public bool Cancelled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who made the final decision.
|
||||
/// </summary>
|
||||
public string? ApproverId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was made.
|
||||
/// </summary>
|
||||
public DateTimeOffset? DecidedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Comments from the approver.
|
||||
/// </summary>
|
||||
public string? Comments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for denial/cancellation.
|
||||
/// </summary>
|
||||
public string? DenialReason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// <copyright file="IGuidGenerator.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for GUID generation to enable deterministic testing.
|
||||
/// </summary>
|
||||
public interface IGuidGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a new GUID.
|
||||
/// </summary>
|
||||
/// <returns>A new GUID.</returns>
|
||||
Guid NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation using Guid.NewGuid().
|
||||
/// </summary>
|
||||
internal sealed class DefaultGuidGenerator : IGuidGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static readonly IGuidGenerator Instance = new DefaultGuidGenerator();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid NewGuid() => Guid.NewGuid();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// <copyright file="IIdempotencyHandler.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Handles idempotency checking for action execution.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-005
|
||||
/// </summary>
|
||||
public interface IIdempotencyHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a deterministic idempotency key for an action.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The action proposal.</param>
|
||||
/// <param name="context">The action context.</param>
|
||||
/// <returns>The idempotency key.</returns>
|
||||
string GenerateKey(ActionProposal proposal, ActionContext context);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an action was already executed.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Check result with previous execution if found.</returns>
|
||||
Task<IdempotencyResult> CheckAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Records an action execution for idempotency.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="result">The execution result.</param>
|
||||
/// <param name="context">The action context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RecordExecutionAsync(
|
||||
string key,
|
||||
ActionExecutionResult result,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an idempotency record (for rollback scenarios).
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RemoveAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of idempotency check.
|
||||
/// </summary>
|
||||
public sealed record IdempotencyResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the action was already executed.
|
||||
/// </summary>
|
||||
public required bool AlreadyExecuted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous execution result if executed.
|
||||
/// </summary>
|
||||
public ActionExecutionResult? PreviousResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the action was previously executed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExecutedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who executed the action.
|
||||
/// </summary>
|
||||
public string? ExecutedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating no previous execution.
|
||||
/// </summary>
|
||||
public static IdempotencyResult NotExecuted => new() { AlreadyExecuted = false };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// <copyright file="IdempotencyHandler.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory idempotency handler for development and testing.
|
||||
/// In production, this would use PostgreSQL with TTL cleanup.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-005
|
||||
/// </summary>
|
||||
internal sealed class IdempotencyHandler : IIdempotencyHandler
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IdempotencyRecord> _records = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IdempotencyOptions _options;
|
||||
private readonly ILogger<IdempotencyHandler> _logger;
|
||||
|
||||
public IdempotencyHandler(
|
||||
TimeProvider timeProvider,
|
||||
IOptions<IdempotencyOptions> options,
|
||||
ILogger<IdempotencyHandler> logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options?.Value ?? new IdempotencyOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateKey(ActionProposal proposal, ActionContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// Key components: tenant, action type, target identifiers
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(context.TenantId);
|
||||
sb.Append('|');
|
||||
sb.Append(proposal.ActionType);
|
||||
|
||||
// Add target-specific components in sorted order for determinism
|
||||
var targets = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (proposal.Parameters.TryGetValue("cve_id", out var cveId) && !string.IsNullOrEmpty(cveId))
|
||||
{
|
||||
targets["cve"] = cveId;
|
||||
}
|
||||
|
||||
if (proposal.Parameters.TryGetValue("image_digest", out var digest) && !string.IsNullOrEmpty(digest))
|
||||
{
|
||||
targets["image"] = digest;
|
||||
}
|
||||
|
||||
if (proposal.Parameters.TryGetValue("finding_id", out var findingId) && !string.IsNullOrEmpty(findingId))
|
||||
{
|
||||
targets["finding"] = findingId;
|
||||
}
|
||||
|
||||
if (proposal.Parameters.TryGetValue("component", out var component) && !string.IsNullOrEmpty(component))
|
||||
{
|
||||
targets["component"] = component;
|
||||
}
|
||||
|
||||
// If using explicit idempotency key from proposal, include it
|
||||
if (!string.IsNullOrEmpty(proposal.IdempotencyKey))
|
||||
{
|
||||
targets["key"] = proposal.IdempotencyKey;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in targets)
|
||||
{
|
||||
sb.Append('|');
|
||||
sb.Append(key);
|
||||
sb.Append(':');
|
||||
sb.Append(value);
|
||||
}
|
||||
|
||||
var content = sb.ToString();
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IdempotencyResult> CheckAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
|
||||
if (!_records.TryGetValue(key, out var record))
|
||||
{
|
||||
return Task.FromResult(IdempotencyResult.NotExecuted);
|
||||
}
|
||||
|
||||
// Check if record has expired
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (now >= record.ExpiresAt)
|
||||
{
|
||||
_records.TryRemove(key, out _);
|
||||
return Task.FromResult(IdempotencyResult.NotExecuted);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Idempotency hit for key {Key}, previously executed at {ExecutedAt} by {ExecutedBy}",
|
||||
key, record.ExecutedAt, record.ExecutedBy);
|
||||
|
||||
return Task.FromResult(new IdempotencyResult
|
||||
{
|
||||
AlreadyExecuted = true,
|
||||
PreviousResult = record.Result,
|
||||
ExecutedAt = record.ExecutedAt,
|
||||
ExecutedBy = record.ExecutedBy
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordExecutionAsync(
|
||||
string key,
|
||||
ActionExecutionResult result,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var record = new IdempotencyRecord
|
||||
{
|
||||
Key = key,
|
||||
Result = result,
|
||||
ExecutedAt = now,
|
||||
ExecutedBy = context.UserId,
|
||||
TenantId = context.TenantId,
|
||||
ExpiresAt = now.AddDays(_options.TtlDays)
|
||||
};
|
||||
|
||||
_records[key] = record;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Recorded idempotency for key {Key}, expires at {ExpiresAt}",
|
||||
key, record.ExpiresAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_records.TryRemove(key, out _);
|
||||
|
||||
_logger.LogDebug("Removed idempotency record for key {Key}", key);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up expired records. Should be called periodically.
|
||||
/// </summary>
|
||||
public void CleanupExpired()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiredKeys = _records
|
||||
.Where(kvp => now >= kvp.Value.ExpiresAt)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_records.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
if (expiredKeys.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Cleaned up {Count} expired idempotency records", expiredKeys.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record IdempotencyRecord
|
||||
{
|
||||
public required string Key { get; init; }
|
||||
public required ActionExecutionResult Result { get; init; }
|
||||
public required DateTimeOffset ExecutedAt { get; init; }
|
||||
public required string ExecutedBy { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for idempotency handling.
|
||||
/// </summary>
|
||||
public sealed class IdempotencyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Days to retain idempotency records before expiration.
|
||||
/// </summary>
|
||||
public int TtlDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether idempotency checking is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// <copyright file="AttestationIntegration.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Attestation;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates AI attestation with the conversation service.
|
||||
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-005
|
||||
/// </summary>
|
||||
public sealed class AttestationIntegration : IAttestationIntegration
|
||||
{
|
||||
private readonly IAiAttestationService _attestationService;
|
||||
private readonly IPromptTemplateRegistry _templateRegistry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AttestationIntegration> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AttestationIntegration"/> class.
|
||||
/// </summary>
|
||||
public AttestationIntegration(
|
||||
IAiAttestationService attestationService,
|
||||
IPromptTemplateRegistry templateRegistry,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AttestationIntegration> logger)
|
||||
{
|
||||
_attestationService = attestationService ?? throw new ArgumentNullException(nameof(attestationService));
|
||||
_templateRegistry = templateRegistry ?? throw new ArgumentNullException(nameof(templateRegistry));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AiAttestationResult?> AttestTurnAsync(
|
||||
string runId,
|
||||
string tenantId,
|
||||
ConversationTurn turn,
|
||||
GroundingResult? groundingResult,
|
||||
bool sign,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (turn.Role != TurnRole.Assistant)
|
||||
{
|
||||
// Only attest assistant (AI) turns
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Attesting turn {TurnId} for run {RunId}", turn.TurnId, runId);
|
||||
|
||||
try
|
||||
{
|
||||
// Extract claims from grounding result and convert to ClaimEvidence format
|
||||
var claims = groundingResult?.GroundedClaims
|
||||
.Select(c => new ClaimEvidence
|
||||
{
|
||||
Text = c.ClaimText,
|
||||
Position = c.Position,
|
||||
Length = c.Length,
|
||||
GroundingScore = c.Confidence,
|
||||
GroundedBy = c.EvidenceLinks
|
||||
.Select(e => e.ToString())
|
||||
.ToImmutableArray(),
|
||||
Verified = c.Confidence >= 0.7
|
||||
})
|
||||
.ToImmutableArray() ?? ImmutableArray<ClaimEvidence>.Empty;
|
||||
|
||||
// Create attestation for the first claim (representative of the turn)
|
||||
// In practice, you might create multiple claim attestations per turn
|
||||
var contentDigest = ComputeContentDigest(turn.Content);
|
||||
var claimText = turn.Content.Length > 500
|
||||
? turn.Content[..500] + "..."
|
||||
: turn.Content;
|
||||
|
||||
var claimAttestation = new AiClaimAttestation
|
||||
{
|
||||
ClaimId = $"{runId}:{turn.TurnId}:turn-claim",
|
||||
RunId = runId,
|
||||
TurnId = turn.TurnId,
|
||||
TenantId = tenantId,
|
||||
ClaimText = claimText,
|
||||
ClaimDigest = ComputeContentDigest(claimText),
|
||||
GroundedBy = claims.SelectMany(c => c.GroundedBy).Distinct().ToImmutableArray(),
|
||||
GroundingScore = groundingResult?.OverallScore ?? 0.0,
|
||||
Verified = groundingResult?.OverallScore >= 0.7,
|
||||
Timestamp = turn.Timestamp,
|
||||
ContentDigest = contentDigest
|
||||
};
|
||||
|
||||
var result = await _attestationService.CreateClaimAttestationAsync(
|
||||
claimAttestation,
|
||||
sign,
|
||||
ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created claim attestation {AttestationId} for turn {TurnId}",
|
||||
result.AttestationId, turn.TurnId);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to create claim attestation for turn {TurnId}: {Message}",
|
||||
turn.TurnId, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AiAttestationResult?> AttestRunAsync(
|
||||
Conversation conversation,
|
||||
string runId,
|
||||
string promptTemplateName,
|
||||
AiModelInfo modelInfo,
|
||||
bool sign,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Attesting run {RunId} for conversation {ConversationId}",
|
||||
runId, conversation.ConversationId);
|
||||
|
||||
try
|
||||
{
|
||||
// Get prompt template info
|
||||
var templateInfo = _templateRegistry.GetTemplateInfo(promptTemplateName);
|
||||
|
||||
// Collect all evidence URIs from turns
|
||||
var allEvidenceUris = conversation.Turns
|
||||
.SelectMany(t => t.EvidenceLinks)
|
||||
.Select(e => e.Uri)
|
||||
.Distinct()
|
||||
.ToImmutableArray();
|
||||
|
||||
// Build turn summaries with claims
|
||||
var turns = conversation.Turns
|
||||
.Select(t => new AiTurnSummary
|
||||
{
|
||||
TurnId = t.TurnId,
|
||||
Role = MapRole(t.Role),
|
||||
ContentDigest = ComputeContentDigest(t.Content),
|
||||
Timestamp = t.Timestamp,
|
||||
Claims = t.Role == TurnRole.Assistant
|
||||
? ImmutableArray.Create(new ClaimEvidence
|
||||
{
|
||||
Text = t.Content.Length > 200 ? t.Content[..200] + "..." : t.Content,
|
||||
Position = 0,
|
||||
Length = Math.Min(t.Content.Length, 200),
|
||||
GroundedBy = t.EvidenceLinks.Select(e => e.Uri).ToImmutableArray(),
|
||||
GroundingScore = 0.8,
|
||||
Verified = t.EvidenceLinks.Length > 0
|
||||
})
|
||||
: ImmutableArray<ClaimEvidence>.Empty
|
||||
})
|
||||
.ToImmutableArray();
|
||||
|
||||
// Build run context
|
||||
var context = new AiRunContext
|
||||
{
|
||||
FindingId = conversation.Context.ScanId,
|
||||
CveId = conversation.Context.CurrentCveId,
|
||||
Component = conversation.Context.CurrentComponent,
|
||||
ImageDigest = conversation.Context.CurrentImageDigest,
|
||||
PolicyId = conversation.Context.Policy?.PolicyIds.FirstOrDefault(),
|
||||
EvidenceUris = allEvidenceUris
|
||||
};
|
||||
|
||||
var runAttestation = new AiRunAttestation
|
||||
{
|
||||
RunId = runId,
|
||||
TenantId = conversation.TenantId,
|
||||
UserId = conversation.UserId ?? "unknown",
|
||||
ConversationId = conversation.ConversationId,
|
||||
StartedAt = conversation.CreatedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
Model = modelInfo,
|
||||
PromptTemplate = templateInfo,
|
||||
Context = context,
|
||||
Turns = turns,
|
||||
OverallGroundingScore = ComputeOverallGroundingScore(turns)
|
||||
};
|
||||
|
||||
var result = await _attestationService.CreateRunAttestationAsync(
|
||||
runAttestation,
|
||||
sign,
|
||||
ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created run attestation {AttestationId} for run {RunId} with {TurnCount} turns",
|
||||
result.AttestationId, runId, turns.Length);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to create run attestation for {RunId}: {Message}",
|
||||
runId, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AiAttestationVerificationResult> VerifyRunAsync(
|
||||
string runId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogDebug("Verifying run attestation for {RunId}", runId);
|
||||
|
||||
var result = await _attestationService.VerifyRunAttestationAsync(runId, ct);
|
||||
|
||||
if (result.Valid)
|
||||
{
|
||||
_logger.LogInformation("Run {RunId} attestation verified successfully", runId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Run {RunId} attestation verification failed: {Reason}",
|
||||
runId, result.FailureReason);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ComputeContentDigest(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static double ComputeOverallGroundingScore(ImmutableArray<AiTurnSummary> turns)
|
||||
{
|
||||
var assistantTurns = turns.Where(t => t.Role == Attestation.Models.TurnRole.Assistant).ToList();
|
||||
if (assistantTurns.Count == 0) return 0.0;
|
||||
|
||||
var avgScore = assistantTurns
|
||||
.Where(t => t.GroundingScore.HasValue)
|
||||
.DefaultIfEmpty()
|
||||
.Average(t => t?.GroundingScore ?? 0.0);
|
||||
|
||||
return avgScore;
|
||||
}
|
||||
|
||||
private static Attestation.Models.TurnRole MapRole(TurnRole role) => role switch
|
||||
{
|
||||
TurnRole.User => Attestation.Models.TurnRole.User,
|
||||
TurnRole.Assistant => Attestation.Models.TurnRole.Assistant,
|
||||
TurnRole.System => Attestation.Models.TurnRole.System,
|
||||
_ => Attestation.Models.TurnRole.System
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for attestation integration.
|
||||
/// </summary>
|
||||
public interface IAttestationIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an attestation for a conversation turn.
|
||||
/// </summary>
|
||||
/// <param name="runId">The run identifier.</param>
|
||||
/// <param name="tenantId">The tenant identifier.</param>
|
||||
/// <param name="turn">The conversation turn.</param>
|
||||
/// <param name="groundingResult">The grounding validation result.</param>
|
||||
/// <param name="sign">Whether to sign the attestation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The attestation result, or null if attestation is skipped.</returns>
|
||||
Task<AiAttestationResult?> AttestTurnAsync(
|
||||
string runId,
|
||||
string tenantId,
|
||||
ConversationTurn turn,
|
||||
GroundingResult? groundingResult,
|
||||
bool sign,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an attestation for a completed run.
|
||||
/// </summary>
|
||||
/// <param name="conversation">The conversation.</param>
|
||||
/// <param name="runId">The run identifier.</param>
|
||||
/// <param name="promptTemplateName">The prompt template name used.</param>
|
||||
/// <param name="modelInfo">The AI model information.</param>
|
||||
/// <param name="sign">Whether to sign the attestation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The attestation result, or null if attestation fails.</returns>
|
||||
Task<AiAttestationResult?> AttestRunAsync(
|
||||
Conversation conversation,
|
||||
string runId,
|
||||
string promptTemplateName,
|
||||
AiModelInfo modelInfo,
|
||||
bool sign,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a run attestation.
|
||||
/// </summary>
|
||||
/// <param name="runId">The run identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
Task<AiAttestationVerificationResult> VerifyRunAsync(
|
||||
string runId,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of grounding validation for a turn.
|
||||
/// </summary>
|
||||
public sealed record GroundingResult
|
||||
{
|
||||
/// <summary>Overall grounding score (0.0-1.0).</summary>
|
||||
public required double OverallScore { get; init; }
|
||||
|
||||
/// <summary>Individual grounded claims.</summary>
|
||||
public ImmutableArray<GroundedClaim> GroundedClaims { get; init; } = ImmutableArray<GroundedClaim>.Empty;
|
||||
|
||||
/// <summary>Ungrounded claims (claims without evidence).</summary>
|
||||
public ImmutableArray<string> UngroundedClaims { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A claim that has been grounded to evidence.
|
||||
/// </summary>
|
||||
public sealed record GroundedClaim
|
||||
{
|
||||
/// <summary>The claim text.</summary>
|
||||
public required string ClaimText { get; init; }
|
||||
|
||||
/// <summary>Position in the content.</summary>
|
||||
public required int Position { get; init; }
|
||||
|
||||
/// <summary>Length of the claim.</summary>
|
||||
public required int Length { get; init; }
|
||||
|
||||
/// <summary>Confidence score (0.0-1.0).</summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>Evidence links supporting this claim.</summary>
|
||||
public ImmutableArray<Uri> EvidenceLinks { get; init; } = ImmutableArray<Uri>.Empty;
|
||||
}
|
||||
@@ -345,16 +345,31 @@ public sealed record ConversationContext
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation topic.
|
||||
/// </summary>
|
||||
public string? Topic { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current CVE being discussed.
|
||||
/// </summary>
|
||||
public string? CurrentCveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the focused CVE ID (alias for CurrentCveId).
|
||||
/// </summary>
|
||||
public string? FocusedCveId => CurrentCveId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current component PURL.
|
||||
/// </summary>
|
||||
public string? CurrentComponent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the focused component (alias for CurrentComponent).
|
||||
/// </summary>
|
||||
public string? FocusedComponent => CurrentComponent;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current image digest.
|
||||
/// </summary>
|
||||
@@ -370,6 +385,49 @@ public sealed record ConversationContext
|
||||
/// </summary>
|
||||
public string? SbomId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the finding ID in context.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run ID in context.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user ID.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
|
||||
/// </summary>
|
||||
public string? UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the vulnerability severity.
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the vulnerability is reachable.
|
||||
/// </summary>
|
||||
public bool? IsReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVSS score.
|
||||
/// </summary>
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the EPSS score.
|
||||
/// </summary>
|
||||
public double? EpssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets context tags.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets accumulated evidence links.
|
||||
/// </summary>
|
||||
@@ -413,11 +471,21 @@ public sealed record ConversationTurn
|
||||
/// </summary>
|
||||
public required string TurnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the turn number in the conversation (1-based).
|
||||
/// </summary>
|
||||
public int TurnNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the role (user/assistant/system).
|
||||
/// </summary>
|
||||
public required TurnRole Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor identifier (user ID or system ID).
|
||||
/// </summary>
|
||||
public string? ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message content.
|
||||
/// </summary>
|
||||
@@ -536,11 +604,27 @@ public sealed record ProposedAction
|
||||
/// </summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action subject (CVE, component, etc.).
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action rationale.
|
||||
/// </summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action payload (JSON).
|
||||
/// </summary>
|
||||
public string? Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this action requires confirmation.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// <copyright file="EvidencePackChatIntegration.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Evidence.Pack;
|
||||
using StellaOps.Evidence.Pack.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates Evidence Pack creation with chat grounding validation.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
|
||||
/// </summary>
|
||||
public sealed class EvidencePackChatIntegration
|
||||
{
|
||||
private readonly IEvidencePackService _evidencePackService;
|
||||
private readonly ILogger<EvidencePackChatIntegration> _logger;
|
||||
private readonly EvidencePackChatOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EvidencePackChatIntegration"/> class.
|
||||
/// </summary>
|
||||
public EvidencePackChatIntegration(
|
||||
IEvidencePackService evidencePackService,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EvidencePackChatIntegration> logger,
|
||||
IOptions<EvidencePackChatOptions>? options = null)
|
||||
{
|
||||
_evidencePackService = evidencePackService ?? throw new ArgumentNullException(nameof(evidencePackService));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new EvidencePackChatOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an Evidence Pack from a grounding validation result if conditions are met.
|
||||
/// </summary>
|
||||
/// <param name="grounding">The grounding validation result.</param>
|
||||
/// <param name="context">The conversation context.</param>
|
||||
/// <param name="conversationId">The conversation ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created Evidence Pack, or null if conditions not met.</returns>
|
||||
public async Task<EvidencePack?> TryCreateFromGroundingAsync(
|
||||
GroundingValidationResult grounding,
|
||||
ConversationContext context,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.AutoCreateEnabled)
|
||||
{
|
||||
_logger.LogDebug("Auto-create Evidence Packs disabled");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!grounding.IsAcceptable)
|
||||
{
|
||||
_logger.LogDebug("Grounding not acceptable (score {Score:F2}), skipping Evidence Pack creation",
|
||||
grounding.GroundingScore);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (grounding.GroundingScore < _options.MinGroundingScore)
|
||||
{
|
||||
_logger.LogDebug("Grounding score {Score:F2} below threshold {Threshold:F2}, skipping Evidence Pack creation",
|
||||
grounding.GroundingScore, _options.MinGroundingScore);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (grounding.ValidatedLinks.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No validated links in grounding result, skipping Evidence Pack creation");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build claims from grounded claims
|
||||
var claims = BuildClaimsFromGrounding(grounding);
|
||||
if (claims.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No claims extracted from grounding, skipping Evidence Pack creation");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build evidence items from validated links
|
||||
var evidence = BuildEvidenceFromLinks(grounding.ValidatedLinks);
|
||||
if (evidence.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No evidence items built from links, skipping Evidence Pack creation");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine subject
|
||||
var subject = BuildSubject(context);
|
||||
|
||||
// Create context
|
||||
var packContext = new EvidencePackContext
|
||||
{
|
||||
TenantId = context.TenantId,
|
||||
RunId = context.RunId,
|
||||
ConversationId = conversationId,
|
||||
UserId = context.UserId,
|
||||
GeneratedBy = "AdvisoryAI"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var pack = await _evidencePackService.CreateAsync(
|
||||
claims.ToArray(),
|
||||
evidence.ToArray(),
|
||||
subject,
|
||||
packContext,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created Evidence Pack {PackId} from chat grounding (score {Score:F2}, {ClaimCount} claims, {EvidenceCount} evidence items)",
|
||||
pack.PackId, grounding.GroundingScore, claims.Length, evidence.Length);
|
||||
|
||||
return pack;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create Evidence Pack from grounding result");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private ImmutableArray<EvidenceClaim> BuildClaimsFromGrounding(GroundingValidationResult grounding)
|
||||
{
|
||||
var claims = new List<EvidenceClaim>();
|
||||
var claimIndex = 0;
|
||||
|
||||
// Build claims from grounded claims (claims near valid links)
|
||||
var validLinkPositions = grounding.ValidatedLinks
|
||||
.Where(l => l.IsValid)
|
||||
.Select(l => l.Position)
|
||||
.ToHashSet();
|
||||
|
||||
// We need to extract claims that were considered grounded
|
||||
// These are claims that had a nearby link
|
||||
// For now, we infer from the structure - grounded claims = TotalClaims - UngroundedClaims
|
||||
// We'll create claims based on the validated links and their context
|
||||
|
||||
foreach (var link in grounding.ValidatedLinks.Where(l => l.IsValid))
|
||||
{
|
||||
var claimId = $"claim-{claimIndex++:D3}";
|
||||
var evidenceId = $"ev-{link.Type}-{claimIndex:D3}";
|
||||
|
||||
// Determine claim type based on link type
|
||||
var claimType = link.Type switch
|
||||
{
|
||||
"vex" => Evidence.Pack.Models.ClaimType.VulnerabilityStatus,
|
||||
"reach" or "runtime" => Evidence.Pack.Models.ClaimType.Reachability,
|
||||
"sbom" => Evidence.Pack.Models.ClaimType.VulnerabilityStatus,
|
||||
_ => Evidence.Pack.Models.ClaimType.Custom
|
||||
};
|
||||
|
||||
// Build claim text based on link context
|
||||
var claimText = link.Type switch
|
||||
{
|
||||
"vex" => $"VEX statement from {link.Path}",
|
||||
"reach" => $"Reachability analysis for {link.Path}",
|
||||
"runtime" => $"Runtime observation from {link.Path}",
|
||||
"sbom" => $"Component present in SBOM {link.Path}",
|
||||
"ops-mem" => $"OpsMemory context from {link.Path}",
|
||||
_ => $"Evidence from {link.Type}:{link.Path}"
|
||||
};
|
||||
|
||||
claims.Add(new EvidenceClaim
|
||||
{
|
||||
ClaimId = claimId,
|
||||
Text = claimText,
|
||||
Type = claimType,
|
||||
Status = "grounded",
|
||||
Confidence = grounding.GroundingScore,
|
||||
EvidenceIds = [evidenceId],
|
||||
Source = "ai"
|
||||
});
|
||||
}
|
||||
|
||||
return claims.ToImmutableArray();
|
||||
}
|
||||
|
||||
private ImmutableArray<EvidenceItem> BuildEvidenceFromLinks(ImmutableArray<ValidatedLink> validatedLinks)
|
||||
{
|
||||
var evidence = new List<EvidenceItem>();
|
||||
var evidenceIndex = 0;
|
||||
|
||||
foreach (var link in validatedLinks.Where(l => l.IsValid))
|
||||
{
|
||||
var evidenceId = $"ev-{link.Type}-{evidenceIndex++:D3}";
|
||||
|
||||
var evidenceType = link.Type switch
|
||||
{
|
||||
"sbom" => EvidenceType.Sbom,
|
||||
"vex" => EvidenceType.Vex,
|
||||
"reach" => EvidenceType.Reachability,
|
||||
"runtime" => EvidenceType.Runtime,
|
||||
"attest" => EvidenceType.Attestation,
|
||||
"ops-mem" => EvidenceType.OpsMemory,
|
||||
_ => EvidenceType.Custom
|
||||
};
|
||||
|
||||
var snapshot = CreateSnapshotForType(link.Type, link.Path, link.ResolvedUri);
|
||||
|
||||
evidence.Add(new EvidenceItem
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
Type = evidenceType,
|
||||
Uri = link.ResolvedUri ?? $"stella://{link.Type}/{link.Path}",
|
||||
Digest = ComputeLinkDigest(link),
|
||||
CollectedAt = _timeProvider.GetUtcNow(),
|
||||
Snapshot = snapshot
|
||||
});
|
||||
}
|
||||
|
||||
return evidence.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static EvidenceSnapshot CreateSnapshotForType(string type, string path, string? resolvedUri)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
"sbom" => EvidenceSnapshot.Custom("sbom", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary()),
|
||||
|
||||
"vex" => EvidenceSnapshot.Custom("vex", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary()),
|
||||
|
||||
"reach" => EvidenceSnapshot.Custom("reachability", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary()),
|
||||
|
||||
"runtime" => EvidenceSnapshot.Custom("runtime", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary()),
|
||||
|
||||
_ => EvidenceSnapshot.Custom(type, new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary())
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeLinkDigest(ValidatedLink link)
|
||||
{
|
||||
var input = $"{link.Type}:{link.Path}:{link.ResolvedUri}";
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static EvidenceSubject BuildSubject(ConversationContext context)
|
||||
{
|
||||
// Determine subject type based on available context
|
||||
if (!string.IsNullOrEmpty(context.FindingId))
|
||||
{
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Finding,
|
||||
FindingId = context.FindingId,
|
||||
CveId = context.CurrentCveId,
|
||||
Component = context.CurrentComponent,
|
||||
ImageDigest = context.CurrentImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CurrentCveId))
|
||||
{
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Cve,
|
||||
CveId = context.CurrentCveId,
|
||||
Component = context.CurrentComponent,
|
||||
ImageDigest = context.CurrentImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CurrentComponent))
|
||||
{
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Component,
|
||||
Component = context.CurrentComponent,
|
||||
ImageDigest = context.CurrentImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CurrentImageDigest))
|
||||
{
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Image,
|
||||
ImageDigest = context.CurrentImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
// Default to custom subject
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Custom
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for Evidence Pack chat integration.
|
||||
/// </summary>
|
||||
public sealed class EvidencePackChatOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether to auto-create Evidence Packs.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool AutoCreateEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum grounding score for auto-creation.
|
||||
/// Default: 0.7.
|
||||
/// </summary>
|
||||
public double MinGroundingScore { get; set; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to auto-sign created packs.
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool AutoSign { get; set; }
|
||||
}
|
||||
@@ -341,7 +341,7 @@ public sealed partial class GroundingValidator
|
||||
return claim[..(maxLength - 3)] + "...";
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs):(?<path>[^\]]+)\]", RegexOptions.Compiled)]
|
||||
[GeneratedRegex(@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs|ops-mem):(?<path>[^\]]+)\]", RegexOptions.Compiled)]
|
||||
private static partial Regex ObjectLinkRegex();
|
||||
|
||||
[GeneratedRegex(@"(?:is|are|was|were|has been|have been)\s+(?:not\s+)?(?:affected|vulnerable|exploitable|fixed|patched|mitigated|under investigation)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
|
||||
294
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/OpsMemoryIntegration.cs
Normal file
294
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/OpsMemoryIntegration.cs
Normal file
@@ -0,0 +1,294 @@
|
||||
// <copyright file="OpsMemoryIntegration.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using OpsMemoryConversationContext = StellaOps.OpsMemory.Integration.ConversationContext;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates OpsMemory with AdvisoryAI chat sessions.
|
||||
/// Enables surfacing past decisions and recording new decisions from chat actions.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-004
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryIntegration : IOpsMemoryIntegration
|
||||
{
|
||||
private readonly IOpsMemoryChatProvider _opsMemoryProvider;
|
||||
private readonly OpsMemoryContextEnricher _contextEnricher;
|
||||
private readonly ILogger<OpsMemoryIntegration> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new OpsMemoryIntegration.
|
||||
/// </summary>
|
||||
public OpsMemoryIntegration(
|
||||
IOpsMemoryChatProvider opsMemoryProvider,
|
||||
OpsMemoryContextEnricher contextEnricher,
|
||||
ILogger<OpsMemoryIntegration> logger)
|
||||
{
|
||||
_opsMemoryProvider = opsMemoryProvider ?? throw new ArgumentNullException(nameof(opsMemoryProvider));
|
||||
_contextEnricher = contextEnricher ?? throw new ArgumentNullException(nameof(contextEnricher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryEnrichmentResult> EnrichConversationContextAsync(
|
||||
ConversationContext context,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Enriching conversation context with OpsMemory for tenant {TenantId}",
|
||||
tenantId);
|
||||
|
||||
// Build chat context request from conversation context
|
||||
var request = BuildChatContextRequest(context, tenantId);
|
||||
|
||||
// Enrich prompt with OpsMemory context
|
||||
var enrichedPrompt = await _contextEnricher.EnrichPromptAsync(
|
||||
request,
|
||||
existingPrompt: null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new OpsMemoryEnrichmentResult
|
||||
{
|
||||
SystemPromptAddition = enrichedPrompt.SystemPromptAddition ?? string.Empty,
|
||||
ContextBlock = enrichedPrompt.EnrichedPrompt,
|
||||
ReferencedMemoryIds = enrichedPrompt.DecisionsReferenced,
|
||||
HasEnrichment = enrichedPrompt.HasEnrichment,
|
||||
SimilarDecisionCount = enrichedPrompt.Context.SimilarDecisions.Length,
|
||||
ApplicableTacticCount = enrichedPrompt.Context.ApplicableTactics.Length
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryRecord?> RecordDecisionFromActionAsync(
|
||||
ProposedAction action,
|
||||
Conversation conversation,
|
||||
ConversationTurn turn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ShouldRecordAction(action))
|
||||
{
|
||||
_logger.LogDebug("Skipping non-decision action: {ActionType}", action.ActionType);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recording decision from chat action: {ActionType} for {Subject}",
|
||||
action.ActionType, action.Subject ?? "(unknown)");
|
||||
|
||||
// Build action execution result
|
||||
var actionResult = new ActionExecutionResult
|
||||
{
|
||||
Action = MapActionType(action.ActionType),
|
||||
CveId = ExtractCveId(action),
|
||||
Component = ExtractComponent(action),
|
||||
Success = true, // Assuming executed action was successful
|
||||
Rationale = action.Rationale ?? turn.Content,
|
||||
ExecutedAt = turn.Timestamp,
|
||||
ActorId = turn.ActorId ?? "system",
|
||||
Metadata = action.Parameters
|
||||
};
|
||||
|
||||
// Build conversation context for OpsMemory
|
||||
var opsMemoryConversationContext = new OpsMemoryConversationContext
|
||||
{
|
||||
ConversationId = conversation.ConversationId,
|
||||
TenantId = conversation.TenantId,
|
||||
UserId = conversation.UserId ?? "unknown",
|
||||
Topic = conversation.Context.Topic,
|
||||
TurnNumber = turn.TurnNumber,
|
||||
Situation = ExtractSituation(conversation.Context),
|
||||
EvidenceLinks = turn.EvidenceLinks.Select(e => e.Uri).ToImmutableArray()
|
||||
};
|
||||
|
||||
// Record to OpsMemory
|
||||
var record = await _opsMemoryProvider.RecordFromActionAsync(
|
||||
actionResult,
|
||||
opsMemoryConversationContext,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created OpsMemory record {MemoryId} from conversation {ConversationId}",
|
||||
record.MemoryId, conversation.ConversationId);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsForContextAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await _opsMemoryProvider.GetRecentDecisionsAsync(tenantId, limit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static ChatContextRequest BuildChatContextRequest(
|
||||
ConversationContext context,
|
||||
string tenantId)
|
||||
{
|
||||
return new ChatContextRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CveId = context.FocusedCveId,
|
||||
Component = context.FocusedComponent,
|
||||
Severity = context.Severity,
|
||||
Reachability = context.IsReachable.HasValue
|
||||
? (context.IsReachable.Value ? ReachabilityStatus.Reachable : ReachabilityStatus.NotReachable)
|
||||
: null,
|
||||
CvssScore = context.CvssScore,
|
||||
EpssScore = context.EpssScore,
|
||||
ContextTags = context.Tags,
|
||||
MaxSuggestions = 3,
|
||||
MinSimilarity = 0.6
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ShouldRecordAction(ProposedAction action)
|
||||
{
|
||||
// Only record security decision actions
|
||||
var recordableActions = new[]
|
||||
{
|
||||
"accept_risk", "suppress", "quarantine", "remediate", "defer",
|
||||
"approve", "reject", "escalate", "mitigate", "monitor"
|
||||
};
|
||||
|
||||
return recordableActions.Contains(action.ActionType, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static DecisionAction MapActionType(string actionType)
|
||||
{
|
||||
// Map action types to OpsMemory.Models.DecisionAction enum values
|
||||
return actionType.ToUpperInvariant() switch
|
||||
{
|
||||
"ACCEPT_RISK" or "ACCEPT" or "SUPPRESS" or "APPROVE" => DecisionAction.Accept,
|
||||
"QUARANTINE" => DecisionAction.Quarantine,
|
||||
"REMEDIATE" or "FIX" => DecisionAction.Remediate,
|
||||
"DEFER" or "POSTPONE" or "MONITOR" => DecisionAction.Defer,
|
||||
"REJECT" or "FALSE_POSITIVE" => DecisionAction.FalsePositive,
|
||||
"ESCALATE" => DecisionAction.Escalate,
|
||||
"MITIGATE" => DecisionAction.Mitigate,
|
||||
_ => DecisionAction.Defer // Default to Defer for unknown actions
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractCveId(ProposedAction action)
|
||||
{
|
||||
if (action.Parameters.TryGetValue("cve_id", out var cveId))
|
||||
return cveId;
|
||||
|
||||
if (action.Parameters.TryGetValue("cveId", out cveId))
|
||||
return cveId;
|
||||
|
||||
if (action.Subject?.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return action.Subject;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractComponent(ProposedAction action)
|
||||
{
|
||||
if (action.Parameters.TryGetValue("component", out var component))
|
||||
return component;
|
||||
|
||||
if (action.Parameters.TryGetValue("purl", out var purl))
|
||||
return purl;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SituationContext? ExtractSituation(ConversationContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.FocusedCveId) &&
|
||||
string.IsNullOrWhiteSpace(context.FocusedComponent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SituationContext
|
||||
{
|
||||
CveId = context.FocusedCveId,
|
||||
Component = context.FocusedComponent,
|
||||
Severity = context.Severity,
|
||||
Reachability = context.IsReachable.HasValue
|
||||
? (context.IsReachable.Value ? ReachabilityStatus.Reachable : ReachabilityStatus.NotReachable)
|
||||
: ReachabilityStatus.Unknown,
|
||||
CvssScore = context.CvssScore,
|
||||
EpssScore = context.EpssScore,
|
||||
ContextTags = context.Tags
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for OpsMemory integration with AdvisoryAI.
|
||||
/// </summary>
|
||||
public interface IOpsMemoryIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Enriches conversation context with OpsMemory data.
|
||||
/// </summary>
|
||||
Task<OpsMemoryEnrichmentResult> EnrichConversationContextAsync(
|
||||
ConversationContext context,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Records a decision from an executed chat action to OpsMemory.
|
||||
/// </summary>
|
||||
Task<OpsMemoryRecord?> RecordDecisionFromActionAsync(
|
||||
ProposedAction action,
|
||||
Conversation conversation,
|
||||
ConversationTurn turn,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent decisions for context.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsForContextAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of OpsMemory enrichment for conversation.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryEnrichmentResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the system prompt addition.
|
||||
/// </summary>
|
||||
public required string SystemPromptAddition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the context block to include in the conversation.
|
||||
/// </summary>
|
||||
public required string ContextBlock { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the memory IDs referenced.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ReferencedMemoryIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any enrichment was added.
|
||||
/// </summary>
|
||||
public bool HasEnrichment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of similar decisions found.
|
||||
/// </summary>
|
||||
public int SimilarDecisionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of applicable tactics found.
|
||||
/// </summary>
|
||||
public int ApplicableTacticCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// <copyright file="OpsMemoryLinkResolver.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves ops-mem:// object links to OpsMemory records.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryLinkResolver : ITypedLinkResolver
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly ILogger<OpsMemoryLinkResolver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OpsMemoryLinkResolver"/> class.
|
||||
/// </summary>
|
||||
public OpsMemoryLinkResolver(
|
||||
IOpsMemoryStore store,
|
||||
ILogger<OpsMemoryLinkResolver> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the link type this resolver handles.
|
||||
/// </summary>
|
||||
public string LinkType => "ops-mem";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LinkResolution> ResolveAsync(
|
||||
string path,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
_logger.LogWarning("Cannot resolve ops-mem link without tenant ID");
|
||||
return new LinkResolution { Exists = false };
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var record = await _store.GetByIdAsync(path, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
_logger.LogDebug("OpsMemory record not found: {MemoryId}", path);
|
||||
return new LinkResolution { Exists = false };
|
||||
}
|
||||
|
||||
return new LinkResolution
|
||||
{
|
||||
Exists = true,
|
||||
Uri = $"ops-mem://{path}",
|
||||
ObjectType = "decision",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["cveId"] = record.Situation?.CveId ?? string.Empty,
|
||||
["action"] = record.Decision?.Action.ToString() ?? string.Empty,
|
||||
["outcome"] = record.Outcome?.Status.ToString() ?? "pending",
|
||||
["decidedAt"] = record.RecordedAt.ToString("O")
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error resolving ops-mem link: {Path}", path);
|
||||
return new LinkResolution { Exists = false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for type-specific link resolvers.
|
||||
/// </summary>
|
||||
public interface ITypedLinkResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the link type this resolver handles.
|
||||
/// </summary>
|
||||
string LinkType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a link of this type.
|
||||
/// </summary>
|
||||
Task<LinkResolution> ResolveAsync(
|
||||
string path,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composite link resolver that delegates to type-specific resolvers.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
|
||||
/// </summary>
|
||||
public sealed class CompositeObjectLinkResolver : IObjectLinkResolver
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, ITypedLinkResolver> _resolvers;
|
||||
private readonly ILogger<CompositeObjectLinkResolver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompositeObjectLinkResolver"/> class.
|
||||
/// </summary>
|
||||
public CompositeObjectLinkResolver(
|
||||
IEnumerable<ITypedLinkResolver> resolvers,
|
||||
ILogger<CompositeObjectLinkResolver> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resolvers);
|
||||
|
||||
_resolvers = resolvers.ToDictionary(
|
||||
r => r.LinkType,
|
||||
r => r,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LinkResolution> ResolveAsync(
|
||||
string type,
|
||||
string path,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_resolvers.TryGetValue(type, out var resolver))
|
||||
{
|
||||
_logger.LogDebug("No resolver registered for link type: {Type}", type);
|
||||
return new LinkResolution { Exists = false };
|
||||
}
|
||||
|
||||
return await resolver.ResolveAsync(path, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// <copyright file="ActionsServiceCollectionExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering action-related services.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE
|
||||
/// </summary>
|
||||
public static class ActionsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds action policy integration services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configurePolicy">Optional policy configuration.</param>
|
||||
/// <param name="configureIdempotency">Optional idempotency configuration.</param>
|
||||
/// <param name="configureAudit">Optional audit configuration.</param>
|
||||
/// <param name="configureExecutor">Optional executor configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddActionPolicyIntegration(
|
||||
this IServiceCollection services,
|
||||
Action<ActionPolicyOptions>? configurePolicy = null,
|
||||
Action<IdempotencyOptions>? configureIdempotency = null,
|
||||
Action<AuditLedgerOptions>? configureAudit = null,
|
||||
Action<ActionExecutorOptions>? configureExecutor = null)
|
||||
{
|
||||
// Configure options
|
||||
if (configurePolicy is not null)
|
||||
{
|
||||
services.Configure(configurePolicy);
|
||||
}
|
||||
|
||||
if (configureIdempotency is not null)
|
||||
{
|
||||
services.Configure(configureIdempotency);
|
||||
}
|
||||
|
||||
if (configureAudit is not null)
|
||||
{
|
||||
services.Configure(configureAudit);
|
||||
}
|
||||
|
||||
if (configureExecutor is not null)
|
||||
{
|
||||
services.Configure(configureExecutor);
|
||||
}
|
||||
|
||||
// Register core services
|
||||
services.TryAddSingleton<IActionRegistry, ActionRegistry>();
|
||||
services.TryAddSingleton<IGuidGenerator, DefaultGuidGenerator>();
|
||||
|
||||
// Register policy gate
|
||||
services.TryAddScoped<IActionPolicyGate, ActionPolicyGate>();
|
||||
|
||||
// Register idempotency handler
|
||||
services.TryAddSingleton<IIdempotencyHandler, IdempotencyHandler>();
|
||||
|
||||
// Register approval workflow adapter
|
||||
services.TryAddSingleton<IApprovalWorkflowAdapter, ApprovalWorkflowAdapter>();
|
||||
|
||||
// Register audit ledger
|
||||
services.TryAddSingleton<IActionAuditLedger, ActionAuditLedger>();
|
||||
|
||||
// Register action executor
|
||||
services.TryAddScoped<IActionExecutor, ActionExecutor>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds action policy integration with default configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddDefaultActionPolicyIntegration(
|
||||
this IServiceCollection services)
|
||||
{
|
||||
return services.AddActionPolicyIntegration();
|
||||
}
|
||||
}
|
||||
429
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/IRunService.cs
Normal file
429
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/IRunService.cs
Normal file
@@ -0,0 +1,429 @@
|
||||
// <copyright file="IRunService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing AI investigation runs.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-002
|
||||
/// </summary>
|
||||
public interface IRunService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new run.
|
||||
/// </summary>
|
||||
/// <param name="request">The create request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created run.</returns>
|
||||
Task<Run> CreateAsync(CreateRunRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a run by ID.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The run, or null if not found.</returns>
|
||||
Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries runs.
|
||||
/// </summary>
|
||||
/// <param name="query">The query parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The matching runs.</returns>
|
||||
Task<RunQueryResult> QueryAsync(RunQuery query, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an event to a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="eventRequest">The event to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The added event.</returns>
|
||||
Task<RunEvent> AddEventAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
AddRunEventRequest eventRequest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a user turn to the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="message">The user message.</param>
|
||||
/// <param name="userId">The user ID.</param>
|
||||
/// <param name="evidenceLinks">Optional evidence links.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The added event.</returns>
|
||||
Task<RunEvent> AddUserTurnAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string message,
|
||||
string userId,
|
||||
ImmutableArray<EvidenceLink>? evidenceLinks = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an assistant turn to the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="message">The assistant message.</param>
|
||||
/// <param name="evidenceLinks">Optional evidence links.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The added event.</returns>
|
||||
Task<RunEvent> AddAssistantTurnAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string message,
|
||||
ImmutableArray<EvidenceLink>? evidenceLinks = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Proposes an action in the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="action">The proposed action.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The action proposed event.</returns>
|
||||
Task<RunEvent> ProposeActionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
ProposeActionRequest action,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests approval for pending actions.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="approvers">The approver user IDs.</param>
|
||||
/// <param name="reason">The reason for approval request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> RequestApprovalAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
ImmutableArray<string> approvers,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Approves or rejects a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="approved">Whether to approve or reject.</param>
|
||||
/// <param name="approverId">The approver's user ID.</param>
|
||||
/// <param name="reason">The approval/rejection reason.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> ApproveAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
bool approved,
|
||||
string approverId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Executes an approved action.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="actionEventId">The action event ID to execute.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The action executed event.</returns>
|
||||
Task<RunEvent> ExecuteActionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string actionEventId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an artifact to the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="artifact">The artifact to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> AddArtifactAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunArtifact artifact,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches an evidence pack to the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="evidencePack">The evidence pack reference.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> AttachEvidencePackAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
EvidencePackReference evidencePack,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Completes a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="summary">Optional completion summary.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The completed run.</returns>
|
||||
Task<Run> CompleteAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? summary = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="reason">The cancellation reason.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The cancelled run.</returns>
|
||||
Task<Run> CancelAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Hands off a run to another user.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="toUserId">The user to hand off to.</param>
|
||||
/// <param name="message">Optional handoff message.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> HandOffAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string toUserId,
|
||||
string? message = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an attestation for a completed run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attested run.</returns>
|
||||
Task<Run> AttestAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timeline for a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="skip">Number of events to skip.</param>
|
||||
/// <param name="take">Number of events to take.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The timeline events.</returns>
|
||||
Task<ImmutableArray<RunEvent>> GetTimelineAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new run.
|
||||
/// </summary>
|
||||
public sealed record CreateRunRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the initiating user ID.
|
||||
/// </summary>
|
||||
public required string InitiatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run title.
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run objective.
|
||||
/// </summary>
|
||||
public string? Objective { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the initial context.
|
||||
/// </summary>
|
||||
public RunContext? Context { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to add a run event.
|
||||
/// </summary>
|
||||
public sealed record AddRunEventRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the event type.
|
||||
/// </summary>
|
||||
public required RunEventType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor ID.
|
||||
/// </summary>
|
||||
public string? ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event content.
|
||||
/// </summary>
|
||||
public RunEventContent? Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence links.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parent event ID.
|
||||
/// </summary>
|
||||
public string? ParentEventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets event metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to propose an action.
|
||||
/// </summary>
|
||||
public sealed record ProposeActionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action type.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action subject.
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rationale.
|
||||
/// </summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether approval is required.
|
||||
/// </summary>
|
||||
public bool RequiresApproval { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string>? Parameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence links.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for runs.
|
||||
/// </summary>
|
||||
public sealed record RunQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional status filter.
|
||||
/// </summary>
|
||||
public ImmutableArray<RunStatus>? Statuses { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional initiator filter.
|
||||
/// </summary>
|
||||
public string? InitiatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional CVE filter.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional component filter.
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional created after filter.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional created before filter.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number to skip.
|
||||
/// </summary>
|
||||
public int Skip { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number to take.
|
||||
/// </summary>
|
||||
public int Take { get; init; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a run query.
|
||||
/// </summary>
|
||||
public sealed record RunQueryResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matching runs.
|
||||
/// </summary>
|
||||
public required ImmutableArray<Run> Runs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total count.
|
||||
/// </summary>
|
||||
public required int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are more results.
|
||||
/// </summary>
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
104
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/IRunStore.cs
Normal file
104
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/IRunStore.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
// <copyright file="IRunStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// Store for persisting runs.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-004
|
||||
/// </summary>
|
||||
public interface IRunStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves a run.
|
||||
/// </summary>
|
||||
/// <param name="run">The run to save.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task SaveAsync(Run run, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a run by ID.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The run, or null if not found.</returns>
|
||||
Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries runs.
|
||||
/// </summary>
|
||||
/// <param name="query">The query parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The matching runs and total count.</returns>
|
||||
Task<(ImmutableArray<Run> Runs, int TotalCount)> QueryAsync(
|
||||
RunQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets runs by status.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="statuses">The statuses to filter by.</param>
|
||||
/// <param name="skip">Number to skip.</param>
|
||||
/// <param name="take">Number to take.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The matching runs.</returns>
|
||||
Task<ImmutableArray<Run>> GetByStatusAsync(
|
||||
string tenantId,
|
||||
ImmutableArray<RunStatus> statuses,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active runs for a user.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="userId">The user ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The user's active runs.</returns>
|
||||
Task<ImmutableArray<Run>> GetActiveForUserAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets runs pending approval.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="approverId">Optional approver filter.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Runs pending approval.</returns>
|
||||
Task<ImmutableArray<Run>> GetPendingApprovalAsync(
|
||||
string tenantId,
|
||||
string? approverId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates run status.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="newStatus">The new status.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if updated, false if not found.</returns>
|
||||
Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunStatus newStatus,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
161
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/InMemoryRunStore.cs
Normal file
161
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/InMemoryRunStore.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
// <copyright file="InMemoryRunStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IRunStore"/> for development/testing.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-005
|
||||
/// </summary>
|
||||
public sealed class InMemoryRunStore : IRunStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string TenantId, string RunId), Run> _runs = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveAsync(Run run, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(run);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_runs[(run.TenantId, run.RunId)] = run;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_runs.TryGetValue((tenantId, runId), out var run);
|
||||
return Task.FromResult(run);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(ImmutableArray<Run> Runs, int TotalCount)> QueryAsync(
|
||||
RunQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var runs = _runs.Values
|
||||
.Where(r => r.TenantId == query.TenantId)
|
||||
.Where(r => query.Statuses is null || query.Statuses.Value.Contains(r.Status))
|
||||
.Where(r => query.InitiatedBy is null || r.InitiatedBy == query.InitiatedBy)
|
||||
.Where(r => query.CveId is null || r.Context.FocusedCveId == query.CveId)
|
||||
.Where(r => query.Component is null || r.Context.FocusedComponent == query.Component)
|
||||
.Where(r => query.CreatedAfter is null || r.CreatedAt >= query.CreatedAfter)
|
||||
.Where(r => query.CreatedBefore is null || r.CreatedAt <= query.CreatedBefore)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
var totalCount = runs.Count;
|
||||
var pagedRuns = runs
|
||||
.Skip(query.Skip)
|
||||
.Take(query.Take)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult((pagedRuns, totalCount));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DeleteAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return Task.FromResult(_runs.TryRemove((tenantId, runId), out _));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<Run>> GetByStatusAsync(
|
||||
string tenantId,
|
||||
ImmutableArray<RunStatus> statuses,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var runs = _runs.Values
|
||||
.Where(r => r.TenantId == tenantId && statuses.Contains(r.Status))
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(runs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<Run>> GetActiveForUserAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var activeStatuses = new[] { RunStatus.Created, RunStatus.Active, RunStatus.PendingApproval };
|
||||
|
||||
var runs = _runs.Values
|
||||
.Where(r => r.TenantId == tenantId)
|
||||
.Where(r => r.InitiatedBy == userId || r.Metadata.GetValueOrDefault("current_owner") == userId)
|
||||
.Where(r => activeStatuses.Contains(r.Status))
|
||||
.OrderByDescending(r => r.UpdatedAt)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(runs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<Run>> GetPendingApprovalAsync(
|
||||
string tenantId,
|
||||
string? approverId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var runs = _runs.Values
|
||||
.Where(r => r.TenantId == tenantId && r.Status == RunStatus.PendingApproval)
|
||||
.Where(r => approverId is null || (r.Approval?.Approvers.Contains(approverId) ?? false))
|
||||
.OrderByDescending(r => r.UpdatedAt)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(runs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunStatus newStatus,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_runs.TryGetValue((tenantId, runId), out var run))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var updated = run with { Status = newStatus };
|
||||
_runs[(tenantId, runId)] = updated;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all runs (for testing).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_runs.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of runs (for testing).
|
||||
/// </summary>
|
||||
public int Count => _runs.Count;
|
||||
}
|
||||
278
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/Run.cs
Normal file
278
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/Run.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
// <copyright file="Run.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// An auditable container for an AI-assisted investigation session.
|
||||
/// Captures the complete lifecycle from initial query through tool calls,
|
||||
/// artifact generation, and approvals.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-001
|
||||
/// </summary>
|
||||
public sealed record Run
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique run identifier.
|
||||
/// Format: run-{timestamp}-{random}
|
||||
/// </summary>
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant ID for multi-tenancy isolation.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user who initiated the run.
|
||||
/// </summary>
|
||||
public required string InitiatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run title (user-provided or auto-generated).
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run objective/goal.
|
||||
/// </summary>
|
||||
public string? Objective { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current run status.
|
||||
/// </summary>
|
||||
public RunStatus Status { get; init; } = RunStatus.Created;
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the run was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the run was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the run was completed (if completed).
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ordered timeline of events in this run.
|
||||
/// </summary>
|
||||
public ImmutableArray<RunEvent> Events { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifacts produced by this run.
|
||||
/// </summary>
|
||||
public ImmutableArray<RunArtifact> Artifacts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence packs attached to this run.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidencePackReference> EvidencePacks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run context (CVE focus, component scope, etc.).
|
||||
/// </summary>
|
||||
public RunContext Context { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approval requirements and status.
|
||||
/// </summary>
|
||||
public ApprovalInfo? Approval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content hash of the run for attestation.
|
||||
/// </summary>
|
||||
public string? ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attestation for this run (if attested).
|
||||
/// </summary>
|
||||
public RunAttestation? Attestation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a run in its lifecycle.
|
||||
/// </summary>
|
||||
public enum RunStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Run has been created but not started.
|
||||
/// </summary>
|
||||
Created = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Run is actively in progress.
|
||||
/// </summary>
|
||||
Active = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Run is waiting for approval.
|
||||
/// </summary>
|
||||
PendingApproval = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Run was approved and actions executed.
|
||||
/// </summary>
|
||||
Approved = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Run was rejected.
|
||||
/// </summary>
|
||||
Rejected = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Run completed successfully.
|
||||
/// </summary>
|
||||
Completed = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Run was cancelled.
|
||||
/// </summary>
|
||||
Cancelled = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Run failed with error.
|
||||
/// </summary>
|
||||
Failed = 7,
|
||||
|
||||
/// <summary>
|
||||
/// Run expired without completion.
|
||||
/// </summary>
|
||||
Expired = 8
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context information for a run.
|
||||
/// </summary>
|
||||
public sealed record RunContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the focused CVE ID (if any).
|
||||
/// </summary>
|
||||
public string? FocusedCveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the focused component PURL (if any).
|
||||
/// </summary>
|
||||
public string? FocusedComponent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SBOM digest (if any).
|
||||
/// </summary>
|
||||
public string? SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the container image reference (if any).
|
||||
/// </summary>
|
||||
public string? ImageReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scope tags.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets OpsMemory context if enriched.
|
||||
/// </summary>
|
||||
public OpsMemoryRunContext? OpsMemory { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpsMemory context attached to a run.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryRunContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the similar past decisions surfaced.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> SimilarDecisionIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the applicable tactics.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> TacticIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether OpsMemory enrichment was applied.
|
||||
/// </summary>
|
||||
public bool IsEnriched { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approval information for a run.
|
||||
/// </summary>
|
||||
public sealed record ApprovalInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether approval is required.
|
||||
/// </summary>
|
||||
public bool Required { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approver user IDs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Approvers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether approval was granted.
|
||||
/// </summary>
|
||||
public bool? Approved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets who approved/rejected.
|
||||
/// </summary>
|
||||
public string? ApprovedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when approval was decided.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approval/rejection reason.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation for a completed run.
|
||||
/// </summary>
|
||||
public sealed record RunAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the attestation ID.
|
||||
/// </summary>
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest that was attested.
|
||||
/// </summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attestation statement URI.
|
||||
/// </summary>
|
||||
public required string StatementUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the signature.
|
||||
/// </summary>
|
||||
public required string Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the attestation was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
182
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunArtifact.cs
Normal file
182
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunArtifact.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
// <copyright file="RunArtifact.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// An artifact produced by a run.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-001
|
||||
/// </summary>
|
||||
public sealed record RunArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the artifact ID.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact type.
|
||||
/// </summary>
|
||||
public required ArtifactType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the artifact was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest (SHA256).
|
||||
/// </summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content size in bytes.
|
||||
/// </summary>
|
||||
public long ContentSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the media type.
|
||||
/// </summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the storage URI.
|
||||
/// </summary>
|
||||
public string? StorageUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the artifact is inline (small enough to embed).
|
||||
/// </summary>
|
||||
public bool IsInline { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the inline content (if IsInline).
|
||||
/// </summary>
|
||||
public string? InlineContent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event ID that produced this artifact.
|
||||
/// </summary>
|
||||
public string? ProducingEventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets artifact metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of artifact produced by a run.
|
||||
/// </summary>
|
||||
public enum ArtifactType
|
||||
{
|
||||
/// <summary>
|
||||
/// Evidence pack bundle.
|
||||
/// </summary>
|
||||
EvidencePack = 0,
|
||||
|
||||
/// <summary>
|
||||
/// VEX statement.
|
||||
/// </summary>
|
||||
VexStatement = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Decision record.
|
||||
/// </summary>
|
||||
DecisionRecord = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Action result.
|
||||
/// </summary>
|
||||
ActionResult = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation result.
|
||||
/// </summary>
|
||||
PolicyResult = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Remediation plan.
|
||||
/// </summary>
|
||||
RemediationPlan = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Report document.
|
||||
/// </summary>
|
||||
Report = 6,
|
||||
|
||||
/// <summary>
|
||||
/// SBOM document.
|
||||
/// </summary>
|
||||
Sbom = 7,
|
||||
|
||||
/// <summary>
|
||||
/// Attestation statement.
|
||||
/// </summary>
|
||||
Attestation = 8,
|
||||
|
||||
/// <summary>
|
||||
/// Query result data.
|
||||
/// </summary>
|
||||
QueryResult = 9,
|
||||
|
||||
/// <summary>
|
||||
/// Code snippet.
|
||||
/// </summary>
|
||||
CodeSnippet = 10,
|
||||
|
||||
/// <summary>
|
||||
/// Configuration file.
|
||||
/// </summary>
|
||||
Configuration = 11,
|
||||
|
||||
/// <summary>
|
||||
/// Other artifact type.
|
||||
/// </summary>
|
||||
Other = 99
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an evidence pack.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the evidence pack ID.
|
||||
/// </summary>
|
||||
public required string PackId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pack digest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the pack was attached.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AttachedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pack type.
|
||||
/// </summary>
|
||||
public string? PackType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the storage URI.
|
||||
/// </summary>
|
||||
public string? StorageUri { get; init; }
|
||||
}
|
||||
428
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunEvent.cs
Normal file
428
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunEvent.cs
Normal file
@@ -0,0 +1,428 @@
|
||||
// <copyright file="RunEvent.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// An event in a run's timeline.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-001
|
||||
/// </summary>
|
||||
public sealed record RunEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the event ID (unique within the run).
|
||||
/// </summary>
|
||||
public required string EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event type.
|
||||
/// </summary>
|
||||
public required RunEventType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the event occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor who triggered the event (user or system).
|
||||
/// </summary>
|
||||
public string? ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event content (varies by type).
|
||||
/// </summary>
|
||||
public RunEventContent? Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence links attached to this event.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidenceLink> EvidenceLinks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sequence number in the run timeline.
|
||||
/// </summary>
|
||||
public int SequenceNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parent event ID (for threaded responses).
|
||||
/// </summary>
|
||||
public string? ParentEventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets event metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of run event.
|
||||
/// </summary>
|
||||
public enum RunEventType
|
||||
{
|
||||
/// <summary>
|
||||
/// Run was created.
|
||||
/// </summary>
|
||||
Created = 0,
|
||||
|
||||
/// <summary>
|
||||
/// User message/turn.
|
||||
/// </summary>
|
||||
UserTurn = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Assistant response/turn.
|
||||
/// </summary>
|
||||
AssistantTurn = 2,
|
||||
|
||||
/// <summary>
|
||||
/// System message.
|
||||
/// </summary>
|
||||
SystemMessage = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Tool was called.
|
||||
/// </summary>
|
||||
ToolCall = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Tool returned result.
|
||||
/// </summary>
|
||||
ToolResult = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Action was proposed.
|
||||
/// </summary>
|
||||
ActionProposed = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was requested.
|
||||
/// </summary>
|
||||
ApprovalRequested = 7,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was granted.
|
||||
/// </summary>
|
||||
ApprovalGranted = 8,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was denied.
|
||||
/// </summary>
|
||||
ApprovalDenied = 9,
|
||||
|
||||
/// <summary>
|
||||
/// Action was executed.
|
||||
/// </summary>
|
||||
ActionExecuted = 10,
|
||||
|
||||
/// <summary>
|
||||
/// Artifact was produced.
|
||||
/// </summary>
|
||||
ArtifactProduced = 11,
|
||||
|
||||
/// <summary>
|
||||
/// Evidence was attached.
|
||||
/// </summary>
|
||||
EvidenceAttached = 12,
|
||||
|
||||
/// <summary>
|
||||
/// Run was handed off to another user.
|
||||
/// </summary>
|
||||
HandedOff = 13,
|
||||
|
||||
/// <summary>
|
||||
/// Run status changed.
|
||||
/// </summary>
|
||||
StatusChanged = 14,
|
||||
|
||||
/// <summary>
|
||||
/// OpsMemory context was enriched.
|
||||
/// </summary>
|
||||
OpsMemoryEnriched = 15,
|
||||
|
||||
/// <summary>
|
||||
/// Error occurred.
|
||||
/// </summary>
|
||||
Error = 16,
|
||||
|
||||
/// <summary>
|
||||
/// Run was completed.
|
||||
/// </summary>
|
||||
Completed = 17,
|
||||
|
||||
/// <summary>
|
||||
/// Run was cancelled.
|
||||
/// </summary>
|
||||
Cancelled = 18
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content of a run event (polymorphic).
|
||||
/// </summary>
|
||||
public abstract record RunEventContent;
|
||||
|
||||
/// <summary>
|
||||
/// Content for user/assistant turn events.
|
||||
/// </summary>
|
||||
public sealed record TurnContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the message text.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the role (user/assistant/system).
|
||||
/// </summary>
|
||||
public required string Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets referenced artifacts.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ArtifactIds { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for tool call events.
|
||||
/// </summary>
|
||||
public sealed record ToolCallContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tool name.
|
||||
/// </summary>
|
||||
public required string ToolName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tool input parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the call succeeded.
|
||||
/// </summary>
|
||||
public bool? Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the call duration.
|
||||
/// </summary>
|
||||
public TimeSpan? Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for tool result events.
|
||||
/// </summary>
|
||||
public sealed record ToolResultContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tool name.
|
||||
/// </summary>
|
||||
public required string ToolName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result summary.
|
||||
/// </summary>
|
||||
public string? ResultSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the tool succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message (if failed).
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result artifact ID (if any).
|
||||
/// </summary>
|
||||
public string? ArtifactId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for action proposed events.
|
||||
/// </summary>
|
||||
public sealed record ActionProposedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action type.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action subject (CVE, component, etc.).
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the proposed action rationale.
|
||||
/// </summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether approval is required.
|
||||
/// </summary>
|
||||
public bool RequiresApproval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for action executed events.
|
||||
/// </summary>
|
||||
public sealed record ActionExecutedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action type.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the action succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result summary.
|
||||
/// </summary>
|
||||
public string? ResultSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the OpsMemory record ID (if recorded).
|
||||
/// </summary>
|
||||
public string? OpsMemoryRecordId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the produced artifact IDs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ArtifactIds { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for artifact produced events.
|
||||
/// </summary>
|
||||
public sealed record ArtifactProducedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the artifact ID.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact type.
|
||||
/// </summary>
|
||||
public required string ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact name.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest.
|
||||
/// </summary>
|
||||
public string? ContentDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for status changed events.
|
||||
/// </summary>
|
||||
public sealed record StatusChangedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the previous status.
|
||||
/// </summary>
|
||||
public required RunStatus FromStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the new status.
|
||||
/// </summary>
|
||||
public required RunStatus ToStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason for the change.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for OpsMemory enrichment events.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryEnrichedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the similar decision IDs surfaced.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> SimilarDecisionIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the applicable tactic IDs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> TacticIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of known issues found.
|
||||
/// </summary>
|
||||
public int KnownIssueCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for error events.
|
||||
/// </summary>
|
||||
public sealed record ErrorContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the error code.
|
||||
/// </summary>
|
||||
public required string ErrorCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stack trace (if available).
|
||||
/// </summary>
|
||||
public string? StackTrace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the error is recoverable.
|
||||
/// </summary>
|
||||
public bool IsRecoverable { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an evidence link.
|
||||
/// </summary>
|
||||
public sealed record EvidenceLink
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the evidence URI.
|
||||
/// </summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence type.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest.
|
||||
/// </summary>
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence label/description.
|
||||
/// </summary>
|
||||
public string? Label { get; init; }
|
||||
}
|
||||
723
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/RunService.cs
Normal file
723
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/RunService.cs
Normal file
@@ -0,0 +1,723 @@
|
||||
// <copyright file="RunService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of the run service.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-003
|
||||
/// </summary>
|
||||
internal sealed class RunService : IRunService
|
||||
{
|
||||
private readonly IRunStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RunService> _logger;
|
||||
private readonly IGuidGenerator _guidGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RunService"/> class.
|
||||
/// </summary>
|
||||
public RunService(
|
||||
IRunStore store,
|
||||
TimeProvider timeProvider,
|
||||
IGuidGenerator guidGenerator,
|
||||
ILogger<RunService> logger)
|
||||
{
|
||||
_store = store;
|
||||
_timeProvider = timeProvider;
|
||||
_guidGenerator = guidGenerator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> CreateAsync(CreateRunRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.TenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.InitiatedBy);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Title);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var runId = GenerateRunId(now);
|
||||
|
||||
var createdEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.Created,
|
||||
Timestamp = now,
|
||||
ActorId = request.InitiatedBy,
|
||||
SequenceNumber = 0,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = RunStatus.Created,
|
||||
ToStatus = RunStatus.Created,
|
||||
Reason = "Run created"
|
||||
}
|
||||
};
|
||||
|
||||
var run = new Run
|
||||
{
|
||||
RunId = runId,
|
||||
TenantId = request.TenantId,
|
||||
InitiatedBy = request.InitiatedBy,
|
||||
Title = request.Title,
|
||||
Objective = request.Objective,
|
||||
Status = RunStatus.Created,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
Context = request.Context ?? new RunContext(),
|
||||
Events = [createdEvent],
|
||||
Metadata = request.Metadata ?? ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
await _store.SaveAsync(run, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created run {RunId} for tenant {TenantId} by user {UserId}",
|
||||
runId, request.TenantId, request.InitiatedBy);
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
return await _store.GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunQueryResult> QueryAsync(RunQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var (runs, totalCount) = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new RunQueryResult
|
||||
{
|
||||
Runs = runs,
|
||||
TotalCount = totalCount,
|
||||
HasMore = totalCount > query.Skip + runs.Length
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> AddEventAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
AddRunEventRequest eventRequest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = eventRequest.Type,
|
||||
Timestamp = now,
|
||||
ActorId = eventRequest.ActorId,
|
||||
Content = eventRequest.Content,
|
||||
EvidenceLinks = eventRequest.EvidenceLinks ?? [],
|
||||
SequenceNumber = run.Events.Length,
|
||||
ParentEventId = eventRequest.ParentEventId,
|
||||
Metadata = eventRequest.Metadata ?? ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Events = run.Events.Add(newEvent),
|
||||
UpdatedAt = now,
|
||||
Status = run.Status == RunStatus.Created ? RunStatus.Active : run.Status
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> AddUserTurnAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string message,
|
||||
string userId,
|
||||
ImmutableArray<EvidenceLink>? evidenceLinks = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await AddEventAsync(tenantId, runId, new AddRunEventRequest
|
||||
{
|
||||
Type = RunEventType.UserTurn,
|
||||
ActorId = userId,
|
||||
Content = new TurnContent
|
||||
{
|
||||
Message = message,
|
||||
Role = "user"
|
||||
},
|
||||
EvidenceLinks = evidenceLinks
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> AddAssistantTurnAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string message,
|
||||
ImmutableArray<EvidenceLink>? evidenceLinks = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await AddEventAsync(tenantId, runId, new AddRunEventRequest
|
||||
{
|
||||
Type = RunEventType.AssistantTurn,
|
||||
ActorId = "assistant",
|
||||
Content = new TurnContent
|
||||
{
|
||||
Message = message,
|
||||
Role = "assistant"
|
||||
},
|
||||
EvidenceLinks = evidenceLinks
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> ProposeActionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
ProposeActionRequest action,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
|
||||
return await AddEventAsync(tenantId, runId, new AddRunEventRequest
|
||||
{
|
||||
Type = RunEventType.ActionProposed,
|
||||
ActorId = "assistant",
|
||||
Content = new ActionProposedContent
|
||||
{
|
||||
ActionType = action.ActionType,
|
||||
Subject = action.Subject,
|
||||
Rationale = action.Rationale,
|
||||
RequiresApproval = action.RequiresApproval,
|
||||
Parameters = action.Parameters ?? ImmutableDictionary<string, string>.Empty
|
||||
},
|
||||
EvidenceLinks = action.EvidenceLinks
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> RequestApprovalAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
ImmutableArray<string> approvers,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var approvalEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.ApprovalRequested,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = run.Status,
|
||||
ToStatus = RunStatus.PendingApproval,
|
||||
Reason = reason ?? "Approval requested for proposed actions"
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Status = RunStatus.PendingApproval,
|
||||
UpdatedAt = now,
|
||||
Events = run.Events.Add(approvalEvent),
|
||||
Approval = new ApprovalInfo
|
||||
{
|
||||
Required = true,
|
||||
Approvers = approvers
|
||||
}
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Approval requested for run {RunId} from approvers: {Approvers}",
|
||||
runId, string.Join(", ", approvers));
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> ApproveAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
bool approved,
|
||||
string approverId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (run.Status != RunStatus.PendingApproval)
|
||||
{
|
||||
throw new InvalidOperationException($"Run {runId} is not pending approval. Current status: {run.Status}");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newStatus = approved ? RunStatus.Approved : RunStatus.Rejected;
|
||||
var eventType = approved ? RunEventType.ApprovalGranted : RunEventType.ApprovalDenied;
|
||||
|
||||
var approvalEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = eventType,
|
||||
Timestamp = now,
|
||||
ActorId = approverId,
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = run.Status,
|
||||
ToStatus = newStatus,
|
||||
Reason = reason ?? (approved ? "Approved" : "Rejected")
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Status = newStatus,
|
||||
UpdatedAt = now,
|
||||
Events = run.Events.Add(approvalEvent),
|
||||
Approval = run.Approval! with
|
||||
{
|
||||
Approved = approved,
|
||||
ApprovedBy = approverId,
|
||||
ApprovedAt = now,
|
||||
Reason = reason
|
||||
},
|
||||
CompletedAt = approved ? null : now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Run {RunId} {Action} by {ApproverId}: {Reason}",
|
||||
runId, approved ? "approved" : "rejected", approverId, reason ?? "(no reason)");
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> ExecuteActionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string actionEventId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (run.Status != RunStatus.Approved && run.Status != RunStatus.Active)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot execute actions on run with status: {run.Status}");
|
||||
}
|
||||
|
||||
var actionEvent = run.Events.FirstOrDefault(e => e.EventId == actionEventId);
|
||||
if (actionEvent is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Action event {actionEventId} not found in run {runId}");
|
||||
}
|
||||
|
||||
if (actionEvent.Type != RunEventType.ActionProposed)
|
||||
{
|
||||
throw new InvalidOperationException($"Event {actionEventId} is not an action proposal");
|
||||
}
|
||||
|
||||
// In a real implementation, this would execute the action
|
||||
// For now, we just record that it was executed
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var actionContent = actionEvent.Content as ActionProposedContent;
|
||||
|
||||
var executedEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.ActionExecuted,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
ParentEventId = actionEventId,
|
||||
Content = new ActionExecutedContent
|
||||
{
|
||||
ActionType = actionContent?.ActionType ?? "unknown",
|
||||
Success = true,
|
||||
ResultSummary = $"Action {actionContent?.ActionType} executed successfully"
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Events = run.Events.Add(executedEvent),
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Executed action {ActionType} in run {RunId}",
|
||||
actionContent?.ActionType ?? "unknown", runId);
|
||||
|
||||
return executedEvent;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> AddArtifactAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunArtifact artifact,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var artifactEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.ArtifactProduced,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new ArtifactProducedContent
|
||||
{
|
||||
ArtifactId = artifact.ArtifactId,
|
||||
ArtifactType = artifact.Type.ToString(),
|
||||
Name = artifact.Name,
|
||||
ContentDigest = artifact.ContentDigest
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Artifacts = run.Artifacts.Add(artifact),
|
||||
Events = run.Events.Add(artifactEvent),
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> AttachEvidencePackAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
EvidencePackReference evidencePack,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var evidenceEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.EvidenceAttached,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
EvidencePacks = run.EvidencePacks.Add(evidencePack),
|
||||
Events = run.Events.Add(evidenceEvent),
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> CompleteAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? summary = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var completedEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.Completed,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = run.Status,
|
||||
ToStatus = RunStatus.Completed,
|
||||
Reason = summary ?? "Run completed"
|
||||
}
|
||||
};
|
||||
|
||||
var contentDigest = ComputeContentDigest(run);
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Status = RunStatus.Completed,
|
||||
CompletedAt = now,
|
||||
UpdatedAt = now,
|
||||
Events = run.Events.Add(completedEvent),
|
||||
ContentDigest = contentDigest
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Completed run {RunId} with digest {Digest}", runId, contentDigest);
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> CancelAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var cancelledEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.Cancelled,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = run.Status,
|
||||
ToStatus = RunStatus.Cancelled,
|
||||
Reason = reason ?? "Run cancelled"
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Status = RunStatus.Cancelled,
|
||||
CompletedAt = now,
|
||||
UpdatedAt = now,
|
||||
Events = run.Events.Add(cancelledEvent)
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Cancelled run {RunId}: {Reason}", runId, reason ?? "(no reason)");
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> HandOffAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string toUserId,
|
||||
string? message = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var handoffEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.HandedOff,
|
||||
Timestamp = now,
|
||||
ActorId = run.InitiatedBy,
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new TurnContent
|
||||
{
|
||||
Message = message ?? $"Handed off to {toUserId}",
|
||||
Role = "system"
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["to_user"] = toUserId
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Events = run.Events.Add(handoffEvent),
|
||||
UpdatedAt = now,
|
||||
Metadata = run.Metadata.SetItem("current_owner", toUserId)
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Run {RunId} handed off to {UserId}", runId, toUserId);
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> AttestAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (run.Status != RunStatus.Completed)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot attest run that is not completed. Current status: {run.Status}");
|
||||
}
|
||||
|
||||
if (run.Attestation is not null)
|
||||
{
|
||||
throw new InvalidOperationException($"Run {runId} is already attested");
|
||||
}
|
||||
|
||||
var contentDigest = run.ContentDigest ?? ComputeContentDigest(run);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// In a real implementation, this would sign the attestation
|
||||
var attestation = new RunAttestation
|
||||
{
|
||||
AttestationId = $"att-{_guidGenerator.NewGuid():N}",
|
||||
ContentDigest = contentDigest,
|
||||
StatementUri = $"stellaops://runs/{runId}/attestation",
|
||||
Signature = "placeholder-signature", // Would be DSSE signature
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Attestation = attestation,
|
||||
ContentDigest = contentDigest,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Attested run {RunId} with attestation {AttestationId}", runId, attestation.AttestationId);
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<RunEvent>> GetTimelineAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return run.Events
|
||||
.OrderBy(e => e.SequenceNumber)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<Run> GetRequiredRunAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var run = await _store.GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Run {runId} not found");
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
private static void ValidateCanModify(Run run)
|
||||
{
|
||||
if (run.Status is RunStatus.Completed or RunStatus.Cancelled or RunStatus.Failed or RunStatus.Expired)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot modify run with status: {run.Status}");
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateRunId(DateTimeOffset timestamp)
|
||||
{
|
||||
var ts = timestamp.ToString("yyyyMMddHHmmss", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var random = _guidGenerator.NewGuid().ToString("N")[..8];
|
||||
return $"run-{ts}-{random}";
|
||||
}
|
||||
|
||||
private string GenerateEventId()
|
||||
{
|
||||
return $"evt-{_guidGenerator.NewGuid():N}";
|
||||
}
|
||||
|
||||
private static string ComputeContentDigest(Run run)
|
||||
{
|
||||
var content = new
|
||||
{
|
||||
run.RunId,
|
||||
run.TenantId,
|
||||
run.Title,
|
||||
run.CreatedAt,
|
||||
EventCount = run.Events.Length,
|
||||
Events = run.Events.Select(e => new { e.EventId, e.Type, e.Timestamp }).ToArray(),
|
||||
Artifacts = run.Artifacts.Select(a => new { a.ArtifactId, a.ContentDigest }).ToArray()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(content, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for generating GUIDs (injectable for testing).
|
||||
/// </summary>
|
||||
public interface IGuidGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a new GUID.
|
||||
/// </summary>
|
||||
Guid NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default GUID generator using Guid.NewGuid().
|
||||
/// </summary>
|
||||
public sealed class DefaultGuidGenerator : IGuidGenerator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Guid NewGuid() => Guid.NewGuid();
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\OpsMemory\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
|
||||
<!-- Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-006) -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
// <copyright file="ActionExecutorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ActionExecutor.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ActionExecutorTests
|
||||
{
|
||||
private readonly Mock<IActionPolicyGate> _policyGateMock;
|
||||
private readonly ActionRegistry _actionRegistry;
|
||||
private readonly Mock<IIdempotencyHandler> _idempotencyMock;
|
||||
private readonly Mock<IApprovalWorkflowAdapter> _approvalMock;
|
||||
private readonly Mock<IActionAuditLedger> _auditLedgerMock;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly FakeGuidGenerator _guidGenerator;
|
||||
private readonly ActionExecutor _sut;
|
||||
|
||||
public ActionExecutorTests()
|
||||
{
|
||||
_policyGateMock = new Mock<IActionPolicyGate>();
|
||||
_actionRegistry = new ActionRegistry();
|
||||
_idempotencyMock = new Mock<IIdempotencyHandler>();
|
||||
_approvalMock = new Mock<IApprovalWorkflowAdapter>();
|
||||
_auditLedgerMock = new Mock<IActionAuditLedger>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
_guidGenerator = new FakeGuidGenerator();
|
||||
|
||||
var options = Options.Create(new ActionExecutorOptions
|
||||
{
|
||||
EnableIdempotency = true,
|
||||
EnableAuditLogging = true
|
||||
});
|
||||
|
||||
_sut = new ActionExecutor(
|
||||
_policyGateMock.Object,
|
||||
_actionRegistry,
|
||||
_idempotencyMock.Object,
|
||||
_approvalMock.Object,
|
||||
_auditLedgerMock.Object,
|
||||
_timeProvider,
|
||||
_guidGenerator,
|
||||
options,
|
||||
NullLogger<ActionExecutor>.Instance);
|
||||
|
||||
// Default idempotency behavior: not executed
|
||||
_idempotencyMock
|
||||
.Setup(x => x.CheckAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(IdempotencyResult.NotExecuted);
|
||||
_idempotencyMock
|
||||
.Setup(x => x.GenerateKey(It.IsAny<ActionProposal>(), It.IsAny<ActionContext>()))
|
||||
.Returns("test-idempotency-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ExecutesImmediately_WhenPolicyAllows()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("defer");
|
||||
var context = CreateContext();
|
||||
var decision = CreateAllowDecision();
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Success);
|
||||
result.ExecutionId.Should().NotBeNullOrEmpty();
|
||||
|
||||
_auditLedgerMock.Verify(
|
||||
x => x.RecordAsync(
|
||||
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.Executed),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReturnsPendingApproval_WhenApprovalRequired()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve");
|
||||
var context = CreateContext();
|
||||
var decision = CreateApprovalRequiredDecision();
|
||||
|
||||
_approvalMock
|
||||
.Setup(x => x.CreateApprovalRequestAsync(
|
||||
It.IsAny<ActionProposal>(),
|
||||
It.IsAny<ActionPolicyDecision>(),
|
||||
It.IsAny<ActionContext>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ApprovalRequest
|
||||
{
|
||||
RequestId = "approval-123",
|
||||
WorkflowId = "high-risk-approval",
|
||||
TenantId = context.TenantId,
|
||||
RequesterId = context.UserId,
|
||||
RequiredApprovers = ImmutableArray.Create(
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }),
|
||||
Timeout = TimeSpan.FromHours(4),
|
||||
Payload = new ApprovalPayload
|
||||
{
|
||||
ActionType = proposal.ActionType,
|
||||
ActionLabel = proposal.Label,
|
||||
Parameters = proposal.Parameters
|
||||
},
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.PendingApproval);
|
||||
result.OutputData.Should().ContainKey("approvalRequestId");
|
||||
result.OutputData["approvalRequestId"].Should().Be("approval-123");
|
||||
|
||||
_auditLedgerMock.Verify(
|
||||
x => x.RecordAsync(
|
||||
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.ApprovalRequested),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReturnsFailed_WhenPolicyDenies()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("quarantine");
|
||||
var context = CreateContext();
|
||||
var decision = CreateDenyDecision("Missing required role");
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Failed);
|
||||
result.Error.Should().NotBeNull();
|
||||
result.Error!.Code.Should().Be("POLICY_DENIED");
|
||||
|
||||
_auditLedgerMock.Verify(
|
||||
x => x.RecordAsync(
|
||||
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.DeniedByPolicy),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SkipsExecution_WhenIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("defer");
|
||||
var context = CreateContext();
|
||||
var decision = CreateAllowDecision();
|
||||
var previousResult = new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = "previous-exec-123",
|
||||
Outcome = ActionExecutionOutcome.Success,
|
||||
Message = "Previously executed",
|
||||
StartedAt = _timeProvider.GetUtcNow().AddHours(-1),
|
||||
CompletedAt = _timeProvider.GetUtcNow().AddHours(-1),
|
||||
CanRollback = false
|
||||
};
|
||||
|
||||
_idempotencyMock
|
||||
.Setup(x => x.CheckAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new IdempotencyResult
|
||||
{
|
||||
AlreadyExecuted = true,
|
||||
PreviousResult = previousResult,
|
||||
ExecutedAt = _timeProvider.GetUtcNow().AddHours(-1)
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeSameAs(previousResult);
|
||||
|
||||
_auditLedgerMock.Verify(
|
||||
x => x.RecordAsync(
|
||||
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.IdempotentSkipped),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RecordsIdempotency_OnSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("defer");
|
||||
var context = CreateContext();
|
||||
var decision = CreateAllowDecision();
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Success);
|
||||
|
||||
_idempotencyMock.Verify(
|
||||
x => x.RecordExecutionAsync(
|
||||
It.IsAny<string>(),
|
||||
It.Is<ActionExecutionResult>(r => r.Outcome == ActionExecutionOutcome.Success),
|
||||
It.IsAny<ActionContext>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_IncludesOverrideInfo_ForDenyWithOverride()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve");
|
||||
var context = CreateContext();
|
||||
var decision = new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.DenyWithOverride,
|
||||
PolicyId = "high-risk-check",
|
||||
Reason = "Additional approval needed"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Failed);
|
||||
result.Error!.Code.Should().Be("POLICY_DENIED_OVERRIDE_AVAILABLE");
|
||||
result.OutputData.Should().ContainKey("overrideAvailable");
|
||||
result.OutputData["overrideAvailable"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSupportedActionTypes_ReturnsAllActions()
|
||||
{
|
||||
// Act
|
||||
var actionTypes = _sut.GetSupportedActionTypes();
|
||||
|
||||
// Assert
|
||||
actionTypes.Should().NotBeEmpty();
|
||||
actionTypes.Should().Contain(a => a.Type == "approve");
|
||||
actionTypes.Should().Contain(a => a.Type == "quarantine");
|
||||
actionTypes.Should().Contain(a => a.Type == "defer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSupportedActionTypes_IncludesMetadata()
|
||||
{
|
||||
// Act
|
||||
var actionTypes = _sut.GetSupportedActionTypes();
|
||||
var approveAction = actionTypes.Single(a => a.Type == "approve");
|
||||
|
||||
// Assert
|
||||
approveAction.DisplayName.Should().Be("Approve Risk");
|
||||
approveAction.RequiredPermission.Should().NotBeNullOrEmpty();
|
||||
approveAction.SupportsRollback.Should().BeTrue();
|
||||
approveAction.IsDestructive.Should().BeTrue(); // High risk
|
||||
}
|
||||
|
||||
private static ActionProposal CreateProposal(string actionType)
|
||||
{
|
||||
var parameters = actionType switch
|
||||
{
|
||||
"approve" => new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["justification"] = "Risk accepted"
|
||||
},
|
||||
"quarantine" => new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability"
|
||||
},
|
||||
"defer" => new Dictionary<string, string>
|
||||
{
|
||||
["finding_id"] = "finding-123",
|
||||
["defer_days"] = "30",
|
||||
["reason"] = "Scheduled for next sprint"
|
||||
},
|
||||
_ => new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
return new ActionProposal
|
||||
{
|
||||
ProposalId = Guid.NewGuid().ToString(),
|
||||
ActionType = actionType,
|
||||
Label = $"Test {actionType}",
|
||||
Parameters = parameters.ToImmutableDictionary(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionContext CreateContext()
|
||||
{
|
||||
return new ActionContext
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
UserId = "test-user",
|
||||
UserRoles = ImmutableArray.Create("security-analyst"),
|
||||
Environment = "development"
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionPolicyDecision CreateAllowDecision()
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "test-policy",
|
||||
Reason = "Allowed"
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionPolicyDecision CreateApprovalRequiredDecision()
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.AllowWithApproval,
|
||||
PolicyId = "high-risk-approval",
|
||||
Reason = "High-risk action requires approval",
|
||||
ApprovalWorkflowId = "action-approval-high",
|
||||
RequiredApprovers = ImmutableArray.Create(
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(4)
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionPolicyDecision CreateDenyDecision(string reason)
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Deny,
|
||||
PolicyId = "role-check",
|
||||
Reason = reason
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake GUID generator for deterministic testing.
|
||||
/// </summary>
|
||||
internal sealed class FakeGuidGenerator : IGuidGenerator
|
||||
{
|
||||
private int _counter;
|
||||
|
||||
public Guid NewGuid() => new($"00000000-0000-0000-0000-{_counter++:D12}");
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// <copyright file="ActionPolicyGateTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ActionPolicyGate.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ActionPolicyGateTests
|
||||
{
|
||||
private readonly ActionPolicyGate _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public ActionPolicyGateTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new ActionPolicyOptions
|
||||
{
|
||||
DefaultTimeoutHours = 4,
|
||||
CriticalTimeoutHours = 24
|
||||
});
|
||||
|
||||
_sut = new ActionPolicyGate(
|
||||
new ActionRegistry(),
|
||||
_timeProvider,
|
||||
options,
|
||||
NullLogger<ActionPolicyGate>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AllowsLowRiskAction_WithAnyRole()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("defer", new Dictionary<string, string>
|
||||
{
|
||||
["finding_id"] = "finding-123",
|
||||
["defer_days"] = "30",
|
||||
["reason"] = "Scheduled for next sprint"
|
||||
});
|
||||
var context = CreateContext("security-analyst");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Allow);
|
||||
result.PolicyId.Should().Be("low-risk-auto-allow");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiresApproval_ForMediumRiskWithoutElevatedRole()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("create_vex", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["status"] = "not_affected",
|
||||
["justification"] = "Component not in use"
|
||||
});
|
||||
var context = CreateContext("security-analyst");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "team-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AllowsMediumRiskAction_ForSecurityLead()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("create_vex", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["status"] = "not_affected",
|
||||
["justification"] = "Component not in use"
|
||||
});
|
||||
var context = CreateContext("security-lead");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiresApproval_ForHighRiskWithoutAdminRole()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["justification"] = "Risk accepted"
|
||||
});
|
||||
var context = CreateContext("security-analyst");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AllowsHighRiskAction_ForAdmin()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["justification"] = "Risk accepted"
|
||||
});
|
||||
var context = CreateContext("admin");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiresMultiPartyApproval_ForCriticalActionInProduction()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability"
|
||||
});
|
||||
var context = CreateContext("security-lead", environment: "production");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
result.RequiredApprovers.Should().HaveCountGreaterThan(1);
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "ciso");
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiresSingleApproval_ForCriticalActionInNonProduction()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability"
|
||||
});
|
||||
var context = CreateContext("security-lead", environment: "staging");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
result.RequiredApprovers.Should().HaveCount(1);
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DeniesAction_ForMissingRole()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability"
|
||||
});
|
||||
var context = CreateContext("viewer"); // Not a security-lead
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Deny);
|
||||
result.Reason.Should().Contain("role");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DeniesAction_ForUnknownActionType()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("unknown-action", new Dictionary<string, string>());
|
||||
var context = CreateContext("admin");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Deny);
|
||||
result.Reason.Should().Contain("Unknown action type");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DeniesAction_ForInvalidParameters()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "invalid-cve"
|
||||
// Missing required justification
|
||||
});
|
||||
var context = CreateContext("admin");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Deny);
|
||||
result.Reason.Should().Contain("Invalid parameters");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplainAsync_ReturnsSummary_ForAllowDecision()
|
||||
{
|
||||
// Arrange
|
||||
var decision = new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "low-risk-auto-allow",
|
||||
Reason = "Low-risk action allowed automatically"
|
||||
};
|
||||
|
||||
// Act
|
||||
var explanation = await _sut.ExplainAsync(decision, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
explanation.Summary.Should().Contain("allowed");
|
||||
explanation.Details.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplainAsync_IncludesApprovers_ForApprovalDecision()
|
||||
{
|
||||
// Arrange
|
||||
var decision = new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.AllowWithApproval,
|
||||
PolicyId = "high-risk-approval",
|
||||
Reason = "High-risk action requires approval",
|
||||
RequiredApprovers = ImmutableArray.Create(
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var explanation = await _sut.ExplainAsync(decision, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
explanation.Summary.Should().Contain("approval");
|
||||
explanation.SuggestedActions.Should().Contain(a => a.Contains("security-lead"));
|
||||
}
|
||||
|
||||
private static ActionProposal CreateProposal(string actionType, Dictionary<string, string> parameters)
|
||||
{
|
||||
return new ActionProposal
|
||||
{
|
||||
ProposalId = Guid.NewGuid().ToString(),
|
||||
ActionType = actionType,
|
||||
Label = $"Test {actionType}",
|
||||
Parameters = parameters.ToImmutableDictionary(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionContext CreateContext(string role, string environment = "development")
|
||||
{
|
||||
return new ActionContext
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
UserId = "test-user",
|
||||
UserRoles = ImmutableArray.Create(role),
|
||||
Environment = environment
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake TimeProvider for testing.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration);
|
||||
|
||||
public void SetUtcNow(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// <copyright file="ActionRegistryTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ActionRegistry.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ActionRegistryTests
|
||||
{
|
||||
private readonly ActionRegistry _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void GetAction_ReturnsDefinition_ForApproveAction()
|
||||
{
|
||||
// Act
|
||||
var action = _sut.GetAction("approve");
|
||||
|
||||
// Assert
|
||||
action.Should().NotBeNull();
|
||||
action!.ActionType.Should().Be("approve");
|
||||
action.DisplayName.Should().Be("Approve Risk");
|
||||
action.RiskLevel.Should().Be(ActionRiskLevel.High);
|
||||
action.IsIdempotent.Should().BeTrue();
|
||||
action.HasCompensation.Should().BeTrue();
|
||||
action.CompensationActionType.Should().Be("revoke_approval");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAction_ReturnsDefinition_ForQuarantineAction()
|
||||
{
|
||||
// Act
|
||||
var action = _sut.GetAction("quarantine");
|
||||
|
||||
// Assert
|
||||
action.Should().NotBeNull();
|
||||
action!.ActionType.Should().Be("quarantine");
|
||||
action.RiskLevel.Should().Be(ActionRiskLevel.Critical);
|
||||
action.RequiredRole.Should().Be("security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAction_ReturnsNull_ForUnknownAction()
|
||||
{
|
||||
// Act
|
||||
var action = _sut.GetAction("unknown-action");
|
||||
|
||||
// Assert
|
||||
action.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAction_IsCaseInsensitive()
|
||||
{
|
||||
// Act
|
||||
var lower = _sut.GetAction("approve");
|
||||
var upper = _sut.GetAction("APPROVE");
|
||||
var mixed = _sut.GetAction("Approve");
|
||||
|
||||
// Assert
|
||||
lower.Should().NotBeNull();
|
||||
upper.Should().NotBeNull();
|
||||
mixed.Should().NotBeNull();
|
||||
lower!.ActionType.Should().Be(upper!.ActionType);
|
||||
lower.ActionType.Should().Be(mixed!.ActionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllActions_ReturnsAllBuiltInActions()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetAllActions();
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().Contain(a => a.ActionType == "approve");
|
||||
actions.Should().Contain(a => a.ActionType == "quarantine");
|
||||
actions.Should().Contain(a => a.ActionType == "defer");
|
||||
actions.Should().Contain(a => a.ActionType == "create_vex");
|
||||
actions.Should().Contain(a => a.ActionType == "generate_manifest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActionsByRiskLevel_ReturnsCorrectActions_ForLowRisk()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetActionsByRiskLevel(ActionRiskLevel.Low);
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().AllSatisfy(a => a.RiskLevel.Should().Be(ActionRiskLevel.Low));
|
||||
actions.Should().Contain(a => a.ActionType == "defer");
|
||||
actions.Should().Contain(a => a.ActionType == "generate_manifest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActionsByRiskLevel_ReturnsCorrectActions_ForCriticalRisk()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetActionsByRiskLevel(ActionRiskLevel.Critical);
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().AllSatisfy(a => a.RiskLevel.Should().Be(ActionRiskLevel.Critical));
|
||||
actions.Should().Contain(a => a.ActionType == "quarantine");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActionsByTag_ReturnsCorrectActions_ForCveTag()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetActionsByTag("cve");
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().Contain(a => a.ActionType == "approve");
|
||||
actions.Should().Contain(a => a.ActionType == "revoke_approval");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActionsByTag_ReturnsCorrectActions_ForVexTag()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetActionsByTag("vex");
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().Contain(a => a.ActionType == "create_vex");
|
||||
actions.Should().Contain(a => a.ActionType == "approve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_ReturnsSuccess_WithValidParameters()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["justification"] = "Risk accepted for internal service"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("approve", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_ReturnsFailure_ForMissingRequiredParameter()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
// Missing required "justification" parameter
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("approve", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("justification"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_ReturnsFailure_ForInvalidCveIdPattern()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "invalid-cve",
|
||||
["justification"] = "Test"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("approve", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("cve_id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_ReturnsFailure_ForUnknownActionType()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("unknown-action", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Unknown action type"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_AcceptsValidImageDigest()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability",
|
||||
["cve_ids"] = "CVE-2023-44487"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("quarantine", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_RejectsInvalidImageDigest()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "invalid-digest",
|
||||
["reason"] = "Critical vulnerability"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("quarantine", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("image_digest"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// <copyright file="IdempotencyHandlerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for IdempotencyHandler.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class IdempotencyHandlerTests
|
||||
{
|
||||
private readonly IdempotencyHandler _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public IdempotencyHandlerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new IdempotencyOptions { TtlDays = 30 });
|
||||
|
||||
_sut = new IdempotencyHandler(
|
||||
_timeProvider,
|
||||
options,
|
||||
NullLogger<IdempotencyHandler>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal, context);
|
||||
var key2 = _sut.GenerateKey(proposal, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().Be(key2);
|
||||
key1.Should().HaveLength(64); // SHA-256 hex length
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_DiffersByTenant()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context1 = CreateContext("tenant-1");
|
||||
var context2 = CreateContext("tenant-2");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal, context1);
|
||||
var key2 = _sut.GenerateKey(proposal, context2);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_DiffersByActionType()
|
||||
{
|
||||
// Arrange
|
||||
var proposal1 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var proposal2 = CreateProposal("defer", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal1, context);
|
||||
var key2 = _sut.GenerateKey(proposal2, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_DiffersByCveId()
|
||||
{
|
||||
// Arrange
|
||||
var proposal1 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var proposal2 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2024-12345"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal1, context);
|
||||
var key2 = _sut.GenerateKey(proposal2, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_DiffersByImageDigest()
|
||||
{
|
||||
// Arrange
|
||||
var proposal1 = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:aaaa"
|
||||
});
|
||||
var proposal2 = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:bbbb"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal1, context);
|
||||
var key2 = _sut.GenerateKey(proposal2, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_IncludesExplicitIdempotencyKey()
|
||||
{
|
||||
// Arrange
|
||||
var proposal1 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
}, idempotencyKey: "key-1");
|
||||
var proposal2 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
}, idempotencyKey: "key-2");
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal1, context);
|
||||
var key2 = _sut.GenerateKey(proposal2, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsNotExecuted_WhenNoRecord()
|
||||
{
|
||||
// Arrange
|
||||
var key = "non-existent-key";
|
||||
|
||||
// Act
|
||||
var result = await _sut.CheckAsync(key, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeFalse();
|
||||
result.PreviousResult.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsExecuted_WhenRecorded()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
var key = _sut.GenerateKey(proposal, context);
|
||||
var executionResult = CreateExecutionResult("exec-123");
|
||||
|
||||
await _sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var result = await _sut.CheckAsync(key, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeTrue();
|
||||
result.PreviousResult.Should().NotBeNull();
|
||||
result.PreviousResult!.ExecutionId.Should().Be("exec-123");
|
||||
result.ExecutedBy.Should().Be("test-user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsNotExecuted_WhenExpired()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
var key = _sut.GenerateKey(proposal, context);
|
||||
var executionResult = CreateExecutionResult("exec-123");
|
||||
|
||||
await _sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None);
|
||||
|
||||
// Advance past TTL
|
||||
_timeProvider.Advance(TimeSpan.FromDays(31));
|
||||
|
||||
// Act
|
||||
var result = await _sut.CheckAsync(key, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_DeletesRecord()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
var key = _sut.GenerateKey(proposal, context);
|
||||
var executionResult = CreateExecutionResult("exec-123");
|
||||
|
||||
await _sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await _sut.RemoveAsync(key, CancellationToken.None);
|
||||
var result = await _sut.CheckAsync(key, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanupExpired_RemovesExpiredRecords()
|
||||
{
|
||||
// Arrange - synchronous setup with async results
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
var key = _sut.GenerateKey(proposal, context);
|
||||
var executionResult = CreateExecutionResult("exec-123");
|
||||
|
||||
_sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None).Wait();
|
||||
|
||||
// Advance past TTL
|
||||
_timeProvider.Advance(TimeSpan.FromDays(31));
|
||||
|
||||
// Act
|
||||
_sut.CleanupExpired();
|
||||
var result = _sut.CheckAsync(key, CancellationToken.None).Result;
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static ActionProposal CreateProposal(
|
||||
string actionType,
|
||||
Dictionary<string, string> parameters,
|
||||
string? idempotencyKey = null)
|
||||
{
|
||||
return new ActionProposal
|
||||
{
|
||||
ProposalId = Guid.NewGuid().ToString(),
|
||||
ActionType = actionType,
|
||||
Label = $"Test {actionType}",
|
||||
Parameters = parameters.ToImmutableDictionary(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
IdempotencyKey = idempotencyKey
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionContext CreateContext(string tenantId)
|
||||
{
|
||||
return new ActionContext
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = "test-user",
|
||||
UserRoles = ImmutableArray.Create("security-analyst"),
|
||||
Environment = "development"
|
||||
};
|
||||
}
|
||||
|
||||
private ActionExecutionResult CreateExecutionResult(string executionId)
|
||||
{
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Success,
|
||||
Message = "Success",
|
||||
StartedAt = _timeProvider.GetUtcNow(),
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// <copyright file="InMemoryRunStoreTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="InMemoryRunStore"/>.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class InMemoryRunStoreTests
|
||||
{
|
||||
private readonly InMemoryRunStore _store = new();
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_And_GetAsync_RoundTrip()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun("run-1", "tenant-1");
|
||||
|
||||
// Act
|
||||
await _store.SaveAsync(run);
|
||||
var retrieved = await _store.GetAsync("tenant-1", "run-1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(run.RunId, retrieved.RunId);
|
||||
Assert.Equal(run.TenantId, retrieved.TenantId);
|
||||
Assert.Equal(run.Title, retrieved.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_DifferentTenant_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun("run-1", "tenant-1");
|
||||
await _store.SaveAsync(run);
|
||||
|
||||
// Act
|
||||
var retrieved = await _store.GetAsync("tenant-2", "run-1");
|
||||
|
||||
// Assert
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ExistingRun_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun("run-1", "tenant-1");
|
||||
await _store.SaveAsync(run);
|
||||
|
||||
// Act
|
||||
var deleted = await _store.DeleteAsync("tenant-1", "run-1");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
Assert.Null(await _store.GetAsync("tenant-1", "run-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NonExistentRun_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var deleted = await _store.DeleteAsync("tenant-1", "non-existent");
|
||||
|
||||
// Assert
|
||||
Assert.False(deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByTenant()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1"));
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1"));
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-2"));
|
||||
|
||||
// Act
|
||||
var (runs, count) = await _store.QueryAsync(new RunQuery { TenantId = "tenant-1" });
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
Assert.All(runs, r => Assert.Equal("tenant-1", r.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1") with { Status = RunStatus.Active });
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1") with { Status = RunStatus.Completed });
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1") with { Status = RunStatus.Active });
|
||||
|
||||
// Act
|
||||
var (runs, count) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Statuses = [RunStatus.Active]
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
Assert.All(runs, r => Assert.Equal(RunStatus.Active, r.Status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByInitiator()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1", initiatedBy: "user-1"));
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1", initiatedBy: "user-2"));
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1", initiatedBy: "user-1"));
|
||||
|
||||
// Act
|
||||
var (runs, count) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
Assert.All(runs, r => Assert.Equal("user-1", r.InitiatedBy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByCveId()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1", cveId: "CVE-2024-1111"));
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1", cveId: "CVE-2024-2222"));
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1", cveId: "CVE-2024-1111"));
|
||||
|
||||
// Act
|
||||
var (runs, count) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
CveId = "CVE-2024-1111"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
Assert.All(runs, r => Assert.Equal("CVE-2024-1111", r.Context.FocusedCveId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_Pagination_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _store.SaveAsync(CreateTestRun($"run-{i}", "tenant-1"));
|
||||
}
|
||||
|
||||
// Act
|
||||
var (page1, total1) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Skip = 0,
|
||||
Take = 3
|
||||
});
|
||||
|
||||
var (page2, total2) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Skip = 3,
|
||||
Take = 3
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(10, total1);
|
||||
Assert.Equal(10, total2);
|
||||
Assert.Equal(3, page1.Length);
|
||||
Assert.Equal(3, page2.Length);
|
||||
Assert.True(page1.All(r => !page2.Contains(r)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByStatusAsync_ReturnsCorrectRuns()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1") with { Status = RunStatus.Active });
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1") with { Status = RunStatus.PendingApproval });
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1") with { Status = RunStatus.Completed });
|
||||
|
||||
// Act
|
||||
var runs = await _store.GetByStatusAsync(
|
||||
"tenant-1", [RunStatus.Active, RunStatus.PendingApproval]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, runs.Length);
|
||||
Assert.DoesNotContain(runs, r => r.Status == RunStatus.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveForUserAsync_ReturnsUserRuns()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1", initiatedBy: "user-1") with { Status = RunStatus.Active });
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1", initiatedBy: "user-2") with { Status = RunStatus.Active });
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1", initiatedBy: "user-1") with { Status = RunStatus.Completed });
|
||||
|
||||
// Act
|
||||
var runs = await _store.GetActiveForUserAsync("tenant-1", "user-1");
|
||||
|
||||
// Assert
|
||||
Assert.Single(runs);
|
||||
Assert.Equal("user-1", runs[0].InitiatedBy);
|
||||
Assert.Equal(RunStatus.Active, runs[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingApprovalAsync_ReturnsAllPending()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1") with
|
||||
{
|
||||
Status = RunStatus.PendingApproval,
|
||||
Approval = new ApprovalInfo { Required = true, Approvers = ["approver-1"] }
|
||||
});
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1") with
|
||||
{
|
||||
Status = RunStatus.PendingApproval,
|
||||
Approval = new ApprovalInfo { Required = true, Approvers = ["approver-2"] }
|
||||
});
|
||||
|
||||
// Act - no filter
|
||||
var allPending = await _store.GetPendingApprovalAsync("tenant-1");
|
||||
|
||||
// Act - with filter
|
||||
var approver1Pending = await _store.GetPendingApprovalAsync("tenant-1", "approver-1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, allPending.Length);
|
||||
Assert.Single(approver1Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_UpdatesStatus()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun("run-1", "tenant-1") with { Status = RunStatus.Active };
|
||||
await _store.SaveAsync(run);
|
||||
|
||||
// Act
|
||||
var updated = await _store.UpdateStatusAsync("tenant-1", "run-1", RunStatus.Completed);
|
||||
|
||||
// Assert
|
||||
Assert.True(updated);
|
||||
var retrieved = await _store.GetAsync("tenant-1", "run-1");
|
||||
Assert.Equal(RunStatus.Completed, retrieved!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_NonExistent_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var updated = await _store.UpdateStatusAsync("tenant-1", "non-existent", RunStatus.Active);
|
||||
|
||||
// Assert
|
||||
Assert.False(updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_RemovesAllRuns()
|
||||
{
|
||||
// Arrange
|
||||
_store.SaveAsync(CreateTestRun("run-1", "tenant-1")).Wait();
|
||||
_store.SaveAsync(CreateTestRun("run-2", "tenant-1")).Wait();
|
||||
|
||||
// Act
|
||||
_store.Clear();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, _store.Count);
|
||||
}
|
||||
|
||||
private static Run CreateTestRun(
|
||||
string runId,
|
||||
string tenantId,
|
||||
string initiatedBy = "test-user",
|
||||
string? cveId = null) => new()
|
||||
{
|
||||
RunId = runId,
|
||||
TenantId = tenantId,
|
||||
InitiatedBy = initiatedBy,
|
||||
Title = $"Test Run {runId}",
|
||||
Status = RunStatus.Created,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Context = new RunContext
|
||||
{
|
||||
FocusedCveId = cveId
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
// <copyright file="RunServiceIntegrationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for RunService covering full lifecycle scenarios.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class RunServiceIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly InMemoryRunStore _store = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly DeterministicGuidGenerator _guidGenerator = new();
|
||||
private readonly RunService _service;
|
||||
|
||||
private readonly string _testTenantId = "test-tenant";
|
||||
|
||||
public RunServiceIntegrationTests()
|
||||
{
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 10, 10, 0, 0, TimeSpan.Zero));
|
||||
_service = new RunService(
|
||||
_store,
|
||||
_timeProvider,
|
||||
_guidGenerator,
|
||||
NullLogger<RunService>.Instance);
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_store.Clear();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullConversationToAttestationFlow_Succeeds()
|
||||
{
|
||||
// Phase 1: Create Run
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "alice@example.com",
|
||||
Title = "CVE-2024-1234 Investigation",
|
||||
Objective = "Determine if vulnerable code path is reachable",
|
||||
Context = new RunContext
|
||||
{
|
||||
FocusedCveId = "CVE-2024-1234",
|
||||
FocusedComponent = "pkg:npm/express@4.17.1",
|
||||
Tags = ["critical", "production"]
|
||||
}
|
||||
});
|
||||
|
||||
run.Should().NotBeNull();
|
||||
run.Status.Should().Be(RunStatus.Created);
|
||||
run.Events.Should().HaveCount(1);
|
||||
run.Events[0].Type.Should().Be(RunEventType.Created);
|
||||
|
||||
// Phase 2: Conversation (User turn -> Assistant turn -> User turn -> Assistant turn)
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var userTurn1 = await _service.AddUserTurnAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Can you analyze CVE-2024-1234 and tell me if we're affected?",
|
||||
"alice@example.com",
|
||||
[new EvidenceLink { Uri = "sbom:digest/sha256:abc123", Type = "sbom", Label = "Application SBOM" }]);
|
||||
|
||||
userTurn1.Type.Should().Be(RunEventType.UserTurn);
|
||||
userTurn1.EvidenceLinks.Should().HaveCount(1);
|
||||
|
||||
// Run should be active now
|
||||
var activeRun = await _service.GetAsync(_testTenantId, run.RunId);
|
||||
activeRun!.Status.Should().Be(RunStatus.Active);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(2));
|
||||
var assistantTurn1 = await _service.AddAssistantTurnAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Based on the SBOM analysis, express@4.17.1 is present. I found that the vulnerable function `parseQuery` is not called in your codebase.",
|
||||
[new EvidenceLink { Uri = "reach:analysis/CVE-2024-1234", Type = "reachability" }]);
|
||||
|
||||
assistantTurn1.Type.Should().Be(RunEventType.AssistantTurn);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Great, can you propose a VEX statement for this?",
|
||||
"alice@example.com");
|
||||
|
||||
// Phase 3: Action Proposal
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(2));
|
||||
var proposalEvent = await _service.ProposeActionAsync(_testTenantId, run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
Subject = "CVE-2024-1234",
|
||||
Rationale = "Vulnerable function parseQuery is not reachable in application code paths",
|
||||
RequiresApproval = true,
|
||||
Parameters = new Dictionary<string, string>
|
||||
{
|
||||
["vex_status"] = "not_affected",
|
||||
["vex_justification"] = "vulnerable_code_not_in_execute_path"
|
||||
}.ToImmutableDictionary(),
|
||||
EvidenceLinks = [new EvidenceLink
|
||||
{
|
||||
Uri = "reach:analysis/CVE-2024-1234",
|
||||
Type = "reachability",
|
||||
Label = "Reachability Analysis"
|
||||
}]
|
||||
});
|
||||
|
||||
proposalEvent.Type.Should().Be(RunEventType.ActionProposed);
|
||||
|
||||
// Phase 4: Request Approval
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var pendingRun = await _service.RequestApprovalAsync(
|
||||
_testTenantId, run.RunId,
|
||||
["bob@example.com", "charlie@example.com"],
|
||||
"Please review VEX statement for CVE-2024-1234");
|
||||
|
||||
pendingRun.Status.Should().Be(RunStatus.PendingApproval);
|
||||
pendingRun.Approval.Should().NotBeNull();
|
||||
pendingRun.Approval!.Approvers.Should().Contain("bob@example.com");
|
||||
|
||||
// Phase 5: Approval
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
var approvedRun = await _service.ApproveAsync(
|
||||
_testTenantId, run.RunId,
|
||||
approved: true,
|
||||
approverId: "bob@example.com",
|
||||
reason: "Reachability analysis is sound, VEX statement approved");
|
||||
|
||||
approvedRun.Status.Should().Be(RunStatus.Approved);
|
||||
approvedRun.Approval!.Approved.Should().BeTrue();
|
||||
approvedRun.Approval.ApprovedBy.Should().Be("bob@example.com");
|
||||
|
||||
// Phase 6: Execute Action
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var executedEvent = await _service.ExecuteActionAsync(
|
||||
_testTenantId, run.RunId, proposalEvent.EventId);
|
||||
|
||||
executedEvent.Type.Should().Be(RunEventType.ActionExecuted);
|
||||
|
||||
// Phase 7: Add VEX Artifact
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var vexArtifact = new RunArtifact
|
||||
{
|
||||
ArtifactId = "vex-001",
|
||||
Type = ArtifactType.VexStatement,
|
||||
Name = "VEX Statement for CVE-2024-1234",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
MediaType = "application/vnd.openvex+json",
|
||||
StorageUri = "stellaops://artifacts/vex/vex-001.json"
|
||||
};
|
||||
|
||||
var withArtifact = await _service.AddArtifactAsync(_testTenantId, run.RunId, vexArtifact);
|
||||
withArtifact.Artifacts.Should().HaveCount(1);
|
||||
withArtifact.Artifacts[0].ArtifactId.Should().Be("vex-001");
|
||||
|
||||
// Phase 8: Complete Run
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var completedRun = await _service.CompleteAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Investigation complete: CVE-2024-1234 not affected, VEX published");
|
||||
|
||||
completedRun.Status.Should().Be(RunStatus.Completed);
|
||||
completedRun.CompletedAt.Should().NotBeNull();
|
||||
completedRun.ContentDigest.Should().NotBeNullOrEmpty();
|
||||
|
||||
// Phase 9: Attest Run
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var attestedRun = await _service.AttestAsync(_testTenantId, run.RunId);
|
||||
|
||||
attestedRun.Attestation.Should().NotBeNull();
|
||||
attestedRun.Attestation!.AttestationId.Should().StartWith("att-");
|
||||
attestedRun.Attestation.ContentDigest.Should().Be(completedRun.ContentDigest);
|
||||
|
||||
// Verify final timeline
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
timeline.Length.Should().BeGreaterThanOrEqualTo(8);
|
||||
|
||||
// Verify event sequence is properly ordered
|
||||
for (var i = 1; i < timeline.Length; i++)
|
||||
{
|
||||
timeline[i].SequenceNumber.Should().BeGreaterThan(timeline[i - 1].SequenceNumber);
|
||||
timeline[i].Timestamp.Should().BeOnOrAfter(timeline[i - 1].Timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimelinePersistence_EventsAreAppendOnly()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Timeline Test"
|
||||
});
|
||||
|
||||
var initialEventCount = run.Events.Length;
|
||||
|
||||
// Act - Add multiple events
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Message 1", "user-1");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Response 1");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Message 2", "user-1");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Response 2");
|
||||
|
||||
// Assert
|
||||
var finalRun = await _service.GetAsync(_testTenantId, run.RunId);
|
||||
finalRun!.Events.Length.Should().Be(initialEventCount + 4);
|
||||
|
||||
// Events should have monotonically increasing sequence numbers
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
var sequenceNumbers = timeline.Select(e => e.SequenceNumber).ToList();
|
||||
sequenceNumbers.Should().BeInAscendingOrder();
|
||||
|
||||
// Event timestamps should not go backwards
|
||||
var timestamps = timeline.Select(e => e.Timestamp).ToList();
|
||||
for (var i = 1; i < timestamps.Count; i++)
|
||||
{
|
||||
timestamps[i].Should().BeOnOrAfter(timestamps[i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArtifactStorageAndRetrieval_MultipleArtifactTypes()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Artifact Test"
|
||||
});
|
||||
|
||||
var artifacts = new[]
|
||||
{
|
||||
new RunArtifact
|
||||
{
|
||||
ArtifactId = "vex-001",
|
||||
Type = ArtifactType.VexStatement,
|
||||
Name = "VEX Statement",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:vex123",
|
||||
MediaType = "application/vnd.openvex+json"
|
||||
},
|
||||
new RunArtifact
|
||||
{
|
||||
ArtifactId = "report-001",
|
||||
Type = ArtifactType.Report,
|
||||
Name = "Investigation Report",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:report456",
|
||||
MediaType = "application/pdf"
|
||||
},
|
||||
new RunArtifact
|
||||
{
|
||||
ArtifactId = "evidence-001",
|
||||
Type = ArtifactType.EvidencePack,
|
||||
Name = "Evidence Bundle",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:evidence789",
|
||||
MediaType = "application/zip"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddArtifactAsync(_testTenantId, run.RunId, artifact);
|
||||
}
|
||||
|
||||
// Assert
|
||||
var finalRun = await _service.GetAsync(_testTenantId, run.RunId);
|
||||
finalRun!.Artifacts.Should().HaveCount(3);
|
||||
|
||||
// Verify all artifact types are present
|
||||
finalRun.Artifacts.Should().Contain(a => a.Type == ArtifactType.VexStatement);
|
||||
finalRun.Artifacts.Should().Contain(a => a.Type == ArtifactType.Report);
|
||||
finalRun.Artifacts.Should().Contain(a => a.Type == ArtifactType.EvidencePack);
|
||||
|
||||
// Verify artifact details preserved
|
||||
var vexArtifact = finalRun.Artifacts.Single(a => a.ArtifactId == "vex-001");
|
||||
vexArtifact.ContentDigest.Should().Be("sha256:vex123");
|
||||
vexArtifact.MediaType.Should().Be("application/vnd.openvex+json");
|
||||
|
||||
// Verify artifact events in timeline
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
timeline.Where(e => e.Type == RunEventType.ArtifactProduced).Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvidencePackAttachment_TracksAllPacks()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Evidence Pack Test"
|
||||
});
|
||||
|
||||
var evidencePacks = new[]
|
||||
{
|
||||
new EvidencePackReference
|
||||
{
|
||||
PackId = "pack-001",
|
||||
Digest = "sha256:pack001hash",
|
||||
AttachedAt = _timeProvider.GetUtcNow(),
|
||||
PackType = "vulnerability-analysis"
|
||||
},
|
||||
new EvidencePackReference
|
||||
{
|
||||
PackId = "pack-002",
|
||||
Digest = "sha256:pack002hash",
|
||||
AttachedAt = _timeProvider.GetUtcNow(),
|
||||
PackType = "reachability-evidence"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
foreach (var pack in evidencePacks)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AttachEvidencePackAsync(_testTenantId, run.RunId, pack);
|
||||
}
|
||||
|
||||
// Assert
|
||||
var finalRun = await _service.GetAsync(_testTenantId, run.RunId);
|
||||
finalRun!.EvidencePacks.Should().HaveCount(2);
|
||||
finalRun.EvidencePacks.Should().Contain(p => p.PackId == "pack-001");
|
||||
finalRun.EvidencePacks.Should().Contain(p => p.PackId == "pack-002");
|
||||
|
||||
// Verify evidence events in timeline
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
timeline.Where(e => e.Type == RunEventType.EvidenceAttached).Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunContentDigest_IsDeterministic()
|
||||
{
|
||||
// Create two identical runs
|
||||
_guidGenerator.Reset();
|
||||
var run1 = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Determinism Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run1.RunId, "Test message", "user-1");
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run1.RunId, "Test response");
|
||||
var completed1 = await _service.CompleteAsync(_testTenantId, run1.RunId);
|
||||
|
||||
// Reset state and create identical run
|
||||
_store.Clear();
|
||||
_guidGenerator.Reset();
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 10, 10, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var run2 = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Determinism Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run2.RunId, "Test message", "user-1");
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run2.RunId, "Test response");
|
||||
var completed2 = await _service.CompleteAsync(_testTenantId, run2.RunId);
|
||||
|
||||
// Assert - digests should be identical for identical content
|
||||
completed1.ContentDigest.Should().Be(completed2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantIsolation_RunsAreIsolatedByTenant()
|
||||
{
|
||||
// Arrange - Create runs in different tenants
|
||||
var tenant1Run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Tenant 1 Run"
|
||||
});
|
||||
|
||||
var tenant2Run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-2",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Tenant 2 Run"
|
||||
});
|
||||
|
||||
// Act & Assert - Cannot access other tenant's run
|
||||
var crossTenantAccess = await _service.GetAsync("tenant-1", tenant2Run.RunId);
|
||||
crossTenantAccess.Should().BeNull();
|
||||
|
||||
// Query only returns own tenant's runs
|
||||
var tenant1Query = await _service.QueryAsync(new RunQuery { TenantId = "tenant-1" });
|
||||
tenant1Query.Runs.Should().HaveCount(1);
|
||||
tenant1Query.Runs[0].RunId.Should().Be(tenant1Run.RunId);
|
||||
|
||||
var tenant2Query = await _service.QueryAsync(new RunQuery { TenantId = "tenant-2" });
|
||||
tenant2Query.Runs.Should().HaveCount(1);
|
||||
tenant2Query.Runs[0].RunId.Should().Be(tenant2Run.RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandoffWorkflow_TransfersOwnershipCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "alice@example.com",
|
||||
Title = "Handoff Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Starting investigation", "alice@example.com");
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Initial analysis complete");
|
||||
|
||||
// Act - Hand off to another user
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||
var handedOff = await _service.HandOffAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"bob@example.com",
|
||||
"Please continue this investigation - I need to focus on another issue");
|
||||
|
||||
// Assert
|
||||
handedOff.Metadata["current_owner"].Should().Be("bob@example.com");
|
||||
|
||||
// Verify handoff event in timeline
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
var handoffEvent = timeline.FirstOrDefault(e => e.Type == RunEventType.HandedOff);
|
||||
handoffEvent.Should().NotBeNull();
|
||||
handoffEvent!.Metadata["to_user"].Should().Be("bob@example.com");
|
||||
|
||||
// New owner can continue the run
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var continuedEvent = await _service.AddUserTurnAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Continuing investigation from Alice",
|
||||
"bob@example.com");
|
||||
|
||||
continuedEvent.Should().NotBeNull();
|
||||
continuedEvent.ActorId.Should().Be("bob@example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectionWorkflow_StopsRunOnRejection()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Rejection Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Question", "user-1");
|
||||
await _service.ProposeActionAsync(_testTenantId, run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
RequiresApproval = true
|
||||
});
|
||||
await _service.RequestApprovalAsync(_testTenantId, run.RunId, ["approver-1"]);
|
||||
|
||||
// Act - Reject
|
||||
var rejected = await _service.ApproveAsync(
|
||||
_testTenantId, run.RunId,
|
||||
approved: false,
|
||||
approverId: "approver-1",
|
||||
reason: "Insufficient evidence for not_affected status");
|
||||
|
||||
// Assert
|
||||
rejected.Status.Should().Be(RunStatus.Rejected);
|
||||
rejected.Approval!.Approved.Should().BeFalse();
|
||||
rejected.CompletedAt.Should().NotBeNull();
|
||||
|
||||
// Cannot add more events to rejected run
|
||||
await _service.Invoking(s => s.AddUserTurnAsync(
|
||||
_testTenantId, run.RunId, "More questions", "user-1"))
|
||||
.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationWorkflow_PreservesHistory()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Cancellation Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Question 1", "user-1");
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Answer 1");
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Question 2", "user-1");
|
||||
|
||||
var eventCountBeforeCancel = (await _service.GetAsync(_testTenantId, run.RunId))!.Events.Length;
|
||||
|
||||
// Act
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(30));
|
||||
var cancelled = await _service.CancelAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Investigation no longer needed - issue resolved by upstream fix");
|
||||
|
||||
// Assert
|
||||
cancelled.Status.Should().Be(RunStatus.Cancelled);
|
||||
cancelled.CompletedAt.Should().NotBeNull();
|
||||
|
||||
// History is preserved
|
||||
cancelled.Events.Length.Should().Be(eventCountBeforeCancel + 1);
|
||||
cancelled.Events.Last().Type.Should().Be(RunEventType.Cancelled);
|
||||
|
||||
// Timeline still accessible
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
timeline.Length.Should().Be(eventCountBeforeCancel + 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_FailsForNonCompletedRun()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Attest Validation Test"
|
||||
});
|
||||
|
||||
// Act & Assert - Cannot attest non-completed run
|
||||
await _service.Invoking(s => s.AttestAsync(_testTenantId, run.RunId))
|
||||
.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*not completed*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_FailsForAlreadyAttestedRun()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Double Attest Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Q", "user-1");
|
||||
await _service.CompleteAsync(_testTenantId, run.RunId);
|
||||
await _service.AttestAsync(_testTenantId, run.RunId);
|
||||
|
||||
// Act & Assert - Cannot attest twice
|
||||
await _service.Invoking(s => s.AttestAsync(_testTenantId, run.RunId))
|
||||
.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*already attested*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_PaginationWorks()
|
||||
{
|
||||
// Arrange - Create 10 runs
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = $"Run {i + 1}"
|
||||
});
|
||||
}
|
||||
|
||||
// Act - Page 1
|
||||
var page1 = await _service.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
Skip = 0,
|
||||
Take = 3
|
||||
});
|
||||
|
||||
// Act - Page 2
|
||||
var page2 = await _service.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
Skip = 3,
|
||||
Take = 3
|
||||
});
|
||||
|
||||
// Assert
|
||||
page1.TotalCount.Should().Be(10);
|
||||
page1.Runs.Should().HaveCount(3);
|
||||
page1.HasMore.Should().BeTrue();
|
||||
|
||||
page2.Runs.Should().HaveCount(3);
|
||||
page2.HasMore.Should().BeTrue();
|
||||
|
||||
// No overlap between pages
|
||||
page1.Runs.Select(r => r.RunId)
|
||||
.Should().NotIntersectWith(page2.Runs.Select(r => r.RunId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimelineAsync_PaginationWorks()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Timeline Pagination Test"
|
||||
});
|
||||
|
||||
// Add 20 events
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, $"Message {i}", "user-1");
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, $"Response {i}");
|
||||
}
|
||||
|
||||
// Act
|
||||
var page1 = await _service.GetTimelineAsync(_testTenantId, run.RunId, skip: 0, take: 5);
|
||||
var page2 = await _service.GetTimelineAsync(_testTenantId, run.RunId, skip: 5, take: 5);
|
||||
var page3 = await _service.GetTimelineAsync(_testTenantId, run.RunId, skip: 10, take: 5);
|
||||
|
||||
// Assert
|
||||
page1.Should().HaveCount(5);
|
||||
page2.Should().HaveCount(5);
|
||||
page3.Should().HaveCount(5);
|
||||
|
||||
// Verify sequence continuity
|
||||
page1.Last().SequenceNumber.Should().BeLessThan(page2.First().SequenceNumber);
|
||||
page2.Last().SequenceNumber.Should().BeLessThan(page3.First().SequenceNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic GUID generator for testing.
|
||||
/// </summary>
|
||||
private sealed class DeterministicGuidGenerator : IGuidGenerator
|
||||
{
|
||||
private int _counter;
|
||||
|
||||
public Guid NewGuid()
|
||||
{
|
||||
var bytes = new byte[16];
|
||||
BitConverter.GetBytes(_counter++).CopyTo(bytes, 0);
|
||||
return new Guid(bytes);
|
||||
}
|
||||
|
||||
public void Reset() => _counter = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
// <copyright file="RunServiceTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RunService"/>.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RunServiceTests
|
||||
{
|
||||
private readonly InMemoryRunStore _store = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly NullLogger<RunService> _logger = NullLogger<RunService>.Instance;
|
||||
private readonly RunService _service;
|
||||
|
||||
public RunServiceTests()
|
||||
{
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
_service = new RunService(_store, _timeProvider, new DefaultGuidGenerator(), _logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_GeneratesRunWithCorrectProperties()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run",
|
||||
Objective = "Investigate CVE-2024-1234"
|
||||
};
|
||||
|
||||
// Act
|
||||
var run = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(run);
|
||||
Assert.NotEmpty(run.RunId);
|
||||
Assert.Equal("tenant-1", run.TenantId);
|
||||
Assert.Equal("user-1", run.InitiatedBy);
|
||||
Assert.Equal("Test Run", run.Title);
|
||||
Assert.Equal("Investigate CVE-2024-1234", run.Objective);
|
||||
Assert.Equal(RunStatus.Created, run.Status);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), run.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithContext_StoresContextCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "CVE Investigation",
|
||||
Context = new RunContext
|
||||
{
|
||||
FocusedCveId = "CVE-2024-5678",
|
||||
FocusedComponent = "pkg:npm/express@4.17.1",
|
||||
Tags = ["critical", "urgent"]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var run = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("CVE-2024-5678", run.Context.FocusedCveId);
|
||||
Assert.Equal("pkg:npm/express@4.17.1", run.Context.FocusedComponent);
|
||||
Assert.Contains("critical", run.Context.Tags);
|
||||
Assert.Contains("urgent", run.Context.Tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ExistingRun_ReturnsRun()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
};
|
||||
var created = await _service.CreateAsync(request);
|
||||
|
||||
// Act
|
||||
var retrieved = await _service.GetAsync("tenant-1", created.RunId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(created.RunId, retrieved.RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_NonExistentRun_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var retrieved = await _service.GetAsync("tenant-1", "non-existent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddUserTurnAsync_AddsEventAndActivatesRun()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
// Act
|
||||
var evt = await _service.AddUserTurnAsync(
|
||||
"tenant-1", run.RunId, "What vulnerabilities affect this component?", "user-1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(RunEventType.UserTurn, evt.Type);
|
||||
Assert.Equal("user-1", evt.ActorId);
|
||||
var turnContent = Assert.IsType<TurnContent>(evt.Content);
|
||||
Assert.Contains("vulnerabilities", turnContent.Message);
|
||||
|
||||
var updated = await _service.GetAsync("tenant-1", run.RunId);
|
||||
Assert.Equal(RunStatus.Active, updated!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAssistantTurnAsync_AddsEventCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question?", "user-1");
|
||||
|
||||
// Act
|
||||
var evt = await _service.AddAssistantTurnAsync(
|
||||
"tenant-1", run.RunId, "Here is my analysis of the vulnerability...");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(RunEventType.AssistantTurn, evt.Type);
|
||||
Assert.Equal("assistant", evt.ActorId);
|
||||
var assistantContent = Assert.IsType<TurnContent>(evt.Content);
|
||||
Assert.Contains("analysis", assistantContent.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProposeActionAsync_AddsActionProposal()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Fix this CVE", "user-1");
|
||||
|
||||
// Act
|
||||
var evt = await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
Subject = "CVE-2024-1234",
|
||||
Rationale = "Component is not affected due to usage context",
|
||||
RequiresApproval = true
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(RunEventType.ActionProposed, evt.Type);
|
||||
var actionContent = Assert.IsType<ActionProposedContent>(evt.Content);
|
||||
Assert.Equal("vex:publish", actionContent.ActionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestApprovalAsync_ChangesStatusToPendingApproval()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
|
||||
await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
RequiresApproval = true
|
||||
});
|
||||
|
||||
// Act
|
||||
var updated = await _service.RequestApprovalAsync(
|
||||
"tenant-1", run.RunId, ["approver-1", "approver-2"], "Please review VEX decision");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.PendingApproval, updated.Status);
|
||||
Assert.NotNull(updated.Approval);
|
||||
Assert.True(updated.Approval.Required);
|
||||
Assert.Contains("approver-1", updated.Approval.Approvers);
|
||||
Assert.Contains("approver-2", updated.Approval.Approvers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAsync_Approved_ChangesStatusToApproved()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
|
||||
await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
RequiresApproval = true
|
||||
});
|
||||
await _service.RequestApprovalAsync("tenant-1", run.RunId, ["approver-1"], "Review");
|
||||
|
||||
// Act
|
||||
var updated = await _service.ApproveAsync(
|
||||
"tenant-1", run.RunId, approved: true, "approver-1", "Looks good");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.Approved, updated.Status);
|
||||
Assert.NotNull(updated.Approval);
|
||||
Assert.True(updated.Approval.Approved);
|
||||
Assert.Equal("approver-1", updated.Approval.ApprovedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAsync_Rejected_ChangesStatusToRejected()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
|
||||
await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
RequiresApproval = true
|
||||
});
|
||||
await _service.RequestApprovalAsync("tenant-1", run.RunId, ["approver-1"], "Review");
|
||||
|
||||
// Act
|
||||
var updated = await _service.ApproveAsync(
|
||||
"tenant-1", run.RunId, approved: false, "approver-1", "Needs more justification");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.Rejected, updated.Status);
|
||||
Assert.False(updated.Approval!.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteAsync_SetsCompletedStatusAndTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
|
||||
await _service.AddAssistantTurnAsync("tenant-1", run.RunId, "Answer");
|
||||
|
||||
// Act
|
||||
var completed = await _service.CompleteAsync("tenant-1", run.RunId, "Investigation complete");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.Completed, completed.Status);
|
||||
Assert.NotNull(completed.CompletedAt);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), completed.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_SetsCancelledStatus()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
// Act
|
||||
var cancelled = await _service.CancelAsync("tenant-1", run.RunId, "No longer needed");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.Cancelled, cancelled.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddArtifactAsync_AddsArtifactToRun()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
var artifact = new RunArtifact
|
||||
{
|
||||
ArtifactId = "artifact-1",
|
||||
Type = ArtifactType.VexStatement,
|
||||
Name = "VEX for CVE-2024-1234",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:abc123",
|
||||
MediaType = "application/vnd.openvex+json"
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = await _service.AddArtifactAsync("tenant-1", run.RunId, artifact);
|
||||
|
||||
// Assert
|
||||
Assert.Single(updated.Artifacts);
|
||||
Assert.Equal("artifact-1", updated.Artifacts[0].ArtifactId);
|
||||
Assert.Equal(ArtifactType.VexStatement, updated.Artifacts[0].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Run 1",
|
||||
Context = new RunContext { FocusedCveId = "CVE-2024-1111" }
|
||||
});
|
||||
await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-2",
|
||||
Title = "Run 2",
|
||||
Context = new RunContext { FocusedCveId = "CVE-2024-2222" }
|
||||
});
|
||||
await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Run 3",
|
||||
Context = new RunContext { FocusedCveId = "CVE-2024-1111" }
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
CveId = "CVE-2024-1111"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.TotalCount);
|
||||
Assert.All(result.Runs, r =>
|
||||
{
|
||||
Assert.Equal("user-1", r.InitiatedBy);
|
||||
Assert.Equal("CVE-2024-1111", r.Context.FocusedCveId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimelineAsync_ReturnsEventsInOrder()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question 1", "user-1");
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddAssistantTurnAsync("tenant-1", run.RunId, "Answer 1");
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question 2", "user-1");
|
||||
|
||||
// Act
|
||||
var timeline = await _service.GetTimelineAsync("tenant-1", run.RunId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, timeline.Length);
|
||||
Assert.Equal(RunEventType.UserTurn, timeline[0].Type);
|
||||
Assert.Equal(RunEventType.AssistantTurn, timeline[1].Type);
|
||||
Assert.Equal(RunEventType.UserTurn, timeline[2].Type);
|
||||
|
||||
// Verify sequence numbers are ordered
|
||||
Assert.True(timeline[0].SequenceNumber < timeline[1].SequenceNumber);
|
||||
Assert.True(timeline[1].SequenceNumber < timeline[2].SequenceNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandOffAsync_TransfersOwnership()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
// Act
|
||||
var updated = await _service.HandOffAsync("tenant-1", run.RunId, "user-2", "Please continue");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("user-2", updated.Metadata["current_owner"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddEventAsync_NonExistentRun_ThrowsInvalidOperation()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.AddUserTurnAsync("tenant-1", "non-existent", "Message", "user-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteAsync_AlreadyCompleted_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Q", "user-1");
|
||||
await _service.CompleteAsync("tenant-1", run.RunId);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.CompleteAsync("tenant-1", run.RunId));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed class DecisionService : IDecisionService
|
||||
{
|
||||
private readonly ILedgerEventWriteService _writeService;
|
||||
private readonly ILedgerEventRepository _repository;
|
||||
private readonly IEnumerable<IDecisionHook> _hooks;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DecisionService> _logger;
|
||||
|
||||
@@ -22,11 +23,13 @@ public sealed class DecisionService : IDecisionService
|
||||
public DecisionService(
|
||||
ILedgerEventWriteService writeService,
|
||||
ILedgerEventRepository repository,
|
||||
IEnumerable<IDecisionHook> hooks,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DecisionService> logger)
|
||||
{
|
||||
_writeService = writeService ?? throw new ArgumentNullException(nameof(writeService));
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_hooks = hooks ?? Enumerable.Empty<IDecisionHook>();
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
@@ -111,9 +114,37 @@ public sealed class DecisionService : IDecisionService
|
||||
"Decision {DecisionId} recorded for alert {AlertId}: {Status}",
|
||||
decision.Id, decision.AlertId, decision.DecisionStatus);
|
||||
|
||||
// Fire-and-forget hooks - don't block the caller
|
||||
_ = FireHooksAsync(decision, tenantId, cancellationToken);
|
||||
|
||||
return decision;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires all registered hooks asynchronously.
|
||||
/// </summary>
|
||||
private async Task FireHooksAsync(
|
||||
DecisionEvent decision,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var hook in _hooks)
|
||||
{
|
||||
try
|
||||
{
|
||||
await hook.OnDecisionRecordedAsync(decision, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Hooks are fire-and-forget; log but don't throw
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Decision hook {HookType} failed for decision {DecisionId}: {Message}",
|
||||
hook.GetType().Name, decision.Id, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets decision history for an alert (immutable timeline).
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// <copyright file="IDecisionHook.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Hook interface for decision recording events.
|
||||
/// Implementations are called asynchronously after decision is persisted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Hooks are fire-and-forget; exceptions are logged but don't block the caller.
|
||||
/// SPRINT_20260107_006_004_BE Task: OM-007
|
||||
/// </remarks>
|
||||
public interface IDecisionHook
|
||||
{
|
||||
/// <summary>
|
||||
/// Called after a decision is recorded to the ledger.
|
||||
/// </summary>
|
||||
/// <param name="decision">The recorded decision event.</param>
|
||||
/// <param name="tenantId">The tenant identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task OnDecisionRecordedAsync(
|
||||
DecisionEvent decision,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context provided to decision hooks for enriched processing.
|
||||
/// </summary>
|
||||
public sealed record DecisionHookContext
|
||||
{
|
||||
/// <summary>
|
||||
/// The decision event that was recorded.
|
||||
/// </summary>
|
||||
public required DecisionEvent Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was persisted to the ledger.
|
||||
/// </summary>
|
||||
public required DateTimeOffset PersistedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The ledger event sequence number, if available.
|
||||
/// </summary>
|
||||
public long? SequenceNumber { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// <copyright file="IOpsMemoryChatProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
|
||||
namespace StellaOps.OpsMemory.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Provider for integrating OpsMemory with chat-based AI advisors.
|
||||
/// Enables surfacing past decisions in chat context and recording new decisions from chat actions.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-001
|
||||
/// </summary>
|
||||
public interface IOpsMemoryChatProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Enriches chat context with relevant past decisions and playbook suggestions.
|
||||
/// </summary>
|
||||
/// <param name="request">The chat context request with situational information.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>OpsMemory context with similar decisions and applicable tactics.</returns>
|
||||
Task<OpsMemoryContext> EnrichContextAsync(
|
||||
ChatContextRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Records a decision from an executed chat action.
|
||||
/// </summary>
|
||||
/// <param name="action">The action execution result from chat.</param>
|
||||
/// <param name="context">The conversation context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The recorded OpsMemory record.</returns>
|
||||
Task<OpsMemoryRecord> RecordFromActionAsync(
|
||||
ActionExecutionResult action,
|
||||
ConversationContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent decisions for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="limit">Maximum number of decisions to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Recent decision summaries.</returns>
|
||||
Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for chat context enrichment from OpsMemory.
|
||||
/// </summary>
|
||||
public sealed record ChatContextRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant identifier for isolation.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVE identifier being discussed (if any).
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component PURL being discussed.
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the severity level (Critical, High, Medium, Low).
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reachability status.
|
||||
/// </summary>
|
||||
public ReachabilityStatus? Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVSS score (0-10).
|
||||
/// </summary>
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the EPSS score (0-1).
|
||||
/// </summary>
|
||||
public double? EpssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional context tags (environment, team, etc.).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ContextTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of similar decisions to return.
|
||||
/// </summary>
|
||||
public int MaxSuggestions { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum similarity score for matches (0-1).
|
||||
/// </summary>
|
||||
public double MinSimilarity { get; init; } = 0.6;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context from OpsMemory to enrich chat responses.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets similar past decisions with their outcomes.
|
||||
/// </summary>
|
||||
public ImmutableArray<PastDecisionSummary> SimilarDecisions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets relevant known issues from the corpus.
|
||||
/// </summary>
|
||||
public ImmutableArray<KnownIssue> RelevantKnownIssues { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets applicable tactics based on the situation.
|
||||
/// </summary>
|
||||
public ImmutableArray<Tactic> ApplicableTactics { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generated prompt segment for the AI.
|
||||
/// </summary>
|
||||
public string? PromptSegment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of similar situations found.
|
||||
/// </summary>
|
||||
public int TotalSimilarCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are applicable playbook entries.
|
||||
/// </summary>
|
||||
public bool HasPlaybookEntries => SimilarDecisions.Length > 0 || ApplicableTactics.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a past decision for chat context.
|
||||
/// </summary>
|
||||
public sealed record PastDecisionSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the memory record ID.
|
||||
/// </summary>
|
||||
public required string MemoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVE ID (if any).
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component affected.
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the severity at the time of decision.
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action that was taken.
|
||||
/// </summary>
|
||||
public required DecisionAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rationale for the decision.
|
||||
/// </summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the outcome status (if recorded).
|
||||
/// </summary>
|
||||
public OutcomeStatus? OutcomeStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the similarity score to the current situation (0-1).
|
||||
/// </summary>
|
||||
public double SimilarityScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the decision was made.
|
||||
/// </summary>
|
||||
public DateTimeOffset DecidedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any lessons learned from the outcome.
|
||||
/// </summary>
|
||||
public string? LessonsLearned { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A known issue from the corpus.
|
||||
/// </summary>
|
||||
public sealed record KnownIssue
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the issue identifier.
|
||||
/// </summary>
|
||||
public required string IssueId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the issue title.
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the issue description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the recommended action.
|
||||
/// </summary>
|
||||
public string? RecommendedAction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets relevance score (0-1).
|
||||
/// </summary>
|
||||
public double Relevance { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A playbook tactic applicable to the situation.
|
||||
/// </summary>
|
||||
public sealed record Tactic
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tactic identifier.
|
||||
/// </summary>
|
||||
public required string TacticId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tactic name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tactic description.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets applicability conditions.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Conditions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the recommended action.
|
||||
/// </summary>
|
||||
public DecisionAction RecommendedAction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets confidence score (0-1).
|
||||
/// </summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets success rate from past applications.
|
||||
/// </summary>
|
||||
public double? SuccessRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of executing an action from chat.
|
||||
/// </summary>
|
||||
public sealed record ActionExecutionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action that was executed.
|
||||
/// </summary>
|
||||
public required DecisionAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVE ID affected.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component affected.
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the action was successful.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rationale provided by the user or AI.
|
||||
/// </summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of execution.
|
||||
/// </summary>
|
||||
public DateTimeOffset ExecutedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user who triggered the action.
|
||||
/// </summary>
|
||||
public required string ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional metadata about the action.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context of the conversation where the action was taken.
|
||||
/// </summary>
|
||||
public sealed record ConversationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the conversation identifier.
|
||||
/// </summary>
|
||||
public required string ConversationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user identifier.
|
||||
/// </summary>
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation summary/topic.
|
||||
/// </summary>
|
||||
public string? Topic { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the turn number where action was taken.
|
||||
/// </summary>
|
||||
public int TurnNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the situation context extracted from the conversation.
|
||||
/// </summary>
|
||||
public SituationContext? Situation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any evidence links from the conversation.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> EvidenceLinks { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// <copyright file="OpsMemoryChatProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Playbook;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
|
||||
namespace StellaOps.OpsMemory.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of OpsMemory chat provider for AI integration.
|
||||
/// Provides context enrichment from past decisions and records new decisions from chat actions.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-002
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryChatProvider : IOpsMemoryChatProvider
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly ISimilarityVectorGenerator _vectorGenerator;
|
||||
private readonly IPlaybookSuggestionService _playbookService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<OpsMemoryChatProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new OpsMemoryChatProvider.
|
||||
/// </summary>
|
||||
public OpsMemoryChatProvider(
|
||||
IOpsMemoryStore store,
|
||||
ISimilarityVectorGenerator vectorGenerator,
|
||||
IPlaybookSuggestionService playbookService,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<OpsMemoryChatProvider> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_vectorGenerator = vectorGenerator ?? throw new ArgumentNullException(nameof(vectorGenerator));
|
||||
_playbookService = playbookService ?? throw new ArgumentNullException(nameof(playbookService));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryContext> EnrichContextAsync(
|
||||
ChatContextRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Enriching chat context for tenant {TenantId}, CVE {CveId}",
|
||||
request.TenantId, request.CveId ?? "(none)");
|
||||
|
||||
// Build situation from request
|
||||
var situation = BuildSituation(request);
|
||||
|
||||
// Generate similarity vector for the current situation
|
||||
var queryVector = _vectorGenerator.Generate(situation);
|
||||
|
||||
// Find similar past decisions
|
||||
var similarQuery = new SimilarityQuery
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
SimilarityVector = queryVector,
|
||||
Situation = situation,
|
||||
MinSimilarity = request.MinSimilarity,
|
||||
Limit = request.MaxSuggestions * 2 // Fetch more to filter by outcome
|
||||
};
|
||||
|
||||
var similarRecords = await _store.FindSimilarAsync(similarQuery, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Convert to summaries with similarity scores
|
||||
var summaries = similarRecords
|
||||
.Select(r => CreateSummary(r.Record, r.SimilarityScore))
|
||||
.OrderByDescending(s => s.SimilarityScore)
|
||||
.ThenByDescending(s => s.OutcomeStatus == OutcomeStatus.Success ? 1 : 0)
|
||||
.Take(request.MaxSuggestions)
|
||||
.ToImmutableArray();
|
||||
|
||||
// Get applicable tactics from playbook
|
||||
var tactics = await GetApplicableTacticsAsync(situation, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get known issues if CVE is provided
|
||||
var knownIssues = request.CveId is not null
|
||||
? await GetKnownIssuesAsync(request.CveId, cancellationToken).ConfigureAwait(false)
|
||||
: ImmutableArray<KnownIssue>.Empty;
|
||||
|
||||
// Build prompt segment for AI
|
||||
var promptSegment = BuildPromptSegment(summaries, tactics, knownIssues);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Found {DecisionCount} similar decisions, {TacticCount} applicable tactics for {CveId}",
|
||||
summaries.Length, tactics.Length, request.CveId ?? "(no CVE)");
|
||||
|
||||
return new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = summaries,
|
||||
ApplicableTactics = tactics,
|
||||
RelevantKnownIssues = knownIssues,
|
||||
PromptSegment = promptSegment,
|
||||
TotalSimilarCount = similarRecords.Count
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryRecord> RecordFromActionAsync(
|
||||
ActionExecutionResult action,
|
||||
ConversationContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Recording decision from chat action: {Action} for CVE {CveId}",
|
||||
action.Action, action.CveId ?? "(none)");
|
||||
|
||||
// Build situation from conversation context
|
||||
var situation = context.Situation ?? BuildSituationFromAction(action);
|
||||
|
||||
// Generate similarity vector
|
||||
var vector = _vectorGenerator.Generate(situation);
|
||||
|
||||
// Create memory record
|
||||
var memoryId = GenerateMemoryId();
|
||||
var record = new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
TenantId = context.TenantId,
|
||||
RecordedAt = _timeProvider.GetUtcNow(),
|
||||
Situation = situation,
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = action.Action,
|
||||
Rationale = action.Rationale ?? $"Decision made via chat conversation {context.ConversationId}",
|
||||
DecidedBy = action.ActorId,
|
||||
DecidedAt = action.ExecutedAt,
|
||||
PolicyReference = null, // Not from policy gate
|
||||
VexStatementId = null,
|
||||
Mitigation = null
|
||||
},
|
||||
Outcome = null, // Outcome tracked separately
|
||||
SimilarityVector = vector
|
||||
};
|
||||
|
||||
// Store the record
|
||||
await _store.RecordDecisionAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded decision {MemoryId} from chat conversation {ConversationId}",
|
||||
memoryId, context.ConversationId);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new OpsMemoryQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PageSize = limit,
|
||||
SortBy = OpsMemorySortField.RecordedAt,
|
||||
Descending = true
|
||||
};
|
||||
|
||||
var result = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result.Items
|
||||
.Select(r => CreateSummary(r, 0))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static SituationContext BuildSituation(ChatContextRequest request)
|
||||
{
|
||||
return new SituationContext
|
||||
{
|
||||
CveId = request.CveId,
|
||||
Component = request.Component,
|
||||
Severity = request.Severity,
|
||||
Reachability = request.Reachability ?? ReachabilityStatus.Unknown,
|
||||
CvssScore = request.CvssScore,
|
||||
EpssScore = request.EpssScore,
|
||||
ContextTags = request.ContextTags
|
||||
};
|
||||
}
|
||||
|
||||
private static SituationContext BuildSituationFromAction(ActionExecutionResult action)
|
||||
{
|
||||
return new SituationContext
|
||||
{
|
||||
CveId = action.CveId,
|
||||
Component = action.Component,
|
||||
Reachability = ReachabilityStatus.Unknown,
|
||||
ContextTags = action.Metadata.Keys
|
||||
.Where(k => k.StartsWith("tag:", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(k => k[4..])
|
||||
.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static PastDecisionSummary CreateSummary(OpsMemoryRecord record, double similarityScore)
|
||||
{
|
||||
return new PastDecisionSummary
|
||||
{
|
||||
MemoryId = record.MemoryId,
|
||||
CveId = record.Situation.CveId,
|
||||
Component = record.Situation.Component ?? record.Situation.ComponentName,
|
||||
Severity = record.Situation.Severity,
|
||||
Action = record.Decision.Action,
|
||||
Rationale = record.Decision.Rationale,
|
||||
OutcomeStatus = record.Outcome?.Status,
|
||||
SimilarityScore = similarityScore,
|
||||
DecidedAt = record.Decision.DecidedAt,
|
||||
LessonsLearned = record.Outcome?.LessonsLearned
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<Tactic>> GetApplicableTacticsAsync(
|
||||
SituationContext situation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var suggestions = await _playbookService.GetSuggestionsAsync(
|
||||
situation,
|
||||
maxSuggestions: 3,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return suggestions
|
||||
.Select(s => new Tactic
|
||||
{
|
||||
TacticId = $"tactic-{s.Action}",
|
||||
Name = s.Action.ToString(),
|
||||
Description = s.Rationale,
|
||||
Conditions = s.MatchingFactors,
|
||||
RecommendedAction = s.Action,
|
||||
Confidence = s.Confidence,
|
||||
SuccessRate = s.SuccessRate
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get playbook suggestions");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private Task<ImmutableArray<KnownIssue>> GetKnownIssuesAsync(
|
||||
string cveId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// This would integrate with a known issues database
|
||||
// For now, return empty - the actual implementation would query a separate store
|
||||
_ = cancellationToken;
|
||||
_logger.LogDebug("Getting known issues for {CveId}", cveId);
|
||||
return Task.FromResult(ImmutableArray<KnownIssue>.Empty);
|
||||
}
|
||||
|
||||
private static string BuildPromptSegment(
|
||||
ImmutableArray<PastDecisionSummary> decisions,
|
||||
ImmutableArray<Tactic> tactics,
|
||||
ImmutableArray<KnownIssue> issues)
|
||||
{
|
||||
if (decisions.Length == 0 && tactics.Length == 0 && issues.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("## Previous Similar Situations (from OpsMemory)");
|
||||
sb.AppendLine();
|
||||
|
||||
if (decisions.Length > 0)
|
||||
{
|
||||
sb.AppendLine("### Past Decisions");
|
||||
foreach (var decision in decisions)
|
||||
{
|
||||
var outcomeEmoji = decision.OutcomeStatus switch
|
||||
{
|
||||
OutcomeStatus.Success => "[SUCCESS]",
|
||||
OutcomeStatus.Failure => "[FAILED]",
|
||||
OutcomeStatus.PartialSuccess => "[PARTIAL]",
|
||||
_ => "[PENDING]"
|
||||
};
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$"- {decision.CveId ?? "Unknown CVE"} ({decision.Severity ?? "?"} severity): " +
|
||||
$"**{decision.Action}** {outcomeEmoji}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decision.Rationale))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" Rationale: {decision.Rationale}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decision.LessonsLearned))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" Lessons: {decision.LessonsLearned}");
|
||||
}
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$" Reference: [ops-mem:{decision.MemoryId}]");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (tactics.Length > 0)
|
||||
{
|
||||
sb.AppendLine("### Applicable Playbook Tactics");
|
||||
foreach (var tactic in tactics)
|
||||
{
|
||||
var successRate = tactic.SuccessRate.HasValue
|
||||
? $" ({tactic.SuccessRate.Value:P0} success rate)"
|
||||
: "";
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$"- **{tactic.Name}**: {tactic.Description}{successRate}");
|
||||
|
||||
if (tactic.Conditions.Length > 0)
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$" When: {string.Join(", ", tactic.Conditions)}");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (issues.Length > 0)
|
||||
{
|
||||
sb.AppendLine("### Known Issues");
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$"- **{issue.Title}**: {issue.Description}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(issue.RecommendedAction))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$" Recommended: {issue.RecommendedAction}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string GenerateMemoryId()
|
||||
{
|
||||
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
var random = Random.Shared.Next(1000, 9999);
|
||||
return $"om-chat-{timestamp}-{random}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// <copyright file="OpsMemoryContextEnricher.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
|
||||
namespace StellaOps.OpsMemory.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Enriches AI prompt context with OpsMemory data.
|
||||
/// Generates structured prompt segments for past decisions and playbook tactics.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-003
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryContextEnricher
|
||||
{
|
||||
private readonly IOpsMemoryChatProvider _chatProvider;
|
||||
private readonly ILogger<OpsMemoryContextEnricher> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new OpsMemoryContextEnricher.
|
||||
/// </summary>
|
||||
public OpsMemoryContextEnricher(
|
||||
IOpsMemoryChatProvider chatProvider,
|
||||
ILogger<OpsMemoryContextEnricher> logger)
|
||||
{
|
||||
_chatProvider = chatProvider ?? throw new ArgumentNullException(nameof(chatProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches a chat prompt with OpsMemory context.
|
||||
/// </summary>
|
||||
/// <param name="request">The context request.</param>
|
||||
/// <param name="existingPrompt">Optional existing prompt to augment.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Enriched prompt with OpsMemory context.</returns>
|
||||
public async Task<EnrichedPromptResult> EnrichPromptAsync(
|
||||
ChatContextRequest request,
|
||||
string? existingPrompt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Enriching prompt with OpsMemory for CVE {CveId}",
|
||||
request.CveId ?? "(none)");
|
||||
|
||||
var context = await _chatProvider.EnrichContextAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var systemPromptAddition = BuildSystemPromptAddition(context);
|
||||
var contextBlock = BuildContextBlock(context);
|
||||
|
||||
var enrichedPrompt = string.IsNullOrWhiteSpace(existingPrompt)
|
||||
? contextBlock
|
||||
: $"{existingPrompt}\n\n{contextBlock}";
|
||||
|
||||
return new EnrichedPromptResult
|
||||
{
|
||||
EnrichedPrompt = enrichedPrompt,
|
||||
SystemPromptAddition = systemPromptAddition,
|
||||
Context = context,
|
||||
DecisionsReferenced = context.SimilarDecisions.Select(d => d.MemoryId).ToImmutableArray(),
|
||||
TacticsApplied = context.ApplicableTactics.Select(t => t.TacticId).ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a system prompt addition for OpsMemory-aware responses.
|
||||
/// </summary>
|
||||
public static string BuildSystemPromptAddition(OpsMemoryContext context)
|
||||
{
|
||||
if (!context.HasPlaybookEntries)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("## OpsMemory Instructions");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("You have access to the organization's institutional decision memory (OpsMemory).");
|
||||
sb.AppendLine("When providing recommendations:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("1. Reference past decisions using `[ops-mem:ID]` format");
|
||||
sb.AppendLine("2. Explain how past outcomes inform current recommendations");
|
||||
sb.AppendLine("3. Note any lessons learned from similar situations");
|
||||
sb.AppendLine("4. If a past approach failed, suggest alternatives");
|
||||
sb.AppendLine("5. Include confidence levels based on historical success rates");
|
||||
sb.AppendLine();
|
||||
|
||||
if (context.SimilarDecisions.Length > 0)
|
||||
{
|
||||
sb.AppendLine($"Available past decisions: {context.SimilarDecisions.Length} similar situations found.");
|
||||
}
|
||||
|
||||
if (context.ApplicableTactics.Length > 0)
|
||||
{
|
||||
sb.AppendLine($"Applicable playbook tactics: {context.ApplicableTactics.Length} tactics available.");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the context block to include in the prompt.
|
||||
/// </summary>
|
||||
public static string BuildContextBlock(OpsMemoryContext context)
|
||||
{
|
||||
if (!context.HasPlaybookEntries)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine("## Institutional Memory (OpsMemory)");
|
||||
sb.AppendLine();
|
||||
|
||||
// Past decisions section
|
||||
if (context.SimilarDecisions.Length > 0)
|
||||
{
|
||||
sb.AppendLine("### Similar Past Decisions");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var decision in context.SimilarDecisions)
|
||||
{
|
||||
FormatDecisionSummary(sb, decision);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Applicable tactics section
|
||||
if (context.ApplicableTactics.Length > 0)
|
||||
{
|
||||
sb.AppendLine("### Playbook Tactics");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var tactic in context.ApplicableTactics)
|
||||
{
|
||||
FormatTactic(sb, tactic);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Known issues section
|
||||
if (context.RelevantKnownIssues.Length > 0)
|
||||
{
|
||||
sb.AppendLine("### Known Issues");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var issue in context.RelevantKnownIssues)
|
||||
{
|
||||
FormatKnownIssue(sb, issue);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("---");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void FormatDecisionSummary(StringBuilder sb, PastDecisionSummary decision)
|
||||
{
|
||||
var outcomeIndicator = decision.OutcomeStatus switch
|
||||
{
|
||||
OutcomeStatus.Success => "[SUCCESS]",
|
||||
OutcomeStatus.Failure => "[FAILED]",
|
||||
OutcomeStatus.PartialSuccess => "[PARTIAL]",
|
||||
_ => "[PENDING]"
|
||||
};
|
||||
|
||||
var similarity = decision.SimilarityScore.ToString("P0", CultureInfo.InvariantCulture);
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$"#### {decision.CveId ?? "Unknown"} - {decision.Action} {outcomeIndicator}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Similarity:** {similarity}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Component:** {decision.Component ?? "Unknown"}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Severity:** {decision.Severity ?? "?"}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Date:** {decision.DecidedAt:yyyy-MM-dd}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decision.Rationale))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Rationale:** {decision.Rationale}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decision.LessonsLearned))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Lessons:** {decision.LessonsLearned}");
|
||||
}
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Reference:** `[ops-mem:{decision.MemoryId}]`");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatTactic(StringBuilder sb, Tactic tactic)
|
||||
{
|
||||
var confidence = tactic.Confidence.ToString("P0", CultureInfo.InvariantCulture);
|
||||
var successRate = tactic.SuccessRate?.ToString("P0", CultureInfo.InvariantCulture) ?? "N/A";
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"#### {tactic.Name}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"{tactic.Description}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Recommended Action:** {tactic.RecommendedAction}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Confidence:** {confidence}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Historical Success Rate:** {successRate}");
|
||||
|
||||
if (tactic.Conditions.Length > 0)
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$"- **Conditions:** {string.Join(", ", tactic.Conditions)}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatKnownIssue(StringBuilder sb, KnownIssue issue)
|
||||
{
|
||||
var relevance = issue.Relevance.ToString("P0", CultureInfo.InvariantCulture);
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"#### {issue.Title} ({relevance} relevant)");
|
||||
sb.AppendLine();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(issue.Description))
|
||||
{
|
||||
sb.AppendLine(issue.Description);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(issue.RecommendedAction))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"**Recommended:** {issue.RecommendedAction}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of prompt enrichment with OpsMemory context.
|
||||
/// </summary>
|
||||
public sealed record EnrichedPromptResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the enriched prompt with OpsMemory context.
|
||||
/// </summary>
|
||||
public required string EnrichedPrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional content for the system prompt.
|
||||
/// </summary>
|
||||
public string? SystemPromptAddition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full OpsMemory context used for enrichment.
|
||||
/// </summary>
|
||||
public required OpsMemoryContext Context { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the memory IDs of decisions referenced.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> DecisionsReferenced { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tactic IDs applied.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> TacticsApplied { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any OpsMemory context was added.
|
||||
/// </summary>
|
||||
public bool HasEnrichment => Context.HasPlaybookEntries;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// <copyright file="OpsMemoryDecisionHook.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
|
||||
namespace StellaOps.OpsMemory.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Decision hook that records Findings decisions to OpsMemory for playbook learning.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task: OM-007
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryDecisionHook : IDecisionHook
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly ISimilarityVectorGenerator _vectorGenerator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<OpsMemoryDecisionHook> _logger;
|
||||
|
||||
public OpsMemoryDecisionHook(
|
||||
IOpsMemoryStore store,
|
||||
ISimilarityVectorGenerator vectorGenerator,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<OpsMemoryDecisionHook> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_vectorGenerator = vectorGenerator ?? throw new ArgumentNullException(nameof(vectorGenerator));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task OnDecisionRecordedAsync(
|
||||
DecisionEvent decision,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Recording decision {DecisionId} to OpsMemory for tenant {TenantId}",
|
||||
decision.Id, tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
// Extract situation context from the decision
|
||||
var situation = ExtractSituation(decision);
|
||||
|
||||
// Generate similarity vector for future matching
|
||||
var vector = _vectorGenerator.Generate(situation);
|
||||
|
||||
// Map decision to OpsMemory record
|
||||
var record = new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = $"om-{decision.Id}",
|
||||
TenantId = tenantId,
|
||||
RecordedAt = _timeProvider.GetUtcNow(),
|
||||
Situation = situation,
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = MapDecisionAction(decision.DecisionStatus),
|
||||
Rationale = BuildRationale(decision),
|
||||
DecidedBy = decision.ActorId,
|
||||
DecidedAt = decision.Timestamp,
|
||||
PolicyReference = decision.PolicyContext,
|
||||
VexStatementId = null, // Would be extracted from evidence if available
|
||||
Mitigation = null
|
||||
},
|
||||
Outcome = null, // Outcome recorded later via OutcomeTrackingService
|
||||
SimilarityVector = vector
|
||||
};
|
||||
|
||||
await _store.RecordDecisionAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Decision {DecisionId} recorded to OpsMemory as {MemoryId}",
|
||||
decision.Id, record.MemoryId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log but don't throw - this is fire-and-forget
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to record decision {DecisionId} to OpsMemory: {Message}",
|
||||
decision.Id, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts situation context from a decision event.
|
||||
/// </summary>
|
||||
private static SituationContext ExtractSituation(DecisionEvent decision)
|
||||
{
|
||||
// Parse alert ID format: tenant|artifact|vuln or similar
|
||||
var parts = decision.AlertId.Split('|');
|
||||
var vulnId = parts.Length > 2 ? parts[2] : null;
|
||||
|
||||
// Extract CVE from vulnerability ID if present
|
||||
string? cveId = null;
|
||||
if (vulnId?.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
cveId = vulnId;
|
||||
}
|
||||
|
||||
return new SituationContext
|
||||
{
|
||||
CveId = cveId,
|
||||
Component = null, // Would be extracted from evidence bundle
|
||||
ComponentName = null,
|
||||
ComponentVersion = null,
|
||||
Severity = null, // Would be extracted from finding data
|
||||
CvssScore = null,
|
||||
Reachability = ReachabilityStatus.Unknown,
|
||||
EpssScore = null,
|
||||
IsKev = false,
|
||||
ContextTags = ImmutableArray<string>.Empty,
|
||||
AdditionalContext = ImmutableDictionary<string, string>.Empty
|
||||
.Add("artifact_id", decision.ArtifactId)
|
||||
.Add("alert_id", decision.AlertId)
|
||||
.Add("reason_code", decision.ReasonCode)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps decision status to OpsMemory decision action.
|
||||
/// </summary>
|
||||
private static DecisionAction MapDecisionAction(string decisionStatus)
|
||||
{
|
||||
return decisionStatus.ToLowerInvariant() switch
|
||||
{
|
||||
"affected" => DecisionAction.Remediate,
|
||||
"not_affected" => DecisionAction.Accept,
|
||||
"under_investigation" => DecisionAction.Defer,
|
||||
_ => DecisionAction.Defer
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a rationale string from decision data.
|
||||
/// </summary>
|
||||
private static string BuildRationale(DecisionEvent decision)
|
||||
{
|
||||
var parts = new List<string>
|
||||
{
|
||||
$"Status: {decision.DecisionStatus}",
|
||||
$"Reason: {decision.ReasonCode}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decision.ReasonText))
|
||||
{
|
||||
parts.Add($"Details: {decision.ReasonText}");
|
||||
}
|
||||
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
}
|
||||
@@ -296,5 +296,8 @@ public enum OutcomeStatus
|
||||
NegativeOutcome,
|
||||
|
||||
/// <summary>Outcome is still pending.</summary>
|
||||
Pending
|
||||
Pending,
|
||||
|
||||
/// <summary>Decision failed to execute.</summary>
|
||||
Failure
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// <copyright file="IPlaybookSuggestionService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.OpsMemory.Models;
|
||||
|
||||
namespace StellaOps.OpsMemory.Playbook;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating playbook suggestions based on past decisions.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-002 (extracted interface)
|
||||
/// </summary>
|
||||
public interface IPlaybookSuggestionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets playbook suggestions for a given situation.
|
||||
/// </summary>
|
||||
/// <param name="request">The suggestion request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Playbook suggestions ordered by confidence.</returns>
|
||||
Task<PlaybookSuggestionResult> GetSuggestionsAsync(
|
||||
PlaybookSuggestionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets playbook suggestions for a situation context.
|
||||
/// </summary>
|
||||
/// <param name="situation">The situation to analyze.</param>
|
||||
/// <param name="maxSuggestions">Maximum suggestions to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Playbook suggestions.</returns>
|
||||
Task<IReadOnlyList<PlaybookSuggestion>> GetSuggestionsAsync(
|
||||
SituationContext situation,
|
||||
int maxSuggestions,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace StellaOps.OpsMemory.Playbook;
|
||||
/// Service for generating playbook suggestions based on past decisions.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-005
|
||||
/// </summary>
|
||||
public sealed class PlaybookSuggestionService
|
||||
public sealed class PlaybookSuggestionService : IPlaybookSuggestionService
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly SimilarityVectorGenerator _vectorGenerator;
|
||||
@@ -95,6 +95,25 @@ public sealed class PlaybookSuggestionService
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PlaybookSuggestion>> GetSuggestionsAsync(
|
||||
SituationContext situation,
|
||||
int maxSuggestions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Create a default request with tenant placeholder
|
||||
// In real use, the tenant would be extracted from context
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "default",
|
||||
Situation = situation,
|
||||
MaxSuggestions = maxSuggestions
|
||||
};
|
||||
|
||||
var result = await GetSuggestionsAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return result.Suggestions;
|
||||
}
|
||||
|
||||
private ImmutableArray<PlaybookSuggestion> GroupAndRankSuggestions(
|
||||
SituationContext currentSituation,
|
||||
IReadOnlyList<SimilarityMatch> similarRecords,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// <copyright file="ISimilarityVectorGenerator.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
|
||||
namespace StellaOps.OpsMemory.Similarity;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for generating similarity vectors from situation contexts.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-004
|
||||
/// </summary>
|
||||
public interface ISimilarityVectorGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a similarity vector from a situation context.
|
||||
/// </summary>
|
||||
/// <param name="situation">The situation to vectorize.</param>
|
||||
/// <returns>A normalized similarity vector.</returns>
|
||||
ImmutableArray<float> Generate(SituationContext situation);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the factors that contributed to similarity between two situations.
|
||||
/// </summary>
|
||||
/// <param name="a">First situation.</param>
|
||||
/// <param name="b">Second situation.</param>
|
||||
/// <returns>List of matching factors.</returns>
|
||||
ImmutableArray<string> GetMatchingFactors(SituationContext a, SituationContext b);
|
||||
}
|
||||
@@ -13,4 +13,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Findings\StellaOps.Findings.Ledger\StellaOps.Findings.Ledger.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// <copyright file="IKnownIssueStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
|
||||
namespace StellaOps.OpsMemory.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for known issues.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-007
|
||||
/// </summary>
|
||||
public interface IKnownIssueStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new known issue.
|
||||
/// </summary>
|
||||
/// <param name="issue">The issue to create.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created issue with assigned ID.</returns>
|
||||
Task<KnownIssue> CreateAsync(
|
||||
KnownIssue issue,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing known issue.
|
||||
/// </summary>
|
||||
/// <param name="issue">The issue to update.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated issue.</returns>
|
||||
Task<KnownIssue?> UpdateAsync(
|
||||
KnownIssue issue,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a known issue by ID.
|
||||
/// </summary>
|
||||
/// <param name="issueId">The issue ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The issue or null if not found.</returns>
|
||||
Task<KnownIssue?> GetByIdAsync(
|
||||
string issueId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds known issues by context (CVE, component, or tags).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cveId">Optional CVE ID to match.</param>
|
||||
/// <param name="component">Optional component PURL to match.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Matching known issues with relevance scores.</returns>
|
||||
Task<ImmutableArray<KnownIssue>> FindByContextAsync(
|
||||
string tenantId,
|
||||
string? cveId,
|
||||
string? component,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all known issues for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="limit">Maximum number of issues to return.</param>
|
||||
/// <param name="offset">Number of issues to skip.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated list of known issues.</returns>
|
||||
Task<ImmutableArray<KnownIssue>> ListAsync(
|
||||
string tenantId,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a known issue.
|
||||
/// </summary>
|
||||
/// <param name="issueId">The issue ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteAsync(
|
||||
string issueId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
139
src/OpsMemory/StellaOps.OpsMemory/Storage/ITacticStore.cs
Normal file
139
src/OpsMemory/StellaOps.OpsMemory/Storage/ITacticStore.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
// <copyright file="ITacticStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
|
||||
namespace StellaOps.OpsMemory.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for playbook tactics.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-007
|
||||
/// </summary>
|
||||
public interface ITacticStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new tactic.
|
||||
/// </summary>
|
||||
/// <param name="tactic">The tactic to create.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created tactic with assigned ID.</returns>
|
||||
Task<Tactic> CreateAsync(
|
||||
Tactic tactic,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing tactic.
|
||||
/// </summary>
|
||||
/// <param name="tactic">The tactic to update.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated tactic.</returns>
|
||||
Task<Tactic?> UpdateAsync(
|
||||
Tactic tactic,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a tactic by ID.
|
||||
/// </summary>
|
||||
/// <param name="tacticId">The tactic ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The tactic or null if not found.</returns>
|
||||
Task<Tactic?> GetByIdAsync(
|
||||
string tacticId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds tactics matching the given trigger conditions.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="trigger">The trigger conditions to match.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Matching tactics ordered by confidence.</returns>
|
||||
Task<ImmutableArray<Tactic>> FindByTriggerAsync(
|
||||
string tenantId,
|
||||
TacticTrigger trigger,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all tactics for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="limit">Maximum number of tactics to return.</param>
|
||||
/// <param name="offset">Number of tactics to skip.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated list of tactics.</returns>
|
||||
Task<ImmutableArray<Tactic>> ListAsync(
|
||||
string tenantId,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records usage of a tactic (updates usage count and success rate).
|
||||
/// </summary>
|
||||
/// <param name="tacticId">The tactic ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="wasSuccessful">Whether the tactic application was successful.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated tactic.</returns>
|
||||
Task<Tactic?> RecordUsageAsync(
|
||||
string tacticId,
|
||||
string tenantId,
|
||||
bool wasSuccessful,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a tactic.
|
||||
/// </summary>
|
||||
/// <param name="tacticId">The tactic ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteAsync(
|
||||
string tacticId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger conditions for matching tactics.
|
||||
/// </summary>
|
||||
public sealed record TacticTrigger
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the severities to match.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Severities { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVE categories to match.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> CveCategories { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to require reachability.
|
||||
/// </summary>
|
||||
public bool? RequiresReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum EPSS score.
|
||||
/// </summary>
|
||||
public double? MinEpssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum CVSS score.
|
||||
/// </summary>
|
||||
public double? MinCvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets context tags to match.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ContextTags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
// <copyright file="OpsMemoryChatProviderIntegrationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for OpsMemoryChatProvider.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class OpsMemoryChatProviderIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private const string ConnectionString = "Host=localhost;Port=5433;Database=stellaops_test;Username=stellaops_ci;Password=ci_test_password";
|
||||
|
||||
private NpgsqlDataSource? _dataSource;
|
||||
private PostgresOpsMemoryStore? _store;
|
||||
private OpsMemoryChatProvider? _chatProvider;
|
||||
private SimilarityVectorGenerator? _vectorGenerator;
|
||||
private string _testTenantId = string.Empty;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_dataSource = NpgsqlDataSource.Create(ConnectionString);
|
||||
_store = new PostgresOpsMemoryStore(
|
||||
_dataSource,
|
||||
NullLogger<PostgresOpsMemoryStore>.Instance);
|
||||
|
||||
_vectorGenerator = new SimilarityVectorGenerator();
|
||||
|
||||
// Create chat provider with mock stores for known issues and tactics
|
||||
_chatProvider = new OpsMemoryChatProvider(
|
||||
_store,
|
||||
new NullKnownIssueStore(),
|
||||
new NullTacticStore(),
|
||||
_vectorGenerator,
|
||||
NullLogger<OpsMemoryChatProvider>.Instance);
|
||||
|
||||
_testTenantId = $"test-{Guid.NewGuid()}";
|
||||
|
||||
// Clean up any existing test data
|
||||
await using var cmd = _dataSource.CreateCommand("DELETE FROM opsmemory.decisions WHERE tenant_id LIKE 'test-%'");
|
||||
await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_store != null)
|
||||
{
|
||||
await _store.DisposeAsync();
|
||||
}
|
||||
|
||||
if (_dataSource != null)
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContext_WithNoHistory_ReturnsEmptyContext()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
CveId = "CVE-2024-9999",
|
||||
Severity = "HIGH",
|
||||
Reachability = ReachabilityStatus.Reachable
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = await _chatProvider!.EnrichContextAsync(request, ct);
|
||||
|
||||
// Assert
|
||||
context.SimilarDecisions.Should().BeEmpty();
|
||||
context.HasPlaybookEntries.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContext_WithSimilarDecisions_ReturnsSortedBySimilarity()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Arrange - Create decisions with different similarity levels
|
||||
var record1 = await CreateAndStoreDecision(
|
||||
_testTenantId, "CVE-2024-1234", "pkg:npm/test@1.0.0", "HIGH",
|
||||
DecisionAction.Remediate, OutcomeStatus.Success, now);
|
||||
|
||||
var record2 = await CreateAndStoreDecision(
|
||||
_testTenantId, "CVE-2024-5678", "pkg:npm/test@1.0.0", "CRITICAL",
|
||||
DecisionAction.Quarantine, OutcomeStatus.Success, now);
|
||||
|
||||
var record3 = await CreateAndStoreDecision(
|
||||
_testTenantId, "CVE-2024-9012", "pkg:maven/other@2.0.0", "LOW",
|
||||
DecisionAction.Accept, OutcomeStatus.Success, now);
|
||||
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
CveId = "CVE-2024-NEW1",
|
||||
Component = "pkg:npm/test@1.0.0",
|
||||
Severity = "HIGH",
|
||||
Reachability = ReachabilityStatus.Reachable,
|
||||
MaxSuggestions = 3,
|
||||
MinSimilarity = 0.3
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = await _chatProvider!.EnrichContextAsync(request, ct);
|
||||
|
||||
// Assert
|
||||
context.SimilarDecisions.Should().NotBeEmpty();
|
||||
context.HasPlaybookEntries.Should().BeTrue();
|
||||
|
||||
// Similar npm/HIGH decisions should rank higher than maven/LOW
|
||||
if (context.SimilarDecisions.Length >= 2)
|
||||
{
|
||||
context.SimilarDecisions[0].SimilarityScore.Should()
|
||||
.BeGreaterThanOrEqualTo(context.SimilarDecisions[1].SimilarityScore);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContext_FiltersOutFailedDecisions()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Arrange - Create one successful and one failed decision
|
||||
await CreateAndStoreDecision(
|
||||
_testTenantId, "CVE-SUCCESS-001", "pkg:npm/test@1.0.0", "HIGH",
|
||||
DecisionAction.Defer, OutcomeStatus.Success, now);
|
||||
|
||||
await CreateAndStoreDecision(
|
||||
_testTenantId, "CVE-FAILURE-001", "pkg:npm/test@1.0.0", "HIGH",
|
||||
DecisionAction.Accept, OutcomeStatus.Failure, now);
|
||||
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
Component = "pkg:npm/test@1.0.0",
|
||||
Severity = "HIGH",
|
||||
MaxSuggestions = 10,
|
||||
MinSimilarity = 0.0
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = await _chatProvider!.EnrichContextAsync(request, ct);
|
||||
|
||||
// Assert - Only successful decisions should be returned
|
||||
context.SimilarDecisions.Should().AllSatisfy(d =>
|
||||
d.OutcomeStatus.Should().BeOneOf(OutcomeStatus.Success, OutcomeStatus.PartialSuccess));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordFromAction_CreatesOpsMemoryRecord()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Arrange
|
||||
var action = new ActionExecutionResult
|
||||
{
|
||||
Action = DecisionAction.Remediate,
|
||||
CveId = "CVE-2024-ACTION-001",
|
||||
Component = "pkg:npm/vulnerable@1.0.0",
|
||||
Success = true,
|
||||
Rationale = "Upgrading to patched version",
|
||||
ExecutedAt = now,
|
||||
ActorId = "user:alice@example.com"
|
||||
};
|
||||
|
||||
var context = new ConversationContext
|
||||
{
|
||||
ConversationId = "conv-123",
|
||||
TenantId = _testTenantId,
|
||||
UserId = "alice@example.com",
|
||||
Topic = "CVE Remediation",
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = "CVE-2024-ACTION-001",
|
||||
Component = "pkg:npm/vulnerable@1.0.0",
|
||||
Severity = "HIGH",
|
||||
Reachability = ReachabilityStatus.Reachable
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var record = await _chatProvider!.RecordFromActionAsync(action, context, ct);
|
||||
|
||||
// Assert
|
||||
record.Should().NotBeNull();
|
||||
record.TenantId.Should().Be(_testTenantId);
|
||||
record.Situation.CveId.Should().Be("CVE-2024-ACTION-001");
|
||||
record.Decision.Action.Should().Be(DecisionAction.Remediate);
|
||||
record.Decision.Rationale.Should().Be("Upgrading to patched version");
|
||||
|
||||
// Verify persisted
|
||||
var retrieved = await _store!.GetByIdAsync(record.MemoryId, _testTenantId, ct);
|
||||
retrieved.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentDecisions_ReturnsOrderedByDate()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Arrange - Create decisions at different times
|
||||
await CreateAndStoreDecision(_testTenantId, "CVE-OLDEST", "pkg:test@1", "LOW",
|
||||
DecisionAction.Accept, null, now.AddDays(-10));
|
||||
|
||||
await CreateAndStoreDecision(_testTenantId, "CVE-MIDDLE", "pkg:test@1", "MEDIUM",
|
||||
DecisionAction.Defer, null, now.AddDays(-5));
|
||||
|
||||
await CreateAndStoreDecision(_testTenantId, "CVE-NEWEST", "pkg:test@1", "HIGH",
|
||||
DecisionAction.Remediate, null, now);
|
||||
|
||||
// Act
|
||||
var recent = await _chatProvider!.GetRecentDecisionsAsync(_testTenantId, 3, ct);
|
||||
|
||||
// Assert
|
||||
recent.Should().HaveCount(3);
|
||||
recent[0].CveId.Should().Be("CVE-NEWEST");
|
||||
recent[1].CveId.Should().Be("CVE-MIDDLE");
|
||||
recent[2].CveId.Should().Be("CVE-OLDEST");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContext_IsTenantIsolated()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Arrange - Create decisions in different tenants
|
||||
var otherTenantId = $"test-other-{Guid.NewGuid()}";
|
||||
|
||||
await CreateAndStoreDecision(_testTenantId, "CVE-TENANT1", "pkg:test@1", "HIGH",
|
||||
DecisionAction.Remediate, OutcomeStatus.Success, now);
|
||||
|
||||
await CreateAndStoreDecision(otherTenantId, "CVE-TENANT2", "pkg:test@1", "HIGH",
|
||||
DecisionAction.Accept, OutcomeStatus.Success, now);
|
||||
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
Severity = "HIGH",
|
||||
MaxSuggestions = 10,
|
||||
MinSimilarity = 0.0
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = await _chatProvider!.EnrichContextAsync(request, ct);
|
||||
|
||||
// Assert - Only decisions from _testTenantId should be returned
|
||||
context.SimilarDecisions.Should().AllSatisfy(d =>
|
||||
d.CveId.Should().Be("CVE-TENANT1"));
|
||||
}
|
||||
|
||||
private async Task<OpsMemoryRecord> CreateAndStoreDecision(
|
||||
string tenantId,
|
||||
string cveId,
|
||||
string component,
|
||||
string severity,
|
||||
DecisionAction action,
|
||||
OutcomeStatus? outcome,
|
||||
DateTimeOffset at)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var situation = new SituationContext
|
||||
{
|
||||
CveId = cveId,
|
||||
Component = component,
|
||||
Severity = severity,
|
||||
Reachability = ReachabilityStatus.Reachable
|
||||
};
|
||||
|
||||
var record = new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = Guid.NewGuid().ToString(),
|
||||
TenantId = tenantId,
|
||||
RecordedAt = at,
|
||||
Situation = situation,
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = action,
|
||||
Rationale = $"Test decision for {cveId}",
|
||||
DecidedBy = "test",
|
||||
DecidedAt = at
|
||||
},
|
||||
SimilarityVector = _vectorGenerator!.Generate(situation)
|
||||
};
|
||||
|
||||
await _store!.RecordDecisionAsync(record, ct);
|
||||
|
||||
if (outcome.HasValue)
|
||||
{
|
||||
await _store.RecordOutcomeAsync(record.MemoryId, tenantId, new OutcomeRecord
|
||||
{
|
||||
Status = outcome.Value,
|
||||
RecordedBy = "test",
|
||||
RecordedAt = at.AddDays(1)
|
||||
}, ct);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IKnownIssueStore for testing.
|
||||
/// </summary>
|
||||
private sealed class NullKnownIssueStore : IKnownIssueStore
|
||||
{
|
||||
public Task<KnownIssue> CreateAsync(KnownIssue issue, CancellationToken ct) =>
|
||||
Task.FromResult(issue);
|
||||
|
||||
public Task<KnownIssue?> UpdateAsync(KnownIssue issue, CancellationToken ct) =>
|
||||
Task.FromResult<KnownIssue?>(issue);
|
||||
|
||||
public Task<KnownIssue?> GetByIdAsync(string issueId, string tenantId, CancellationToken ct) =>
|
||||
Task.FromResult<KnownIssue?>(null);
|
||||
|
||||
public Task<ImmutableArray<KnownIssue>> FindByContextAsync(
|
||||
string tenantId, string? cveId, string? component, CancellationToken ct) =>
|
||||
Task.FromResult(ImmutableArray<KnownIssue>.Empty);
|
||||
|
||||
public Task<ImmutableArray<KnownIssue>> ListAsync(
|
||||
string tenantId, int limit, int offset, CancellationToken ct) =>
|
||||
Task.FromResult(ImmutableArray<KnownIssue>.Empty);
|
||||
|
||||
public Task<bool> DeleteAsync(string issueId, string tenantId, CancellationToken ct) =>
|
||||
Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of ITacticStore for testing.
|
||||
/// </summary>
|
||||
private sealed class NullTacticStore : ITacticStore
|
||||
{
|
||||
public Task<Tactic> CreateAsync(Tactic tactic, string tenantId, CancellationToken ct) =>
|
||||
Task.FromResult(tactic);
|
||||
|
||||
public Task<Tactic?> UpdateAsync(Tactic tactic, string tenantId, CancellationToken ct) =>
|
||||
Task.FromResult<Tactic?>(tactic);
|
||||
|
||||
public Task<Tactic?> GetByIdAsync(string tacticId, string tenantId, CancellationToken ct) =>
|
||||
Task.FromResult<Tactic?>(null);
|
||||
|
||||
public Task<ImmutableArray<Tactic>> FindByTriggerAsync(
|
||||
string tenantId, TacticTrigger trigger, CancellationToken ct) =>
|
||||
Task.FromResult(ImmutableArray<Tactic>.Empty);
|
||||
|
||||
public Task<ImmutableArray<Tactic>> ListAsync(
|
||||
string tenantId, int limit, int offset, CancellationToken ct) =>
|
||||
Task.FromResult(ImmutableArray<Tactic>.Empty);
|
||||
|
||||
public Task<Tactic?> RecordUsageAsync(
|
||||
string tacticId, string tenantId, bool wasSuccessful, CancellationToken ct) =>
|
||||
Task.FromResult<Tactic?>(null);
|
||||
|
||||
public Task<bool> DeleteAsync(string tacticId, string tenantId, CancellationToken ct) =>
|
||||
Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// <copyright file="OpsMemoryChatProviderTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Playbook;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Unit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for OpsMemoryChatProvider.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpsMemoryChatProviderTests
|
||||
{
|
||||
private readonly Mock<IOpsMemoryStore> _storeMock;
|
||||
private readonly Mock<ISimilarityVectorGenerator> _vectorGeneratorMock;
|
||||
private readonly Mock<IPlaybookSuggestionService> _playbookMock;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly OpsMemoryChatProvider _sut;
|
||||
|
||||
public OpsMemoryChatProviderTests()
|
||||
{
|
||||
_storeMock = new Mock<IOpsMemoryStore>();
|
||||
_vectorGeneratorMock = new Mock<ISimilarityVectorGenerator>();
|
||||
_playbookMock = new Mock<IPlaybookSuggestionService>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_sut = new OpsMemoryChatProvider(
|
||||
_storeMock.Object,
|
||||
_vectorGeneratorMock.Object,
|
||||
_playbookMock.Object,
|
||||
_timeProvider,
|
||||
NullLogger<OpsMemoryChatProvider>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContextAsync_WithSimilarDecisions_ReturnsSummaries()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
CveId = "CVE-2021-44228",
|
||||
Severity = "Critical",
|
||||
MaxSuggestions = 3
|
||||
};
|
||||
|
||||
var pastRecord = CreateTestRecord("om-001", "CVE-2021-44227", OutcomeStatus.Success);
|
||||
var similarMatches = new List<SimilarityMatch>
|
||||
{
|
||||
new SimilarityMatch { Record = pastRecord, SimilarityScore = 0.85 }
|
||||
};
|
||||
|
||||
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
|
||||
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(similarMatches);
|
||||
|
||||
_playbookMock.Setup(p => p.GetSuggestionsAsync(It.IsAny<SituationContext>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<PlaybookSuggestion>());
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichContextAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.SimilarDecisions);
|
||||
Assert.Equal("om-001", result.SimilarDecisions[0].MemoryId);
|
||||
Assert.Equal(0.85, result.SimilarDecisions[0].SimilarityScore);
|
||||
Assert.Equal(OutcomeStatus.Success, result.SimilarDecisions[0].OutcomeStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContextAsync_WithNoMatches_ReturnsEmptyContext()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
CveId = "CVE-2099-99999",
|
||||
MaxSuggestions = 3
|
||||
};
|
||||
|
||||
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
|
||||
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<SimilarityMatch>());
|
||||
|
||||
_playbookMock.Setup(p => p.GetSuggestionsAsync(It.IsAny<SituationContext>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<PlaybookSuggestion>());
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichContextAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.SimilarDecisions);
|
||||
Assert.Empty(result.ApplicableTactics);
|
||||
Assert.False(result.HasPlaybookEntries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContextAsync_OrdersBySimilarityThenOutcome()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
MaxSuggestions = 3
|
||||
};
|
||||
|
||||
var similarMatches = new List<SimilarityMatch>
|
||||
{
|
||||
new SimilarityMatch
|
||||
{
|
||||
Record = CreateTestRecord("om-001", "CVE-1", OutcomeStatus.Failure),
|
||||
SimilarityScore = 0.9
|
||||
},
|
||||
new SimilarityMatch
|
||||
{
|
||||
Record = CreateTestRecord("om-002", "CVE-2", OutcomeStatus.Success),
|
||||
SimilarityScore = 0.9
|
||||
},
|
||||
new SimilarityMatch
|
||||
{
|
||||
Record = CreateTestRecord("om-003", "CVE-3", OutcomeStatus.Success),
|
||||
SimilarityScore = 0.8
|
||||
}
|
||||
};
|
||||
|
||||
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
|
||||
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(similarMatches);
|
||||
|
||||
_playbookMock.Setup(p => p.GetSuggestionsAsync(It.IsAny<SituationContext>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<PlaybookSuggestion>());
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichContextAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.SimilarDecisions.Length);
|
||||
// Highest similarity with success outcome should be first
|
||||
Assert.Equal("om-002", result.SimilarDecisions[0].MemoryId);
|
||||
Assert.Equal("om-001", result.SimilarDecisions[1].MemoryId);
|
||||
Assert.Equal("om-003", result.SimilarDecisions[2].MemoryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordFromActionAsync_CreatesValidRecord()
|
||||
{
|
||||
// Arrange
|
||||
var action = new ActionExecutionResult
|
||||
{
|
||||
Action = DecisionAction.AcceptRisk,
|
||||
CveId = "CVE-2021-44228",
|
||||
Component = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Success = true,
|
||||
Rationale = "Risk accepted due to air-gapped environment",
|
||||
ExecutedAt = _timeProvider.GetUtcNow(),
|
||||
ActorId = "user-123"
|
||||
};
|
||||
|
||||
var conversationContext = new ConversationContext
|
||||
{
|
||||
ConversationId = "conv-001",
|
||||
TenantId = "tenant-1",
|
||||
UserId = "user-123",
|
||||
TurnNumber = 5
|
||||
};
|
||||
|
||||
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
|
||||
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
|
||||
|
||||
OpsMemoryRecord? capturedRecord = null;
|
||||
_storeMock.Setup(s => s.RecordDecisionAsync(It.IsAny<OpsMemoryRecord>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<OpsMemoryRecord, CancellationToken>((r, _) => capturedRecord = r)
|
||||
.ReturnsAsync((OpsMemoryRecord r, CancellationToken _) => r);
|
||||
|
||||
// Act
|
||||
var result = await _sut.RecordFromActionAsync(action, conversationContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.StartsWith("om-chat-", result.MemoryId);
|
||||
Assert.Equal("tenant-1", result.TenantId);
|
||||
Assert.Equal(DecisionAction.AcceptRisk, result.Decision.Action);
|
||||
Assert.Equal("user-123", result.Decision.DecidedBy);
|
||||
Assert.Contains("Risk accepted", result.Decision.Rationale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentDecisionsAsync_ReturnsFormattedSummaries()
|
||||
{
|
||||
// Arrange
|
||||
var records = new PagedResult<OpsMemoryRecord>
|
||||
{
|
||||
Items = ImmutableArray.Create(
|
||||
CreateTestRecord("om-001", "CVE-2021-44228", OutcomeStatus.Success),
|
||||
CreateTestRecord("om-002", "CVE-2021-44229", OutcomeStatus.Failure)
|
||||
),
|
||||
TotalCount = 2,
|
||||
HasMore = false
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.QueryAsync(It.IsAny<OpsMemoryQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(records);
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetRecentDecisionsAsync("tenant-1", 10, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("om-001", result[0].MemoryId);
|
||||
Assert.Equal("om-002", result[1].MemoryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContextAsync_GeneratesPromptSegment()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
CveId = "CVE-2021-44228",
|
||||
MaxSuggestions = 3
|
||||
};
|
||||
|
||||
var pastRecord = CreateTestRecord("om-001", "CVE-2021-44227", OutcomeStatus.Success);
|
||||
var similarMatches = new List<SimilarityMatch>
|
||||
{
|
||||
new SimilarityMatch { Record = pastRecord, SimilarityScore = 0.85 }
|
||||
};
|
||||
|
||||
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
|
||||
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(similarMatches);
|
||||
|
||||
_playbookMock.Setup(p => p.GetSuggestionsAsync(It.IsAny<SituationContext>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<PlaybookSuggestion>());
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichContextAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.PromptSegment);
|
||||
Assert.Contains("Previous Similar Situations", result.PromptSegment);
|
||||
Assert.Contains("CVE-2021-44227", result.PromptSegment);
|
||||
Assert.Contains("[SUCCESS]", result.PromptSegment);
|
||||
}
|
||||
|
||||
private static OpsMemoryRecord CreateTestRecord(string memoryId, string cveId, OutcomeStatus? outcomeStatus)
|
||||
{
|
||||
return new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
TenantId = "tenant-1",
|
||||
RecordedAt = DateTimeOffset.UtcNow,
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = cveId,
|
||||
Severity = "High",
|
||||
Reachability = ReachabilityStatus.Unknown
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = DecisionAction.AcceptRisk,
|
||||
Rationale = "Test rationale",
|
||||
DecidedBy = "test-user",
|
||||
DecidedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
Outcome = outcomeStatus.HasValue
|
||||
? new OutcomeRecord
|
||||
{
|
||||
Status = outcomeStatus.Value,
|
||||
RecordedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake time provider for testing.
|
||||
/// </summary>
|
||||
public sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset now)
|
||||
{
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
_now = _now.Add(duration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// <copyright file="OpsMemoryContextEnricherTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Unit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for OpsMemoryContextEnricher.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpsMemoryContextEnricherTests
|
||||
{
|
||||
private readonly Mock<IOpsMemoryChatProvider> _chatProviderMock;
|
||||
private readonly OpsMemoryContextEnricher _sut;
|
||||
|
||||
public OpsMemoryContextEnricherTests()
|
||||
{
|
||||
_chatProviderMock = new Mock<IOpsMemoryChatProvider>();
|
||||
_sut = new OpsMemoryContextEnricher(
|
||||
_chatProviderMock.Object,
|
||||
NullLogger<OpsMemoryContextEnricher>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichPromptAsync_WithDecisions_IncludesContextBlock()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
CveId = "CVE-2021-44228"
|
||||
};
|
||||
|
||||
var context = new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = ImmutableArray.Create(
|
||||
new PastDecisionSummary
|
||||
{
|
||||
MemoryId = "om-001",
|
||||
CveId = "CVE-2021-44227",
|
||||
Action = DecisionAction.AcceptRisk,
|
||||
OutcomeStatus = OutcomeStatus.Success,
|
||||
SimilarityScore = 0.85,
|
||||
DecidedAt = DateTimeOffset.UtcNow,
|
||||
Rationale = "Air-gapped environment"
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(context);
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichPromptAsync(request, existingPrompt: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasEnrichment);
|
||||
Assert.Contains("Institutional Memory", result.EnrichedPrompt);
|
||||
Assert.Contains("CVE-2021-44227", result.EnrichedPrompt);
|
||||
Assert.Contains("AcceptRisk", result.EnrichedPrompt);
|
||||
Assert.Contains("[SUCCESS]", result.EnrichedPrompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichPromptAsync_WithExistingPrompt_AppendsContextBlock()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest { TenantId = "tenant-1" };
|
||||
var existingPrompt = "User asks about vulnerability remediation.";
|
||||
|
||||
var context = new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = ImmutableArray.Create(
|
||||
new PastDecisionSummary
|
||||
{
|
||||
MemoryId = "om-001",
|
||||
CveId = "CVE-2021-44228",
|
||||
Action = DecisionAction.Remediate,
|
||||
OutcomeStatus = OutcomeStatus.Success,
|
||||
SimilarityScore = 0.9,
|
||||
DecidedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(context);
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichPromptAsync(request, existingPrompt, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.StartsWith("User asks about vulnerability remediation.", result.EnrichedPrompt);
|
||||
Assert.Contains("Institutional Memory", result.EnrichedPrompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichPromptAsync_WithNoEntries_ReturnsEmptyEnrichment()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest { TenantId = "tenant-1" };
|
||||
|
||||
var emptyContext = new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = ImmutableArray<PastDecisionSummary>.Empty,
|
||||
ApplicableTactics = ImmutableArray<Tactic>.Empty,
|
||||
RelevantKnownIssues = ImmutableArray<KnownIssue>.Empty
|
||||
};
|
||||
|
||||
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(emptyContext);
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichPromptAsync(request, existingPrompt: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasEnrichment);
|
||||
Assert.Empty(result.EnrichedPrompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichPromptAsync_WithTactics_IncludesPlaybookSection()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest { TenantId = "tenant-1" };
|
||||
|
||||
var context = new OpsMemoryContext
|
||||
{
|
||||
ApplicableTactics = ImmutableArray.Create(
|
||||
new Tactic
|
||||
{
|
||||
TacticId = "tac-001",
|
||||
Name = "Immediate Patch",
|
||||
Description = "Apply vendor patch immediately for critical vulnerabilities",
|
||||
RecommendedAction = DecisionAction.Remediate,
|
||||
Confidence = 0.9,
|
||||
SuccessRate = 0.95
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(context);
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichPromptAsync(request, existingPrompt: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasEnrichment);
|
||||
Assert.Contains("Playbook Tactics", result.EnrichedPrompt);
|
||||
Assert.Contains("Immediate Patch", result.EnrichedPrompt);
|
||||
Assert.Contains("95%", result.EnrichedPrompt); // Success rate formatted as percentage
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSystemPromptAddition_WithPlaybookEntries_IncludesInstructions()
|
||||
{
|
||||
// Arrange
|
||||
var context = new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = ImmutableArray.Create(
|
||||
new PastDecisionSummary
|
||||
{
|
||||
MemoryId = "om-001",
|
||||
CveId = "CVE-2021-44228",
|
||||
Action = DecisionAction.AcceptRisk,
|
||||
SimilarityScore = 0.85,
|
||||
DecidedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
),
|
||||
ApplicableTactics = ImmutableArray.Create(
|
||||
new Tactic
|
||||
{
|
||||
TacticId = "tac-001",
|
||||
Name = "Test Tactic",
|
||||
Description = "Test description",
|
||||
Confidence = 0.8
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = OpsMemoryContextEnricher.BuildSystemPromptAddition(context);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("OpsMemory Instructions", result);
|
||||
Assert.Contains("[ops-mem:ID]", result);
|
||||
Assert.Contains("1 similar situations", result);
|
||||
Assert.Contains("1 tactics available", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildContextBlock_WithLessonsLearned_IncludesLessons()
|
||||
{
|
||||
// Arrange
|
||||
var context = new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = ImmutableArray.Create(
|
||||
new PastDecisionSummary
|
||||
{
|
||||
MemoryId = "om-001",
|
||||
CveId = "CVE-2021-44228",
|
||||
Action = DecisionAction.AcceptRisk,
|
||||
OutcomeStatus = OutcomeStatus.Failure,
|
||||
SimilarityScore = 0.85,
|
||||
DecidedAt = DateTimeOffset.UtcNow,
|
||||
LessonsLearned = "Should have patched despite low priority"
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = OpsMemoryContextEnricher.BuildContextBlock(context);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("[FAILED]", result);
|
||||
Assert.Contains("Should have patched", result);
|
||||
Assert.Contains("Lessons:", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichPromptAsync_TracksReferencedMemoryIds()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest { TenantId = "tenant-1" };
|
||||
|
||||
var context = new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = ImmutableArray.Create(
|
||||
new PastDecisionSummary
|
||||
{
|
||||
MemoryId = "om-001",
|
||||
CveId = "CVE-1",
|
||||
Action = DecisionAction.AcceptRisk,
|
||||
SimilarityScore = 0.9,
|
||||
DecidedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new PastDecisionSummary
|
||||
{
|
||||
MemoryId = "om-002",
|
||||
CveId = "CVE-2",
|
||||
Action = DecisionAction.Remediate,
|
||||
SimilarityScore = 0.8,
|
||||
DecidedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(context);
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichPromptAsync(request, existingPrompt: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.DecisionsReferenced.Length);
|
||||
Assert.Contains("om-001", result.DecisionsReferenced);
|
||||
Assert.Contains("om-002", result.DecisionsReferenced);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright © 2025-2026 StellaOps
|
||||
// Sprint: SPRINT_20260109_009_005_BE_vex_decision_integration
|
||||
// Task: Integration tests for VEX decision with hybrid reachability
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Vex;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for VEX decision emission with hybrid reachability evidence.
|
||||
/// Tests the full pipeline from reachability facts to VEX document generation.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Sprint", "009_005")]
|
||||
public sealed class VexDecisionReachabilityIntegrationTests
|
||||
{
|
||||
private const string TestTenantId = "integration-test-tenant";
|
||||
private const string TestAuthor = "vex-emitter@stellaops.test";
|
||||
|
||||
#region End-to-End Pipeline Tests
|
||||
|
||||
[Fact(DisplayName = "Pipeline emits VEX for multiple findings with varying reachability states")]
|
||||
public async Task Pipeline_EmitsVex_ForMultipleFindingsWithVaryingStates()
|
||||
{
|
||||
// Arrange: Create findings with different reachability states
|
||||
var findings = new[]
|
||||
{
|
||||
new VexFindingInput { VulnId = "CVE-2024-0001", Purl = "pkg:npm/lodash@4.17.20" },
|
||||
new VexFindingInput { VulnId = "CVE-2024-0002", Purl = "pkg:maven/log4j/log4j-core@2.14.1" },
|
||||
new VexFindingInput { VulnId = "CVE-2024-0003", Purl = "pkg:pypi/requests@2.25.0" }
|
||||
};
|
||||
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:npm/lodash@4.17.20", "CVE-2024-0001")] = CreateFact(
|
||||
ReachabilityState.Unreachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.95m,
|
||||
latticeState: "CU"),
|
||||
[new(TestTenantId, "pkg:maven/log4j/log4j-core@2.14.1", "CVE-2024-0002")] = CreateFact(
|
||||
ReachabilityState.Reachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.99m,
|
||||
latticeState: "CR"),
|
||||
[new(TestTenantId, "pkg:pypi/requests@2.25.0", "CVE-2024-0003")] = CreateFact(
|
||||
ReachabilityState.Unknown,
|
||||
hasRuntime: false,
|
||||
confidence: 0.0m,
|
||||
latticeState: "U")
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = findings
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Document.Should().NotBeNull();
|
||||
result.Document.Statements.Should().HaveCount(3);
|
||||
result.Blocked.Should().BeEmpty();
|
||||
|
||||
// Verify unreachable -> not_affected
|
||||
var lodashStatement = result.Document.Statements.Single(s => s.VulnId == "CVE-2024-0001");
|
||||
lodashStatement.Status.Should().Be("not_affected");
|
||||
lodashStatement.Justification.Should().Be(VexJustification.VulnerableCodeNotInExecutePath);
|
||||
|
||||
// Verify reachable -> affected
|
||||
var log4jStatement = result.Document.Statements.Single(s => s.VulnId == "CVE-2024-0002");
|
||||
log4jStatement.Status.Should().Be("affected");
|
||||
log4jStatement.Justification.Should().BeNull();
|
||||
|
||||
// Verify unknown -> under_investigation
|
||||
var requestsStatement = result.Document.Statements.Single(s => s.VulnId == "CVE-2024-0003");
|
||||
requestsStatement.Status.Should().Be("under_investigation");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Pipeline preserves evidence hash in VEX metadata")]
|
||||
public async Task Pipeline_PreservesEvidenceHash_InVexMetadata()
|
||||
{
|
||||
// Arrange
|
||||
const string expectedHash = "sha256:abc123def456";
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:npm/vulnerable@1.0.0", "CVE-2024-1000")] = CreateFact(
|
||||
ReachabilityState.Unreachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.92m,
|
||||
latticeState: "CU",
|
||||
evidenceHash: expectedHash)
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = new[] { new VexFindingInput { VulnId = "CVE-2024-1000", Purl = "pkg:npm/vulnerable@1.0.0" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Document.Should().NotBeNull();
|
||||
var statement = result.Document.Statements.Should().ContainSingle().Subject;
|
||||
statement.EvidenceBlock.Should().NotBeNull();
|
||||
statement.EvidenceBlock!.GraphHash.Should().Be(expectedHash);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Policy Gate Integration Tests
|
||||
|
||||
[Fact(DisplayName = "Policy gate blocks emission for high-risk findings")]
|
||||
public async Task PolicyGate_BlocksEmission_ForHighRiskFindings()
|
||||
{
|
||||
// Arrange
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:npm/critical@1.0.0", "CVE-2024-CRITICAL")] = CreateFact(
|
||||
ReachabilityState.Reachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.99m,
|
||||
latticeState: "CR")
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(
|
||||
PolicyGateDecisionType.Block,
|
||||
reason: "Requires security review for critical CVEs");
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = new[] { new VexFindingInput { VulnId = "CVE-2024-CRITICAL", Purl = "pkg:npm/critical@1.0.0" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Blocked.Should().ContainSingle();
|
||||
result.Blocked[0].VulnId.Should().Be("CVE-2024-CRITICAL");
|
||||
result.Blocked[0].Reason.Should().Contain("security review");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Policy gate warns but allows emission when configured")]
|
||||
public async Task PolicyGate_WarnsButAllows_WhenConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:npm/medium@1.0.0", "CVE-2024-MEDIUM")] = CreateFact(
|
||||
ReachabilityState.Unreachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.85m,
|
||||
latticeState: "CU")
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(
|
||||
PolicyGateDecisionType.Warn,
|
||||
reason: "Confidence below threshold");
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = new[] { new VexFindingInput { VulnId = "CVE-2024-MEDIUM", Purl = "pkg:npm/medium@1.0.0" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Document.Should().NotBeNull();
|
||||
result.Document.Statements.Should().ContainSingle();
|
||||
result.Blocked.Should().BeEmpty();
|
||||
// Warnings should be logged but emission continues
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lattice State Integration Tests
|
||||
|
||||
[Theory(DisplayName = "All lattice states map to correct VEX status")]
|
||||
[InlineData("U", "under_investigation")]
|
||||
[InlineData("SR", "under_investigation")] // Static-only needs runtime confirmation
|
||||
[InlineData("SU", "not_affected")]
|
||||
[InlineData("RO", "affected")] // Runtime observed = definitely reachable
|
||||
[InlineData("RU", "not_affected")]
|
||||
[InlineData("CR", "affected")]
|
||||
[InlineData("CU", "not_affected")]
|
||||
[InlineData("X", "under_investigation")] // Contested requires manual review
|
||||
public async Task LatticeState_MapsToCorrectVexStatus(string latticeState, string expectedStatus)
|
||||
{
|
||||
// Arrange
|
||||
var state = latticeState switch
|
||||
{
|
||||
"U" => ReachabilityState.Unknown,
|
||||
"SR" or "RO" or "CR" => ReachabilityState.Reachable,
|
||||
"SU" or "RU" or "CU" => ReachabilityState.Unreachable,
|
||||
"X" => ReachabilityState.Contested,
|
||||
_ => ReachabilityState.Unknown
|
||||
};
|
||||
|
||||
var hasRuntime = latticeState is "RO" or "RU" or "CR" or "CU";
|
||||
var confidence = latticeState switch
|
||||
{
|
||||
"CR" or "CU" => 0.95m,
|
||||
"RO" or "RU" => 0.85m,
|
||||
"SR" or "SU" => 0.70m,
|
||||
_ => 0.0m
|
||||
};
|
||||
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:test/lib@1.0.0", "CVE-TEST")] = CreateFact(
|
||||
state,
|
||||
hasRuntime: hasRuntime,
|
||||
confidence: confidence,
|
||||
latticeState: latticeState)
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = new[] { new VexFindingInput { VulnId = "CVE-TEST", Purl = "pkg:test/lib@1.0.0" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Document.Should().NotBeNull();
|
||||
var statement = result.Document.Statements.Should().ContainSingle().Subject;
|
||||
statement.Status.Should().Be(expectedStatus);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Override Integration Tests
|
||||
|
||||
[Fact(DisplayName = "Manual override takes precedence over reachability")]
|
||||
public async Task ManualOverride_TakesPrecedence_OverReachability()
|
||||
{
|
||||
// Arrange: Reachable CVE with manual override to not_affected
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:npm/overridden@1.0.0", "CVE-2024-OVERRIDE")] = CreateFact(
|
||||
ReachabilityState.Reachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.99m,
|
||||
latticeState: "CR")
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = new[]
|
||||
{
|
||||
new VexFindingInput
|
||||
{
|
||||
VulnId = "CVE-2024-OVERRIDE",
|
||||
Purl = "pkg:npm/overridden@1.0.0",
|
||||
OverrideStatus = "not_affected",
|
||||
OverrideJustification = "Vulnerable path protected by WAF rules"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Document.Should().NotBeNull();
|
||||
var statement = result.Document.Statements.Should().ContainSingle().Subject;
|
||||
statement.Status.Should().Be("not_affected");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact(DisplayName = "Same inputs produce identical VEX documents")]
|
||||
public async Task Determinism_SameInputs_ProduceIdenticalDocuments()
|
||||
{
|
||||
// Arrange
|
||||
var facts = new Dictionary<ReachabilityFactKey, ReachabilityFact>
|
||||
{
|
||||
[new(TestTenantId, "pkg:npm/deterministic@1.0.0", "CVE-2024-DET")] = CreateFact(
|
||||
ReachabilityState.Unreachable,
|
||||
hasRuntime: true,
|
||||
confidence: 0.95m,
|
||||
latticeState: "CU")
|
||||
};
|
||||
|
||||
var factsService = CreateMockFactsService(facts);
|
||||
var gateEvaluator = CreateMockGateEvaluator(PolicyGateDecisionType.Allow);
|
||||
|
||||
// Use fixed time for determinism
|
||||
var fixedTime = new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(fixedTime);
|
||||
|
||||
var emitter = CreateEmitter(factsService, gateEvaluator, timeProvider);
|
||||
|
||||
var request = new VexDecisionEmitRequest
|
||||
{
|
||||
TenantId = TestTenantId,
|
||||
Author = TestAuthor,
|
||||
Findings = new[] { new VexFindingInput { VulnId = "CVE-2024-DET", Purl = "pkg:npm/deterministic@1.0.0" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await emitter.EmitAsync(request);
|
||||
var result2 = await emitter.EmitAsync(request);
|
||||
|
||||
// Assert
|
||||
result1.Document.Should().NotBeNull();
|
||||
result2.Document.Should().NotBeNull();
|
||||
|
||||
// Both documents should have identical content
|
||||
result1.Document.Statements.Should().HaveCount(result2.Document.Statements.Count);
|
||||
|
||||
var stmt1 = result1.Document.Statements[0];
|
||||
var stmt2 = result2.Document.Statements[0];
|
||||
|
||||
stmt1.Status.Should().Be(stmt2.Status);
|
||||
stmt1.Justification.Should().Be(stmt2.Justification);
|
||||
stmt1.VulnId.Should().Be(stmt2.VulnId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ReachabilityFact CreateFact(
|
||||
ReachabilityState state,
|
||||
bool hasRuntime,
|
||||
decimal confidence,
|
||||
string? latticeState = null,
|
||||
string? evidenceHash = null)
|
||||
{
|
||||
var metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["lattice_state"] = latticeState ?? state.ToString(),
|
||||
["has_runtime_evidence"] = hasRuntime,
|
||||
["confidence"] = confidence
|
||||
};
|
||||
|
||||
return new ReachabilityFact
|
||||
{
|
||||
State = state,
|
||||
HasRuntimeEvidence = hasRuntime,
|
||||
Confidence = confidence,
|
||||
EvidenceHash = evidenceHash,
|
||||
Metadata = metadata.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilityFactsJoiningService CreateMockFactsService(
|
||||
Dictionary<ReachabilityFactKey, ReachabilityFact> facts)
|
||||
{
|
||||
var mockService = new Mock<ReachabilityFactsJoiningService>(
|
||||
MockBehavior.Strict,
|
||||
null!, null!, null!, null!, null!);
|
||||
|
||||
mockService
|
||||
.Setup(s => s.GetFactsBatchAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<IReadOnlyList<ReachabilityFactsRequest>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string tenantId, IReadOnlyList<ReachabilityFactsRequest> requests, CancellationToken _) =>
|
||||
{
|
||||
var found = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
|
||||
var notFound = new List<ReachabilityFactKey>();
|
||||
|
||||
foreach (var req in requests)
|
||||
{
|
||||
var key = new ReachabilityFactKey(tenantId, req.Purl, req.VulnId);
|
||||
if (facts.TryGetValue(key, out var fact))
|
||||
{
|
||||
found[key] = fact;
|
||||
}
|
||||
else
|
||||
{
|
||||
notFound.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return new ReachabilityFactsBatchResult
|
||||
{
|
||||
Found = found.ToImmutableDictionary(),
|
||||
NotFound = notFound.ToImmutableArray()
|
||||
};
|
||||
});
|
||||
|
||||
return mockService.Object;
|
||||
}
|
||||
|
||||
private static IPolicyGateEvaluator CreateMockGateEvaluator(
|
||||
PolicyGateDecisionType decision,
|
||||
string? reason = null)
|
||||
{
|
||||
var mock = new Mock<IPolicyGateEvaluator>();
|
||||
mock.Setup(e => e.EvaluateAsync(It.IsAny<PolicyGateRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PolicyGateDecision
|
||||
{
|
||||
Decision = decision,
|
||||
Reason = reason
|
||||
});
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static VexDecisionEmitter CreateEmitter(
|
||||
ReachabilityFactsJoiningService factsService,
|
||||
IPolicyGateEvaluator gateEvaluator,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
var options = Options.Create(new VexDecisionEmitterOptions
|
||||
{
|
||||
MinimumConfidenceForNotAffected = 0.7m,
|
||||
RequireRuntimeForNotAffected = false,
|
||||
EnableGates = true
|
||||
});
|
||||
|
||||
return new VexDecisionEmitter(
|
||||
factsService,
|
||||
gateEvaluator,
|
||||
new OptionsMonitorWrapper<VexDecisionEmitterOptions>(options.Value),
|
||||
timeProvider ?? TimeProvider.System,
|
||||
NullLogger<VexDecisionEmitter>.Instance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private sealed class OptionsMonitorWrapper<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public OptionsMonitorWrapper(T value) => CurrentValue = value;
|
||||
public T CurrentValue { get; }
|
||||
public T Get(string? name) => CurrentValue;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
public FakeTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright © 2025-2026 StellaOps
|
||||
// Sprint: SPRINT_20260109_009_005_BE_vex_decision_integration
|
||||
// Task: Schema validation tests for VEX documents
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Vex;
|
||||
|
||||
/// <summary>
|
||||
/// Schema validation tests for VEX documents with StellaOps evidence extensions.
|
||||
/// Validates OpenVEX compliance and extension schema correctness.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "009_005")]
|
||||
public sealed class VexSchemaValidationTests
|
||||
{
|
||||
#region OpenVEX Schema Compliance
|
||||
|
||||
[Fact(DisplayName = "VexStatement has required OpenVEX fields")]
|
||||
public void VexStatement_HasRequiredOpenVexFields()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new VexStatement
|
||||
{
|
||||
VulnId = "CVE-2024-0001",
|
||||
Status = "not_affected",
|
||||
Justification = VexJustification.VulnerableCodeNotInExecutePath,
|
||||
Products = new[] { "pkg:npm/lodash@4.17.21" },
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
|
||||
// Assert: Required fields present
|
||||
node!["vulnerability"]?.GetValue<string>().Should().Be("CVE-2024-0001");
|
||||
node["status"]?.GetValue<string>().Should().Be("not_affected");
|
||||
node["products"].Should().NotBeNull();
|
||||
node["timestamp"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "VEX status values are valid OpenVEX statuses")]
|
||||
[InlineData("affected")]
|
||||
[InlineData("not_affected")]
|
||||
[InlineData("fixed")]
|
||||
[InlineData("under_investigation")]
|
||||
public void VexStatus_IsValidOpenVexStatus(string status)
|
||||
{
|
||||
// Arrange
|
||||
var validStatuses = new[] { "affected", "not_affected", "fixed", "under_investigation" };
|
||||
|
||||
// Assert
|
||||
validStatuses.Should().Contain(status);
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "VEX justification values are valid OpenVEX justifications")]
|
||||
[InlineData("component_not_present")]
|
||||
[InlineData("vulnerable_code_not_present")]
|
||||
[InlineData("vulnerable_code_not_in_execute_path")]
|
||||
[InlineData("vulnerable_code_cannot_be_controlled_by_adversary")]
|
||||
[InlineData("inline_mitigations_already_exist")]
|
||||
public void VexJustification_IsValidOpenVexJustification(string justification)
|
||||
{
|
||||
// Arrange
|
||||
var validJustifications = new[]
|
||||
{
|
||||
"component_not_present",
|
||||
"vulnerable_code_not_present",
|
||||
"vulnerable_code_not_in_execute_path",
|
||||
"vulnerable_code_cannot_be_controlled_by_adversary",
|
||||
"inline_mitigations_already_exist"
|
||||
};
|
||||
|
||||
// Assert
|
||||
validJustifications.Should().Contain(justification);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region StellaOps Evidence Extension Schema
|
||||
|
||||
[Fact(DisplayName = "Evidence extension follows x- prefix convention")]
|
||||
public void EvidenceExtension_FollowsXPrefixConvention()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new VexEvidenceBlock
|
||||
{
|
||||
LatticeState = "CU",
|
||||
Confidence = 0.95m,
|
||||
HasRuntimeEvidence = true,
|
||||
GraphHash = "sha256:abc123"
|
||||
};
|
||||
|
||||
var statement = new VexStatement
|
||||
{
|
||||
VulnId = "CVE-2024-0001",
|
||||
Status = "not_affected",
|
||||
EvidenceBlock = evidence
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
|
||||
// Assert: Extension uses x- prefix
|
||||
json.Should().Contain("\"x-stellaops-evidence\"");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Evidence block has all required fields")]
|
||||
public void EvidenceBlock_HasAllRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new VexEvidenceBlock
|
||||
{
|
||||
LatticeState = "CR",
|
||||
Confidence = 0.99m,
|
||||
HasRuntimeEvidence = true,
|
||||
GraphHash = "sha256:abc123def456",
|
||||
StaticPaths = new[] { "main->vulnerable_func" },
|
||||
RuntimeObservations = new[] { "2026-01-10T12:00:00Z: call observed" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(evidence, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
|
||||
// Assert: All fields present
|
||||
node!["lattice_state"]?.GetValue<string>().Should().Be("CR");
|
||||
node["confidence"]?.GetValue<decimal>().Should().Be(0.99m);
|
||||
node["has_runtime_evidence"]?.GetValue<bool>().Should().BeTrue();
|
||||
node["graph_hash"]?.GetValue<string>().Should().Be("sha256:abc123def456");
|
||||
node["static_paths"].Should().NotBeNull();
|
||||
node["runtime_observations"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "Lattice state values are valid")]
|
||||
[InlineData("U", true)] // Unknown
|
||||
[InlineData("SR", true)] // Statically Reachable
|
||||
[InlineData("SU", true)] // Statically Unreachable
|
||||
[InlineData("RO", true)] // Runtime Observed
|
||||
[InlineData("RU", true)] // Runtime Unobserved
|
||||
[InlineData("CR", true)] // Confirmed Reachable
|
||||
[InlineData("CU", true)] // Confirmed Unreachable
|
||||
[InlineData("X", true)] // Contested
|
||||
[InlineData("INVALID", false)]
|
||||
[InlineData("", false)]
|
||||
public void LatticeState_IsValid(string state, bool expectedValid)
|
||||
{
|
||||
// Arrange
|
||||
var validStates = new[] { "U", "SR", "SU", "RO", "RU", "CR", "CU", "X" };
|
||||
|
||||
// Act
|
||||
var isValid = validStates.Contains(state);
|
||||
|
||||
// Assert
|
||||
isValid.Should().Be(expectedValid);
|
||||
}
|
||||
|
||||
[Theory(DisplayName = "Confidence values are within valid range")]
|
||||
[InlineData(0.0, true)]
|
||||
[InlineData(0.5, true)]
|
||||
[InlineData(1.0, true)]
|
||||
[InlineData(-0.1, false)]
|
||||
[InlineData(1.1, false)]
|
||||
public void Confidence_IsWithinValidRange(decimal value, bool expectedValid)
|
||||
{
|
||||
// Act
|
||||
var isValid = value >= 0.0m && value <= 1.0m;
|
||||
|
||||
// Assert
|
||||
isValid.Should().Be(expectedValid);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Document-Level Schema
|
||||
|
||||
[Fact(DisplayName = "VexDocument has required OpenVEX document fields")]
|
||||
public void VexDocument_HasRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var document = new VexDocument
|
||||
{
|
||||
Context = "https://openvex.dev/ns/v0.2.0",
|
||||
Id = "urn:uuid:12345678-1234-1234-1234-123456789012",
|
||||
Author = "stellaops-vex-emitter@stellaops.io",
|
||||
AuthorRole = "tool",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Version = 1,
|
||||
Statements = new[]
|
||||
{
|
||||
new VexStatement
|
||||
{
|
||||
VulnId = "CVE-2024-0001",
|
||||
Status = "not_affected"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(document, JsonOptions);
|
||||
var node = JsonNode.Parse(json);
|
||||
|
||||
// Assert: Required fields present
|
||||
node!["@context"]?.GetValue<string>().Should().StartWith("https://openvex.dev/ns/");
|
||||
node["@id"]?.GetValue<string>().Should().StartWith("urn:uuid:");
|
||||
node["author"]?.GetValue<string>().Should().NotBeNullOrWhiteSpace();
|
||||
node["timestamp"].Should().NotBeNull();
|
||||
node["version"]?.GetValue<int>().Should().BeGreaterOrEqualTo(1);
|
||||
node["statements"].Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Document ID is valid URN format")]
|
||||
public void DocumentId_IsValidUrnFormat()
|
||||
{
|
||||
// Arrange
|
||||
var validUrns = new[]
|
||||
{
|
||||
"urn:uuid:12345678-1234-1234-1234-123456789012",
|
||||
"urn:stellaops:vex:tenant:12345",
|
||||
"https://stellaops.io/vex/12345"
|
||||
};
|
||||
|
||||
// Assert
|
||||
foreach (var urn in validUrns)
|
||||
{
|
||||
var isValid = urn.StartsWith("urn:") || urn.StartsWith("https://");
|
||||
isValid.Should().BeTrue($"URN '{urn}' should be valid");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Timestamp is ISO 8601 UTC format")]
|
||||
public void Timestamp_IsIso8601UtcFormat()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new VexStatement
|
||||
{
|
||||
VulnId = "CVE-2024-0001",
|
||||
Status = "not_affected",
|
||||
Timestamp = new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(statement, JsonOptions);
|
||||
|
||||
// Assert: Timestamp is ISO 8601 with Z suffix
|
||||
json.Should().Contain("2026-01-10T12:00:00");
|
||||
json.Should().MatchRegex(@"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Validation
|
||||
|
||||
[Fact(DisplayName = "Serialization is deterministic")]
|
||||
public void Serialization_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new VexEvidenceBlock
|
||||
{
|
||||
LatticeState = "CU",
|
||||
Confidence = 0.95m,
|
||||
HasRuntimeEvidence = true,
|
||||
GraphHash = "sha256:deterministic123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var json1 = JsonSerializer.Serialize(evidence, JsonOptions);
|
||||
var json2 = JsonSerializer.Serialize(evidence, JsonOptions);
|
||||
|
||||
// Assert: Both serializations are identical
|
||||
json1.Should().Be(json2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Array ordering is stable")]
|
||||
public void ArrayOrdering_IsStable()
|
||||
{
|
||||
// Arrange
|
||||
var document = new VexDocument
|
||||
{
|
||||
Context = "https://openvex.dev/ns/v0.2.0",
|
||||
Id = "urn:uuid:stable-order-test",
|
||||
Author = "test",
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Version = 1,
|
||||
Statements = new[]
|
||||
{
|
||||
new VexStatement { VulnId = "CVE-A", Status = "affected" },
|
||||
new VexStatement { VulnId = "CVE-B", Status = "not_affected" },
|
||||
new VexStatement { VulnId = "CVE-C", Status = "fixed" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var json1 = JsonSerializer.Serialize(document, JsonOptions);
|
||||
var json2 = JsonSerializer.Serialize(document, JsonOptions);
|
||||
|
||||
// Parse and verify order
|
||||
var node1 = JsonNode.Parse(json1)!["statements"]!.AsArray();
|
||||
var node2 = JsonNode.Parse(json2)!["statements"]!.AsArray();
|
||||
|
||||
// Assert: Order is preserved
|
||||
for (var i = 0; i < node1.Count; i++)
|
||||
{
|
||||
node1[i]!["vulnerability"]?.GetValue<string>()
|
||||
.Should().Be(node2[i]!["vulnerability"]?.GetValue<string>());
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Models (simplified for schema testing)
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private sealed record VexDocument
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("@context")]
|
||||
public required string Context { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("@id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required string Author { get; init; }
|
||||
public string? AuthorRole { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public required int Version { get; init; }
|
||||
public required VexStatement[] Statements { get; init; }
|
||||
}
|
||||
|
||||
private sealed record VexStatement
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("vulnerability")]
|
||||
public required string VulnId { get; init; }
|
||||
|
||||
public required string Status { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
public string[]? Products { get; init; }
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("x-stellaops-evidence")]
|
||||
public VexEvidenceBlock? EvidenceBlock { get; init; }
|
||||
}
|
||||
|
||||
private sealed record VexEvidenceBlock
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("lattice_state")]
|
||||
public required string LatticeState { get; init; }
|
||||
|
||||
public required decimal Confidence { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("has_runtime_evidence")]
|
||||
public required bool HasRuntimeEvidence { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("graph_hash")]
|
||||
public string? GraphHash { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("static_paths")]
|
||||
public string[]? StaticPaths { get; init; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("runtime_observations")]
|
||||
public string[]? RuntimeObservations { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constants for VEX justification values.
|
||||
/// </summary>
|
||||
public static class VexJustification
|
||||
{
|
||||
public const string ComponentNotPresent = "component_not_present";
|
||||
public const string VulnerableCodeNotPresent = "vulnerable_code_not_present";
|
||||
public const string VulnerableCodeNotInExecutePath = "vulnerable_code_not_in_execute_path";
|
||||
public const string VulnerableCodeCannotBeControlled = "vulnerable_code_cannot_be_controlled_by_adversary";
|
||||
public const string InlineMitigationsExist = "inline_mitigations_already_exist";
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
// Sprint: SPRINT_20260109_009_003_BE_cve_symbol_mapping
|
||||
// Task: Implement API endpoints
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using StellaOps.Reachability.Core.CveMapping;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// CVE-Symbol Mapping API for querying vulnerable symbols.
|
||||
/// Maps CVE identifiers to affected functions/methods for reachability analysis.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("v1/cve-mappings")]
|
||||
[Produces("application/json")]
|
||||
public class CveMappingController : ControllerBase
|
||||
{
|
||||
private readonly ICveSymbolMappingService _mappingService;
|
||||
private readonly ILogger<CveMappingController> _logger;
|
||||
|
||||
public CveMappingController(
|
||||
ICveSymbolMappingService mappingService,
|
||||
ILogger<CveMappingController> logger)
|
||||
{
|
||||
_mappingService = mappingService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all symbol mappings for a CVE.
|
||||
/// </summary>
|
||||
/// <param name="cveId">The CVE identifier (e.g., CVE-2021-44228).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of vulnerable symbols for the CVE.</returns>
|
||||
[HttpGet("{cveId}")]
|
||||
[EnableRateLimiting("reachgraph-read")]
|
||||
[ProducesResponseType(typeof(CveMappingResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ResponseCache(Duration = 3600, VaryByQueryKeys = new[] { "cveId" })]
|
||||
public async Task<IActionResult> GetByCveIdAsync(
|
||||
[FromRoute] string cveId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Fetching mappings for CVE {CveId}", cveId);
|
||||
|
||||
var mappings = await _mappingService.GetMappingsForCveAsync(cveId, cancellationToken);
|
||||
|
||||
if (mappings.Count == 0)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "CVE not found",
|
||||
Detail = $"No symbol mappings found for CVE {cveId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var response = new CveMappingResponse
|
||||
{
|
||||
CveId = cveId,
|
||||
MappingCount = mappings.Count,
|
||||
Mappings = mappings.Select(m => new CveMappingDto
|
||||
{
|
||||
Purl = m.Purl,
|
||||
Symbol = m.Symbol.Symbol,
|
||||
CanonicalId = m.Symbol.CanonicalId,
|
||||
FilePath = m.Symbol.FilePath,
|
||||
StartLine = m.Symbol.StartLine,
|
||||
EndLine = m.Symbol.EndLine,
|
||||
Source = m.Source.ToString(),
|
||||
Confidence = m.Confidence,
|
||||
VulnerabilityType = m.VulnerabilityType.ToString(),
|
||||
AffectedVersions = m.AffectedVersions.ToList(),
|
||||
FixedVersions = m.FixedVersions.ToList(),
|
||||
EvidenceUri = m.EvidenceUri
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get mappings for a specific package.
|
||||
/// </summary>
|
||||
/// <param name="purl">Package URL (URL-encoded).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of CVE mappings affecting the package.</returns>
|
||||
[HttpGet("by-package")]
|
||||
[EnableRateLimiting("reachgraph-read")]
|
||||
[ProducesResponseType(typeof(PackageMappingsResponse), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetByPackageAsync(
|
||||
[FromQuery] string purl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Package URL (purl) is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogDebug("Fetching mappings for package {Purl}", purl);
|
||||
|
||||
var mappings = await _mappingService.GetMappingsForPackageAsync(purl, cancellationToken);
|
||||
|
||||
var response = new PackageMappingsResponse
|
||||
{
|
||||
Purl = purl,
|
||||
MappingCount = mappings.Count,
|
||||
Mappings = mappings.Select(m => new CveMappingDto
|
||||
{
|
||||
CveId = m.CveId,
|
||||
Purl = m.Purl,
|
||||
Symbol = m.Symbol.Symbol,
|
||||
CanonicalId = m.Symbol.CanonicalId,
|
||||
FilePath = m.Symbol.FilePath,
|
||||
StartLine = m.Symbol.StartLine,
|
||||
EndLine = m.Symbol.EndLine,
|
||||
Source = m.Source.ToString(),
|
||||
Confidence = m.Confidence,
|
||||
VulnerabilityType = m.VulnerabilityType.ToString(),
|
||||
AffectedVersions = m.AffectedVersions.ToList(),
|
||||
FixedVersions = m.FixedVersions.ToList(),
|
||||
EvidenceUri = m.EvidenceUri
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search for mappings by symbol name.
|
||||
/// </summary>
|
||||
/// <param name="symbol">Symbol name or pattern.</param>
|
||||
/// <param name="language">Optional programming language filter.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of CVE mappings matching the symbol.</returns>
|
||||
[HttpGet("by-symbol")]
|
||||
[EnableRateLimiting("reachgraph-read")]
|
||||
[ProducesResponseType(typeof(SymbolMappingsResponse), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetBySymbolAsync(
|
||||
[FromQuery] string symbol,
|
||||
[FromQuery] string? language,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbol))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Symbol name is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogDebug("Searching mappings for symbol {Symbol}, language {Language}", symbol, language ?? "any");
|
||||
|
||||
var mappings = await _mappingService.SearchBySymbolAsync(symbol, language, cancellationToken);
|
||||
|
||||
var response = new SymbolMappingsResponse
|
||||
{
|
||||
Symbol = symbol,
|
||||
Language = language,
|
||||
MappingCount = mappings.Count,
|
||||
Mappings = mappings.Select(m => new CveMappingDto
|
||||
{
|
||||
CveId = m.CveId,
|
||||
Purl = m.Purl,
|
||||
Symbol = m.Symbol.Symbol,
|
||||
CanonicalId = m.Symbol.CanonicalId,
|
||||
FilePath = m.Symbol.FilePath,
|
||||
StartLine = m.Symbol.StartLine,
|
||||
EndLine = m.Symbol.EndLine,
|
||||
Source = m.Source.ToString(),
|
||||
Confidence = m.Confidence,
|
||||
VulnerabilityType = m.VulnerabilityType.ToString(),
|
||||
AffectedVersions = m.AffectedVersions.ToList(),
|
||||
FixedVersions = m.FixedVersions.ToList(),
|
||||
EvidenceUri = m.EvidenceUri
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add or update a CVE-symbol mapping.
|
||||
/// </summary>
|
||||
/// <param name="request">The mapping to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created or updated mapping.</returns>
|
||||
[HttpPost]
|
||||
[EnableRateLimiting("reachgraph-write")]
|
||||
[ProducesResponseType(typeof(CveMappingDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(CveMappingDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> UpsertMappingAsync(
|
||||
[FromBody] UpsertCveMappingRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.CveId))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "CVE ID is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Purl))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Package URL (purl) is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Symbol))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Symbol name is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation("Upserting mapping: CVE {CveId}, Package {Purl}, Symbol {Symbol}",
|
||||
request.CveId, request.Purl, request.Symbol);
|
||||
|
||||
if (!Enum.TryParse<MappingSource>(request.Source, ignoreCase: true, out var source))
|
||||
{
|
||||
source = MappingSource.Unknown;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<VulnerabilityType>(request.VulnerabilityType, ignoreCase: true, out var vulnType))
|
||||
{
|
||||
vulnType = VulnerabilityType.Unknown;
|
||||
}
|
||||
|
||||
var mapping = new CveSymbolMapping
|
||||
{
|
||||
CveId = request.CveId,
|
||||
Purl = request.Purl,
|
||||
Symbol = new VulnerableSymbol
|
||||
{
|
||||
Symbol = request.Symbol,
|
||||
CanonicalId = request.CanonicalId,
|
||||
FilePath = request.FilePath,
|
||||
StartLine = request.StartLine,
|
||||
EndLine = request.EndLine
|
||||
},
|
||||
Source = source,
|
||||
Confidence = request.Confidence ?? 0.5,
|
||||
VulnerabilityType = vulnType,
|
||||
AffectedVersions = request.AffectedVersions?.ToImmutableArray() ?? [],
|
||||
FixedVersions = request.FixedVersions?.ToImmutableArray() ?? [],
|
||||
EvidenceUri = request.EvidenceUri
|
||||
};
|
||||
|
||||
var result = await _mappingService.AddOrUpdateMappingAsync(mapping, cancellationToken);
|
||||
|
||||
var response = new CveMappingDto
|
||||
{
|
||||
CveId = result.CveId,
|
||||
Purl = result.Purl,
|
||||
Symbol = result.Symbol.Symbol,
|
||||
CanonicalId = result.Symbol.CanonicalId,
|
||||
FilePath = result.Symbol.FilePath,
|
||||
StartLine = result.Symbol.StartLine,
|
||||
EndLine = result.Symbol.EndLine,
|
||||
Source = result.Source.ToString(),
|
||||
Confidence = result.Confidence,
|
||||
VulnerabilityType = result.VulnerabilityType.ToString(),
|
||||
AffectedVersions = result.AffectedVersions.ToList(),
|
||||
FixedVersions = result.FixedVersions.ToList(),
|
||||
EvidenceUri = result.EvidenceUri
|
||||
};
|
||||
|
||||
return CreatedAtAction(nameof(GetByCveIdAsync), new { cveId = result.CveId }, response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyze a commit/patch to extract vulnerable symbols.
|
||||
/// </summary>
|
||||
/// <param name="request">The patch analysis request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Extracted symbols from the patch.</returns>
|
||||
[HttpPost("analyze-patch")]
|
||||
[EnableRateLimiting("reachgraph-write")]
|
||||
[ProducesResponseType(typeof(PatchAnalysisResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> AnalyzePatchAsync(
|
||||
[FromBody] AnalyzePatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.CommitUrl) && string.IsNullOrWhiteSpace(request.DiffContent))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Either CommitUrl or DiffContent is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogDebug("Analyzing patch: {CommitUrl}", request.CommitUrl ?? "(inline diff)");
|
||||
|
||||
var result = await _mappingService.AnalyzePatchAsync(
|
||||
request.CommitUrl,
|
||||
request.DiffContent,
|
||||
cancellationToken);
|
||||
|
||||
var response = new PatchAnalysisResponse
|
||||
{
|
||||
CommitUrl = request.CommitUrl,
|
||||
ExtractedSymbols = result.ExtractedSymbols.Select(s => new ExtractedSymbolDto
|
||||
{
|
||||
Symbol = s.Symbol,
|
||||
FilePath = s.FilePath,
|
||||
StartLine = s.StartLine,
|
||||
EndLine = s.EndLine,
|
||||
ChangeType = s.ChangeType.ToString(),
|
||||
Language = s.Language
|
||||
}).ToList(),
|
||||
AnalyzedAt = result.AnalyzedAt
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrich CVE mapping from OSV database.
|
||||
/// </summary>
|
||||
/// <param name="cveId">CVE to enrich.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Enriched mapping data from OSV.</returns>
|
||||
[HttpPost("{cveId}/enrich")]
|
||||
[EnableRateLimiting("reachgraph-write")]
|
||||
[ProducesResponseType(typeof(EnrichmentResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> EnrichFromOsvAsync(
|
||||
[FromRoute] string cveId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Enriching CVE {CveId} from OSV", cveId);
|
||||
|
||||
var enrichedMappings = await _mappingService.EnrichFromOsvAsync(cveId, cancellationToken);
|
||||
|
||||
if (enrichedMappings.Count == 0)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "CVE not found in OSV",
|
||||
Detail = $"No OSV data found for CVE {cveId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var response = new EnrichmentResponse
|
||||
{
|
||||
CveId = cveId,
|
||||
EnrichedCount = enrichedMappings.Count,
|
||||
Mappings = enrichedMappings.Select(m => new CveMappingDto
|
||||
{
|
||||
CveId = m.CveId,
|
||||
Purl = m.Purl,
|
||||
Symbol = m.Symbol.Symbol,
|
||||
CanonicalId = m.Symbol.CanonicalId,
|
||||
FilePath = m.Symbol.FilePath,
|
||||
Source = m.Source.ToString(),
|
||||
Confidence = m.Confidence,
|
||||
VulnerabilityType = m.VulnerabilityType.ToString(),
|
||||
AffectedVersions = m.AffectedVersions.ToList(),
|
||||
FixedVersions = m.FixedVersions.ToList()
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get mapping statistics.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Statistics about the mapping corpus.</returns>
|
||||
[HttpGet("stats")]
|
||||
[EnableRateLimiting("reachgraph-read")]
|
||||
[ProducesResponseType(typeof(MappingStatsResponse), StatusCodes.Status200OK)]
|
||||
[ResponseCache(Duration = 300)]
|
||||
public async Task<IActionResult> GetStatsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var stats = await _mappingService.GetStatsAsync(cancellationToken);
|
||||
|
||||
var response = new MappingStatsResponse
|
||||
{
|
||||
TotalMappings = stats.TotalMappings,
|
||||
UniqueCves = stats.UniqueCves,
|
||||
UniquePackages = stats.UniquePackages,
|
||||
BySource = stats.BySource,
|
||||
ByVulnerabilityType = stats.ByVulnerabilityType,
|
||||
AverageConfidence = stats.AverageConfidence,
|
||||
LastUpdated = stats.LastUpdated
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Response containing CVE mappings.
|
||||
/// </summary>
|
||||
public record CveMappingResponse
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public int MappingCount { get; init; }
|
||||
public required List<CveMappingDto> Mappings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for package-based query.
|
||||
/// </summary>
|
||||
public record PackageMappingsResponse
|
||||
{
|
||||
public required string Purl { get; init; }
|
||||
public int MappingCount { get; init; }
|
||||
public required List<CveMappingDto> Mappings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for symbol-based query.
|
||||
/// </summary>
|
||||
public record SymbolMappingsResponse
|
||||
{
|
||||
public required string Symbol { get; init; }
|
||||
public string? Language { get; init; }
|
||||
public int MappingCount { get; init; }
|
||||
public required List<CveMappingDto> Mappings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVE mapping data transfer object.
|
||||
/// </summary>
|
||||
public record CveMappingDto
|
||||
{
|
||||
public string? CveId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public required string Symbol { get; init; }
|
||||
public string? CanonicalId { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public int? StartLine { get; init; }
|
||||
public int? EndLine { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public required string VulnerabilityType { get; init; }
|
||||
public List<string>? AffectedVersions { get; init; }
|
||||
public List<string>? FixedVersions { get; init; }
|
||||
public string? EvidenceUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to add/update a CVE mapping.
|
||||
/// </summary>
|
||||
public record UpsertCveMappingRequest
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public required string Symbol { get; init; }
|
||||
public string? CanonicalId { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public int? StartLine { get; init; }
|
||||
public int? EndLine { get; init; }
|
||||
public string? Source { get; init; }
|
||||
public double? Confidence { get; init; }
|
||||
public string? VulnerabilityType { get; init; }
|
||||
public List<string>? AffectedVersions { get; init; }
|
||||
public List<string>? FixedVersions { get; init; }
|
||||
public string? EvidenceUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to analyze a patch.
|
||||
/// </summary>
|
||||
public record AnalyzePatchRequest
|
||||
{
|
||||
public string? CommitUrl { get; init; }
|
||||
public string? DiffContent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from patch analysis.
|
||||
/// </summary>
|
||||
public record PatchAnalysisResponse
|
||||
{
|
||||
public string? CommitUrl { get; init; }
|
||||
public required List<ExtractedSymbolDto> ExtractedSymbols { get; init; }
|
||||
public DateTimeOffset AnalyzedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracted symbol from patch.
|
||||
/// </summary>
|
||||
public record ExtractedSymbolDto
|
||||
{
|
||||
public required string Symbol { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public int? StartLine { get; init; }
|
||||
public int? EndLine { get; init; }
|
||||
public required string ChangeType { get; init; }
|
||||
public string? Language { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from OSV enrichment.
|
||||
/// </summary>
|
||||
public record EnrichmentResponse
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public int EnrichedCount { get; init; }
|
||||
public required List<CveMappingDto> Mappings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mapping statistics response.
|
||||
/// </summary>
|
||||
public record MappingStatsResponse
|
||||
{
|
||||
public int TotalMappings { get; init; }
|
||||
public int UniqueCves { get; init; }
|
||||
public int UniquePackages { get; init; }
|
||||
public Dictionary<string, int>? BySource { get; init; }
|
||||
public Dictionary<string, int>? ByVulnerabilityType { get; init; }
|
||||
public double AverageConfidence { get; init; }
|
||||
public DateTimeOffset LastUpdated { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
// <copyright file="SarifSchemaValidationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Sarif.Fingerprints;
|
||||
using StellaOps.Scanner.Sarif.Models;
|
||||
using StellaOps.Scanner.Sarif.Rules;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Sarif.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// SARIF 2.1.0 schema validation tests.
|
||||
/// Sprint: SPRINT_20260109_010_001 Task: Write schema validation tests
|
||||
///
|
||||
/// These tests validate that generated SARIF conforms to SARIF 2.1.0 specification
|
||||
/// requirements. Reference: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class SarifSchemaValidationTests
|
||||
{
|
||||
private readonly SarifExportService _service;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public SarifSchemaValidationTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
var ruleRegistry = new SarifRuleRegistry();
|
||||
var fingerprintGenerator = new FingerprintGenerator(ruleRegistry);
|
||||
_service = new SarifExportService(ruleRegistry, fingerprintGenerator, _timeProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.13: sarifLog object requirements
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SarifLog_RequiredProperties_ArePresent()
|
||||
{
|
||||
// Arrange
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
// Act
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
// Assert - Required properties per SARIF 2.1.0 section 3.13
|
||||
root["version"].Should().NotBeNull("version is required");
|
||||
root["$schema"].Should().NotBeNull("$schema is required for SARIF files");
|
||||
root["runs"].Should().NotBeNull("runs array is required");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.13.2: version property must be "2.1.0"
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SarifLog_Version_Is2_1_0()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
root["version"]!.GetValue<string>().Should().Be("2.1.0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.13.3: $schema property format
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SarifLog_Schema_IsValidUri()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
var schema = root["$schema"]!.GetValue<string>();
|
||||
schema.Should().Contain("sarif");
|
||||
Uri.TryCreate(schema, UriKind.Absolute, out _).Should().BeTrue("$schema must be a valid URI");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.13.4: runs is an array of run objects
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SarifLog_Runs_IsArray()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
root["runs"]!.Should().BeOfType<JsonArray>();
|
||||
root["runs"]!.AsArray().Count.Should().BeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.14: run object requirements
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Run_RequiredProperties_ArePresent()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
var run = root["runs"]![0]!.AsObject();
|
||||
|
||||
// Required property: tool
|
||||
run["tool"].Should().NotBeNull("tool is required in run object");
|
||||
|
||||
// results is optional but we always include it
|
||||
run["results"].Should().NotBeNull("results should be present");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.18: tool object requirements
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Tool_RequiredProperties_ArePresent()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
var tool = root["runs"]![0]!["tool"]!.AsObject();
|
||||
|
||||
// Required property: driver
|
||||
tool["driver"].Should().NotBeNull("driver is required in tool object");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.19: toolComponent (driver) requirements
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Driver_RequiredProperties_ArePresent()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
var driver = root["runs"]![0]!["tool"]!["driver"]!.AsObject();
|
||||
|
||||
// Required property: name
|
||||
driver["name"].Should().NotBeNull("name is required in driver");
|
||||
driver["name"]!.GetValue<string>().Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.19.2: driver name must match options
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Driver_Name_MatchesOptions()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions() with { ToolName = "Custom Scanner" };
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
var driver = root["runs"]![0]!["tool"]!["driver"]!.AsObject();
|
||||
|
||||
driver["name"]!.GetValue<string>().Should().Be("Custom Scanner");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.27: result object requirements
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Result_RequiredProperties_ArePresent()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
var results = root["runs"]![0]!["results"]!.AsArray();
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
// ruleId is technically optional but we always include it
|
||||
result!["ruleId"].Should().NotBeNull("ruleId should be present");
|
||||
|
||||
// message is required
|
||||
result["message"].Should().NotBeNull("message is required in result");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.27.10: level values must be from enumeration
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Result_Level_IsValidEnumValue()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
var results = root["runs"]![0]!["results"]!.AsArray();
|
||||
|
||||
var validLevels = new[] { "none", "note", "warning", "error" };
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
if (result!["level"] != null)
|
||||
{
|
||||
var level = result["level"]!.GetValue<string>();
|
||||
validLevels.Should().Contain(level, "level must be a valid SARIF enum value");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.11: message object requirements
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Message_HasTextOrId()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
var results = root["runs"]![0]!["results"]!.AsArray();
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
var message = result!["message"]!.AsObject();
|
||||
var hasText = message["text"] != null;
|
||||
var hasId = message["id"] != null;
|
||||
|
||||
(hasText || hasId).Should().BeTrue("message must have either text or id");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.28: location object validation
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Location_PhysicalLocation_HasValidStructure()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
new FindingInput
|
||||
{
|
||||
Type = FindingType.Vulnerability,
|
||||
Title = "Test vulnerability",
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
FilePath = "src/test.cs",
|
||||
StartLine = 10,
|
||||
EndLine = 15,
|
||||
StartColumn = 5,
|
||||
EndColumn = 20
|
||||
}
|
||||
};
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
var result = root["runs"]![0]!["results"]![0]!.AsObject();
|
||||
|
||||
if (result["locations"] != null)
|
||||
{
|
||||
var locations = result["locations"]!.AsArray();
|
||||
foreach (var location in locations)
|
||||
{
|
||||
var physicalLocation = location!["physicalLocation"];
|
||||
if (physicalLocation != null)
|
||||
{
|
||||
// artifactLocation should be present
|
||||
physicalLocation["artifactLocation"].Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.30: region object validation - line numbers are 1-based
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Region_LineNumbers_AreOneBased()
|
||||
{
|
||||
var findings = new[]
|
||||
{
|
||||
new FindingInput
|
||||
{
|
||||
Type = FindingType.Vulnerability,
|
||||
Title = "Test vulnerability",
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
FilePath = "src/test.cs",
|
||||
StartLine = 1, // Minimum valid line
|
||||
EndLine = 5
|
||||
}
|
||||
};
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
var result = root["runs"]![0]!["results"]![0]!.AsObject();
|
||||
|
||||
if (result["locations"]?[0]?["physicalLocation"]?["region"] is JsonObject region)
|
||||
{
|
||||
if (region["startLine"] != null)
|
||||
{
|
||||
region["startLine"]!.GetValue<int>().Should().BeGreaterThanOrEqualTo(1, "SARIF line numbers are 1-based");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.49: reportingDescriptor (rule) requirements
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Rule_RequiredProperties_ArePresent()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
var rules = root["runs"]![0]!["tool"]!["driver"]!["rules"];
|
||||
|
||||
if (rules != null)
|
||||
{
|
||||
foreach (var rule in rules.AsArray())
|
||||
{
|
||||
// id is required
|
||||
rule!["id"].Should().NotBeNull("rule id is required");
|
||||
rule["id"]!.GetValue<string>().Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SARIF JSON must be valid (parseable)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Export_ProducesValidJson()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should not throw
|
||||
var doc = JsonDocument.Parse(json);
|
||||
doc.RootElement.ValueKind.Should().Be(JsonValueKind.Object);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empty findings should produce valid SARIF with empty results
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Export_EmptyFindings_ProducesValidSarif()
|
||||
{
|
||||
var findings = Array.Empty<FindingInput>();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
root["version"]!.GetValue<string>().Should().Be("2.1.0");
|
||||
root["runs"]![0]!["results"]!.AsArray().Count.Should().Be(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section 3.27.18: fingerprints must be object with string values
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Result_Fingerprints_AreStringValues()
|
||||
{
|
||||
var findings = CreateSampleFindings();
|
||||
var options = CreateDefaultOptions();
|
||||
|
||||
var json = await _service.ExportToJsonAsync(findings, options, TestContext.Current.CancellationToken);
|
||||
var root = JsonNode.Parse(json)!.AsObject();
|
||||
var results = root["runs"]![0]!["results"]!.AsArray();
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
var fingerprints = result!["fingerprints"];
|
||||
if (fingerprints != null)
|
||||
{
|
||||
fingerprints.Should().BeOfType<JsonObject>();
|
||||
foreach (var kvp in fingerprints.AsObject())
|
||||
{
|
||||
kvp.Value!.GetValueKind().Should().Be(JsonValueKind.String,
|
||||
"fingerprint values must be strings");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static FindingInput[] CreateSampleFindings()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new FindingInput
|
||||
{
|
||||
Type = FindingType.Vulnerability,
|
||||
Title = "Test vulnerability CVE-2024-12345",
|
||||
VulnerabilityId = "CVE-2024-12345",
|
||||
ComponentPurl = "pkg:npm/test-package@1.0.0",
|
||||
ComponentName = "test-package",
|
||||
ComponentVersion = "1.0.0",
|
||||
Severity = Severity.High,
|
||||
CvssScore = 8.0
|
||||
},
|
||||
new FindingInput
|
||||
{
|
||||
Type = FindingType.License,
|
||||
Title = "GPL-3.0 license detected",
|
||||
ComponentPurl = "pkg:npm/gpl-lib@2.0.0",
|
||||
ComponentName = "gpl-lib",
|
||||
ComponentVersion = "2.0.0",
|
||||
Severity = Severity.Medium
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static SarifExportOptions CreateDefaultOptions()
|
||||
{
|
||||
return new SarifExportOptions
|
||||
{
|
||||
ToolName = "StellaOps Scanner",
|
||||
ToolVersion = "1.0.0-test"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,14 @@ internal static class RunEndpoints
|
||||
var summary = queueLagProvider.Capture();
|
||||
return Results.Ok(summary);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
@@ -107,6 +115,14 @@ internal static class RunEndpoints
|
||||
|
||||
return Results.Ok(new RunCollectionResponse(runs, nextCursor));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
@@ -134,6 +150,14 @@ internal static class RunEndpoints
|
||||
|
||||
return Results.Ok(new RunResponse(run));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
@@ -161,6 +185,14 @@ internal static class RunEndpoints
|
||||
|
||||
return Results.Ok(new RunDeltaCollectionResponse(run.Deltas));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
@@ -243,6 +275,14 @@ internal static class RunEndpoints
|
||||
|
||||
return Results.Created($"/api/v1/scheduler/runs/{run.Id}", new RunResponse(run));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
@@ -302,9 +342,13 @@ internal static class RunEndpoints
|
||||
|
||||
return Results.Ok(new RunResponse(cancelled));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
@@ -402,9 +446,13 @@ internal static class RunEndpoints
|
||||
|
||||
return Results.Created($"/api/v1/scheduler/runs/{retryRun.Id}", new RunResponse(retryRun));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
@@ -439,6 +487,20 @@ internal static class RunEndpoints
|
||||
{
|
||||
// Client disconnected; nothing to do.
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
if (!httpContext.Response.HasStarted)
|
||||
{
|
||||
await Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized).ExecuteAsync(httpContext);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (!httpContext.Response.HasStarted)
|
||||
{
|
||||
await Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden).ExecuteAsync(httpContext);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
if (!httpContext.Response.HasStarted)
|
||||
@@ -503,6 +565,14 @@ internal static class RunEndpoints
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
|
||||
@@ -64,6 +64,14 @@ internal static class ScheduleEndpoints
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
@@ -93,6 +101,14 @@ internal static class ScheduleEndpoints
|
||||
var summary = await runSummaryService.GetAsync(tenant.TenantId, scheduleId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new ScheduleResponse(schedule, summary));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
@@ -159,6 +175,14 @@ internal static class ScheduleEndpoints
|
||||
var response = new ScheduleResponse(schedule, null);
|
||||
return Results.Created($"/api/v1/scheduler/schedules/{schedule.Id}", response);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
@@ -205,6 +229,14 @@ internal static class ScheduleEndpoints
|
||||
|
||||
return Results.Ok(new ScheduleResponse(updated, null));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
@@ -273,6 +305,14 @@ internal static class ScheduleEndpoints
|
||||
|
||||
return Results.Ok(new ScheduleResponse(updated, null));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
@@ -341,6 +381,14 @@ internal static class ScheduleEndpoints
|
||||
|
||||
return Results.Ok(new ScheduleResponse(updated, null));
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ValidationException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
|
||||
680
src/Signals/StellaOps.Signals/Api/RuntimeAgentController.cs
Normal file
680
src/Signals/StellaOps.Signals/Api/RuntimeAgentController.cs
Normal file
@@ -0,0 +1,680 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RuntimeAgentController.cs
|
||||
// Sprint: SPRINT_20260109_009_004
|
||||
// Task: API endpoints for runtime agent registration, heartbeat, and facts ingestion
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Signals.RuntimeAgent;
|
||||
|
||||
namespace StellaOps.Signals.Api;
|
||||
|
||||
/// <summary>
|
||||
/// API controller for runtime agent management and facts ingestion.
|
||||
/// Provides endpoints for agent registration, heartbeat, and runtime observation ingestion.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/agents")]
|
||||
[Produces("application/json")]
|
||||
public sealed class RuntimeAgentController : ControllerBase
|
||||
{
|
||||
private readonly IAgentRegistrationService _registrationService;
|
||||
private readonly IRuntimeFactsIngest _factsIngestService;
|
||||
private readonly ILogger<RuntimeAgentController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RuntimeAgentController"/> class.
|
||||
/// </summary>
|
||||
public RuntimeAgentController(
|
||||
IAgentRegistrationService registrationService,
|
||||
IRuntimeFactsIngest factsIngestService,
|
||||
ILogger<RuntimeAgentController> logger)
|
||||
{
|
||||
_registrationService = registrationService ?? throw new ArgumentNullException(nameof(registrationService));
|
||||
_factsIngestService = factsIngestService ?? throw new ArgumentNullException(nameof(factsIngestService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new runtime agent.
|
||||
/// </summary>
|
||||
/// <param name="request">Registration request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Agent registration response with agent ID.</returns>
|
||||
[HttpPost("register")]
|
||||
[ProducesResponseType(typeof(AgentRegistrationApiResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<AgentRegistrationApiResponse>> Register(
|
||||
[FromBody] RegisterAgentApiRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.AgentId))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid agent ID",
|
||||
Detail = "The 'agentId' field is required.",
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Hostname))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid hostname",
|
||||
Detail = "The 'hostname' field is required.",
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Registering agent {AgentId}, hostname {Hostname}, platform {Platform}",
|
||||
request.AgentId, request.Hostname, request.Platform);
|
||||
|
||||
try
|
||||
{
|
||||
var registrationRequest = new AgentRegistrationRequest
|
||||
{
|
||||
AgentId = request.AgentId,
|
||||
Platform = request.Platform,
|
||||
Hostname = request.Hostname,
|
||||
ContainerId = request.ContainerId,
|
||||
KubernetesNamespace = request.KubernetesNamespace,
|
||||
KubernetesPodName = request.KubernetesPodName,
|
||||
ApplicationName = request.ApplicationName,
|
||||
ProcessId = request.ProcessId,
|
||||
AgentVersion = request.AgentVersion ?? "1.0.0",
|
||||
InitialPosture = request.InitialPosture,
|
||||
Tags = request.Tags?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
|
||||
};
|
||||
|
||||
var registration = await _registrationService.RegisterAsync(registrationRequest, ct);
|
||||
|
||||
var response = MapToApiResponse(registration);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetAgent),
|
||||
new { agentId = registration.AgentId },
|
||||
response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error registering agent {AgentId}", request.AgentId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
|
||||
{
|
||||
Title = "Internal server error",
|
||||
Detail = "An error occurred while registering the agent.",
|
||||
Status = StatusCodes.Status500InternalServerError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an agent heartbeat.
|
||||
/// </summary>
|
||||
/// <param name="agentId">Agent ID.</param>
|
||||
/// <param name="request">Heartbeat request with state and statistics.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Heartbeat response with commands.</returns>
|
||||
[HttpPost("{agentId}/heartbeat")]
|
||||
[ProducesResponseType(typeof(AgentHeartbeatApiResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AgentHeartbeatApiResponse>> Heartbeat(
|
||||
string agentId,
|
||||
[FromBody] HeartbeatApiRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogDebug("Heartbeat received from agent {AgentId}", agentId);
|
||||
|
||||
try
|
||||
{
|
||||
var heartbeatRequest = new AgentHeartbeatRequest
|
||||
{
|
||||
AgentId = agentId,
|
||||
State = request.State,
|
||||
Posture = request.Posture,
|
||||
Statistics = request.Statistics,
|
||||
};
|
||||
|
||||
var response = await _registrationService.HeartbeatAsync(heartbeatRequest, ct);
|
||||
|
||||
return Ok(new AgentHeartbeatApiResponse
|
||||
{
|
||||
Continue = response.Continue,
|
||||
NewPosture = response.NewPosture,
|
||||
Command = response.Command,
|
||||
});
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Agent not found",
|
||||
Detail = $"No agent found with ID '{agentId}'.",
|
||||
Status = StatusCodes.Status404NotFound,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error recording heartbeat for agent {AgentId}", agentId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
|
||||
{
|
||||
Title = "Internal server error",
|
||||
Detail = "An error occurred while recording the heartbeat.",
|
||||
Status = StatusCodes.Status500InternalServerError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets agent details.
|
||||
/// </summary>
|
||||
/// <param name="agentId">Agent ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Agent details.</returns>
|
||||
[HttpGet("{agentId}")]
|
||||
[ProducesResponseType(typeof(AgentRegistrationApiResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AgentRegistrationApiResponse>> GetAgent(
|
||||
string agentId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var registration = await _registrationService.GetAsync(agentId, ct);
|
||||
|
||||
if (registration == null)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Agent not found",
|
||||
Detail = $"No agent found with ID '{agentId}'.",
|
||||
Status = StatusCodes.Status404NotFound,
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(MapToApiResponse(registration));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all registered agents.
|
||||
/// </summary>
|
||||
/// <param name="platform">Optional platform filter.</param>
|
||||
/// <param name="healthyOnly">Only return healthy agents.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of agents.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(AgentListApiResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<AgentListApiResponse>> ListAgents(
|
||||
[FromQuery(Name = "platform")] RuntimePlatform? platform = null,
|
||||
[FromQuery(Name = "healthy_only")] bool healthyOnly = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
IReadOnlyList<AgentRegistration> agents;
|
||||
|
||||
if (healthyOnly)
|
||||
{
|
||||
agents = await _registrationService.ListHealthyAsync(ct);
|
||||
}
|
||||
else if (platform.HasValue)
|
||||
{
|
||||
agents = await _registrationService.ListByPlatformAsync(platform.Value, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
agents = await _registrationService.ListAsync(ct);
|
||||
}
|
||||
|
||||
return Ok(new AgentListApiResponse
|
||||
{
|
||||
Agents = agents.Select(MapToApiResponse).ToList(),
|
||||
TotalCount = agents.Count,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deregisters an agent.
|
||||
/// </summary>
|
||||
/// <param name="agentId">Agent ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>No content on success.</returns>
|
||||
[HttpDelete("{agentId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Unregister(
|
||||
string agentId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Unregistering agent {AgentId}", agentId);
|
||||
|
||||
try
|
||||
{
|
||||
await _registrationService.UnregisterAsync(agentId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Agent not found",
|
||||
Detail = $"No agent found with ID '{agentId}'.",
|
||||
Status = StatusCodes.Status404NotFound,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a command to an agent.
|
||||
/// </summary>
|
||||
/// <param name="agentId">Agent ID.</param>
|
||||
/// <param name="request">Command request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Accepted.</returns>
|
||||
[HttpPost("{agentId}/commands")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SendCommand(
|
||||
string agentId,
|
||||
[FromBody] CommandApiRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Sending command {Command} to agent {AgentId}",
|
||||
request.Command, agentId);
|
||||
|
||||
try
|
||||
{
|
||||
await _registrationService.SendCommandAsync(agentId, request.Command, ct);
|
||||
return Accepted();
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Agent not found",
|
||||
Detail = $"No agent found with ID '{agentId}'.",
|
||||
Status = StatusCodes.Status404NotFound,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates agent posture.
|
||||
/// </summary>
|
||||
/// <param name="agentId">Agent ID.</param>
|
||||
/// <param name="request">Posture update request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>No content on success.</returns>
|
||||
[HttpPatch("{agentId}/posture")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdatePosture(
|
||||
string agentId,
|
||||
[FromBody] PostureUpdateRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Updating posture of agent {AgentId} to {Posture}",
|
||||
agentId, request.Posture);
|
||||
|
||||
try
|
||||
{
|
||||
await _registrationService.UpdatePostureAsync(agentId, request.Posture, ct);
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Agent not found",
|
||||
Detail = $"No agent found with ID '{agentId}'.",
|
||||
Status = StatusCodes.Status404NotFound,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static AgentRegistrationApiResponse MapToApiResponse(AgentRegistration registration)
|
||||
{
|
||||
return new AgentRegistrationApiResponse
|
||||
{
|
||||
AgentId = registration.AgentId,
|
||||
Platform = registration.Platform,
|
||||
Hostname = registration.Hostname,
|
||||
ContainerId = registration.ContainerId,
|
||||
KubernetesNamespace = registration.KubernetesNamespace,
|
||||
KubernetesPodName = registration.KubernetesPodName,
|
||||
ApplicationName = registration.ApplicationName,
|
||||
ProcessId = registration.ProcessId,
|
||||
AgentVersion = registration.AgentVersion,
|
||||
RegisteredAt = registration.RegisteredAt,
|
||||
LastHeartbeat = registration.LastHeartbeat,
|
||||
State = registration.State,
|
||||
Posture = registration.Posture,
|
||||
Tags = registration.Tags.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API controller for runtime facts ingestion.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/agents/{agentId}/facts")]
|
||||
[Produces("application/json")]
|
||||
public sealed class RuntimeFactsController : ControllerBase
|
||||
{
|
||||
private readonly IRuntimeFactsIngest _factsIngestService;
|
||||
private readonly ILogger<RuntimeFactsController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RuntimeFactsController"/> class.
|
||||
/// </summary>
|
||||
public RuntimeFactsController(
|
||||
IRuntimeFactsIngest factsIngestService,
|
||||
ILogger<RuntimeFactsController> logger)
|
||||
{
|
||||
_factsIngestService = factsIngestService ?? throw new ArgumentNullException(nameof(factsIngestService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests a batch of runtime method events.
|
||||
/// </summary>
|
||||
/// <param name="agentId">Agent ID.</param>
|
||||
/// <param name="request">Batch of events.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Ingestion result.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(FactsIngestApiResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<FactsIngestApiResponse>> IngestFacts(
|
||||
string agentId,
|
||||
[FromBody] FactsIngestApiRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (request.Events == null || request.Events.Count == 0)
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "At least one event is required.",
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Ingesting {EventCount} events from agent {AgentId}",
|
||||
request.Events.Count, agentId);
|
||||
|
||||
try
|
||||
{
|
||||
var events = request.Events.Select(e => new RuntimeMethodEvent
|
||||
{
|
||||
EventId = e.EventId ?? Guid.NewGuid().ToString("N"),
|
||||
SymbolId = e.SymbolId,
|
||||
MethodName = e.MethodName,
|
||||
TypeName = e.TypeName,
|
||||
AssemblyOrModule = e.AssemblyOrModule,
|
||||
Timestamp = e.Timestamp,
|
||||
Kind = e.Kind,
|
||||
ContainerId = e.ContainerId,
|
||||
ProcessId = e.ProcessId,
|
||||
ThreadId = e.ThreadId,
|
||||
CallDepth = e.CallDepth,
|
||||
DurationMicroseconds = e.DurationMicroseconds,
|
||||
Context = e.Context?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
|
||||
});
|
||||
|
||||
var result = await _factsIngestService.IngestBatchAsync(agentId, events, ct);
|
||||
|
||||
return Ok(new FactsIngestApiResponse
|
||||
{
|
||||
AcceptedCount = result.AcceptedCount,
|
||||
RejectedCount = result.RejectedCount,
|
||||
AggregatedSymbols = result.AggregatedSymbols,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error ingesting facts from agent {AgentId}", agentId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
|
||||
{
|
||||
Title = "Internal server error",
|
||||
Detail = "An error occurred while ingesting facts.",
|
||||
Status = StatusCodes.Status500InternalServerError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region API DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Agent registration API request.
|
||||
/// </summary>
|
||||
public sealed record RegisterAgentApiRequest
|
||||
{
|
||||
/// <summary>Unique agent identifier (generated by agent).</summary>
|
||||
[Required]
|
||||
public required string AgentId { get; init; }
|
||||
|
||||
/// <summary>Target platform.</summary>
|
||||
public RuntimePlatform Platform { get; init; } = RuntimePlatform.DotNet;
|
||||
|
||||
/// <summary>Hostname where agent is running.</summary>
|
||||
[Required]
|
||||
public required string Hostname { get; init; }
|
||||
|
||||
/// <summary>Container ID if running in container.</summary>
|
||||
public string? ContainerId { get; init; }
|
||||
|
||||
/// <summary>Kubernetes namespace if running in K8s.</summary>
|
||||
public string? KubernetesNamespace { get; init; }
|
||||
|
||||
/// <summary>Kubernetes pod name if running in K8s.</summary>
|
||||
public string? KubernetesPodName { get; init; }
|
||||
|
||||
/// <summary>Target application name.</summary>
|
||||
public string? ApplicationName { get; init; }
|
||||
|
||||
/// <summary>Target process ID.</summary>
|
||||
public int? ProcessId { get; init; }
|
||||
|
||||
/// <summary>Agent version.</summary>
|
||||
public string? AgentVersion { get; init; }
|
||||
|
||||
/// <summary>Initial posture.</summary>
|
||||
public RuntimePosture InitialPosture { get; init; } = RuntimePosture.Sampled;
|
||||
|
||||
/// <summary>Tags for grouping/filtering.</summary>
|
||||
public Dictionary<string, string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent registration API response.
|
||||
/// </summary>
|
||||
public sealed record AgentRegistrationApiResponse
|
||||
{
|
||||
/// <summary>Agent ID.</summary>
|
||||
public required string AgentId { get; init; }
|
||||
|
||||
/// <summary>Platform.</summary>
|
||||
public required RuntimePlatform Platform { get; init; }
|
||||
|
||||
/// <summary>Hostname.</summary>
|
||||
public required string Hostname { get; init; }
|
||||
|
||||
/// <summary>Container ID.</summary>
|
||||
public string? ContainerId { get; init; }
|
||||
|
||||
/// <summary>Kubernetes namespace.</summary>
|
||||
public string? KubernetesNamespace { get; init; }
|
||||
|
||||
/// <summary>Kubernetes pod name.</summary>
|
||||
public string? KubernetesPodName { get; init; }
|
||||
|
||||
/// <summary>Application name.</summary>
|
||||
public string? ApplicationName { get; init; }
|
||||
|
||||
/// <summary>Process ID.</summary>
|
||||
public int? ProcessId { get; init; }
|
||||
|
||||
/// <summary>Agent version.</summary>
|
||||
public required string AgentVersion { get; init; }
|
||||
|
||||
/// <summary>Registered timestamp.</summary>
|
||||
public required DateTimeOffset RegisteredAt { get; init; }
|
||||
|
||||
/// <summary>Last heartbeat timestamp.</summary>
|
||||
public DateTimeOffset LastHeartbeat { get; init; }
|
||||
|
||||
/// <summary>State.</summary>
|
||||
public AgentState State { get; init; }
|
||||
|
||||
/// <summary>Posture.</summary>
|
||||
public RuntimePosture Posture { get; init; }
|
||||
|
||||
/// <summary>Tags.</summary>
|
||||
public Dictionary<string, string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent heartbeat API request.
|
||||
/// </summary>
|
||||
public sealed record HeartbeatApiRequest
|
||||
{
|
||||
/// <summary>Current agent state.</summary>
|
||||
public required AgentState State { get; init; }
|
||||
|
||||
/// <summary>Current posture.</summary>
|
||||
public required RuntimePosture Posture { get; init; }
|
||||
|
||||
/// <summary>Statistics snapshot.</summary>
|
||||
public AgentStatistics? Statistics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent heartbeat API response.
|
||||
/// </summary>
|
||||
public sealed record AgentHeartbeatApiResponse
|
||||
{
|
||||
/// <summary>Whether the agent should continue.</summary>
|
||||
public bool Continue { get; init; } = true;
|
||||
|
||||
/// <summary>New posture if changed.</summary>
|
||||
public RuntimePosture? NewPosture { get; init; }
|
||||
|
||||
/// <summary>Command to execute.</summary>
|
||||
public AgentCommand? Command { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent list API response.
|
||||
/// </summary>
|
||||
public sealed record AgentListApiResponse
|
||||
{
|
||||
/// <summary>List of agents.</summary>
|
||||
public required IReadOnlyList<AgentRegistrationApiResponse> Agents { get; init; }
|
||||
|
||||
/// <summary>Total count.</summary>
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command API request.
|
||||
/// </summary>
|
||||
public sealed record CommandApiRequest
|
||||
{
|
||||
/// <summary>Command to send.</summary>
|
||||
[Required]
|
||||
public required AgentCommand Command { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posture update request.
|
||||
/// </summary>
|
||||
public sealed record PostureUpdateRequest
|
||||
{
|
||||
/// <summary>New posture.</summary>
|
||||
[Required]
|
||||
public required RuntimePosture Posture { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Facts ingest API request.
|
||||
/// </summary>
|
||||
public sealed record FactsIngestApiRequest
|
||||
{
|
||||
/// <summary>Events to ingest.</summary>
|
||||
[Required]
|
||||
public required IReadOnlyList<RuntimeEventApiDto> Events { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime event API DTO.
|
||||
/// </summary>
|
||||
public sealed record RuntimeEventApiDto
|
||||
{
|
||||
/// <summary>Event ID (optional, will be generated if not provided).</summary>
|
||||
public string? EventId { get; init; }
|
||||
|
||||
/// <summary>Symbol ID.</summary>
|
||||
[Required]
|
||||
public required string SymbolId { get; init; }
|
||||
|
||||
/// <summary>Method name.</summary>
|
||||
[Required]
|
||||
public required string MethodName { get; init; }
|
||||
|
||||
/// <summary>Type name.</summary>
|
||||
[Required]
|
||||
public required string TypeName { get; init; }
|
||||
|
||||
/// <summary>Assembly or module.</summary>
|
||||
[Required]
|
||||
public required string AssemblyOrModule { get; init; }
|
||||
|
||||
/// <summary>Timestamp.</summary>
|
||||
[Required]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>Event kind.</summary>
|
||||
public RuntimeEventKind Kind { get; init; } = RuntimeEventKind.Sample;
|
||||
|
||||
/// <summary>Container ID.</summary>
|
||||
public string? ContainerId { get; init; }
|
||||
|
||||
/// <summary>Process ID.</summary>
|
||||
public int? ProcessId { get; init; }
|
||||
|
||||
/// <summary>Thread ID.</summary>
|
||||
public string? ThreadId { get; init; }
|
||||
|
||||
/// <summary>Call depth.</summary>
|
||||
public int? CallDepth { get; init; }
|
||||
|
||||
/// <summary>Duration in microseconds.</summary>
|
||||
public long? DurationMicroseconds { get; init; }
|
||||
|
||||
/// <summary>Additional context.</summary>
|
||||
public Dictionary<string, string>? Context { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Facts ingest API response.
|
||||
/// </summary>
|
||||
public sealed record FactsIngestApiResponse
|
||||
{
|
||||
/// <summary>Number of accepted events.</summary>
|
||||
public required int AcceptedCount { get; init; }
|
||||
|
||||
/// <summary>Number of rejected events.</summary>
|
||||
public required int RejectedCount { get; init; }
|
||||
|
||||
/// <summary>Number of aggregated symbols.</summary>
|
||||
public required int AggregatedSymbols { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,241 @@
|
||||
-- Signals Schema Migration 002: Runtime Agent Framework
|
||||
-- Sprint: SPRINT_20260109_009_004
|
||||
-- Creates tables for runtime agent registration, heartbeats, and aggregated facts
|
||||
|
||||
-- ============================================================================
|
||||
-- Runtime Agent Registrations
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS signals.runtime_agents (
|
||||
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
artifact_digest TEXT NOT NULL,
|
||||
platform TEXT NOT NULL CHECK (platform IN ('dotnet', 'java', 'native', 'python', 'nodejs', 'go', 'rust')),
|
||||
posture TEXT NOT NULL DEFAULT 'sampled'
|
||||
CHECK (posture IN ('none', 'passive', 'sampled', 'active_tracing', 'deep', 'full')),
|
||||
metadata JSONB,
|
||||
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_heartbeat_at TIMESTAMPTZ,
|
||||
state TEXT NOT NULL DEFAULT 'registered'
|
||||
CHECK (state IN ('registered', 'starting', 'running', 'stopping', 'stopped', 'error')),
|
||||
statistics JSONB,
|
||||
version TEXT,
|
||||
hostname TEXT,
|
||||
container_id TEXT,
|
||||
pod_name TEXT,
|
||||
namespace TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_agents_tenant ON signals.runtime_agents(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_agents_artifact ON signals.runtime_agents(artifact_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_agents_heartbeat ON signals.runtime_agents(last_heartbeat_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_agents_state ON signals.runtime_agents(state);
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_agents_active ON signals.runtime_agents(tenant_id, state)
|
||||
WHERE state = 'running';
|
||||
|
||||
COMMENT ON TABLE signals.runtime_agents IS 'Runtime agent registrations for method-level execution trace collection';
|
||||
COMMENT ON COLUMN signals.runtime_agents.platform IS 'Target platform: dotnet, java, native, python, nodejs, go, rust';
|
||||
COMMENT ON COLUMN signals.runtime_agents.posture IS 'Collection intensity: none, passive, sampled, active_tracing, deep, full';
|
||||
COMMENT ON COLUMN signals.runtime_agents.state IS 'Agent lifecycle state: registered, starting, running, stopping, stopped, error';
|
||||
|
||||
-- ============================================================================
|
||||
-- Runtime Facts (Aggregated Observations)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS signals.runtime_facts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
artifact_digest TEXT NOT NULL,
|
||||
canonical_symbol_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
hit_count BIGINT NOT NULL DEFAULT 0,
|
||||
first_seen TIMESTAMPTZ NOT NULL,
|
||||
last_seen TIMESTAMPTZ NOT NULL,
|
||||
contexts JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
agent_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT runtime_facts_unique UNIQUE (tenant_id, artifact_digest, canonical_symbol_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_facts_tenant ON signals.runtime_facts(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_facts_artifact ON signals.runtime_facts(tenant_id, artifact_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_facts_symbol ON signals.runtime_facts(canonical_symbol_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_facts_last_seen ON signals.runtime_facts(last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_facts_hit_count ON signals.runtime_facts(hit_count DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_facts_gin_contexts ON signals.runtime_facts USING GIN (contexts);
|
||||
|
||||
COMMENT ON TABLE signals.runtime_facts IS 'Aggregated runtime method observations from runtime agents';
|
||||
COMMENT ON COLUMN signals.runtime_facts.canonical_symbol_id IS 'Canonicalized symbol identifier from symbol normalization pipeline';
|
||||
COMMENT ON COLUMN signals.runtime_facts.hit_count IS 'Total number of times this symbol was observed executing';
|
||||
COMMENT ON COLUMN signals.runtime_facts.contexts IS 'JSONB array of runtime contexts (container, route, process) where symbol was observed';
|
||||
COMMENT ON COLUMN signals.runtime_facts.agent_ids IS 'Array of agent IDs that have reported observations for this symbol';
|
||||
|
||||
-- ============================================================================
|
||||
-- Agent Heartbeat History (for monitoring)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS signals.agent_heartbeats (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id UUID NOT NULL REFERENCES signals.runtime_agents(agent_id) ON DELETE CASCADE,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
events_collected BIGINT NOT NULL DEFAULT 0,
|
||||
events_transmitted BIGINT NOT NULL DEFAULT 0,
|
||||
events_dropped BIGINT NOT NULL DEFAULT 0,
|
||||
memory_bytes BIGINT,
|
||||
cpu_percent REAL,
|
||||
error_count INT NOT NULL DEFAULT 0,
|
||||
last_error TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_heartbeats_agent ON signals.agent_heartbeats(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_heartbeats_recorded ON signals.agent_heartbeats(recorded_at DESC);
|
||||
|
||||
-- Partitioning hint: Consider partitioning by recorded_at for high-volume deployments
|
||||
COMMENT ON TABLE signals.agent_heartbeats IS 'Agent heartbeat history for monitoring and diagnostics';
|
||||
|
||||
-- ============================================================================
|
||||
-- Agent Commands Queue (for remote control)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS signals.agent_commands (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id UUID NOT NULL REFERENCES signals.runtime_agents(agent_id) ON DELETE CASCADE,
|
||||
command_type TEXT NOT NULL CHECK (command_type IN (
|
||||
'start', 'stop', 'reconfigure', 'flush', 'update_filters', 'set_posture'
|
||||
)),
|
||||
payload JSONB,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'acknowledged', 'executing', 'completed', 'failed')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
acknowledged_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
result JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_commands_agent ON signals.agent_commands(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_commands_pending ON signals.agent_commands(agent_id, status)
|
||||
WHERE status = 'pending';
|
||||
|
||||
COMMENT ON TABLE signals.agent_commands IS 'Command queue for remote agent control';
|
||||
COMMENT ON COLUMN signals.agent_commands.command_type IS 'Command type: start, stop, reconfigure, flush, update_filters, set_posture';
|
||||
|
||||
-- ============================================================================
|
||||
-- Views for Runtime Agent Management
|
||||
-- ============================================================================
|
||||
|
||||
-- Active agents summary
|
||||
CREATE OR REPLACE VIEW signals.active_agents AS
|
||||
SELECT
|
||||
ra.agent_id,
|
||||
ra.tenant_id,
|
||||
ra.artifact_digest,
|
||||
ra.platform,
|
||||
ra.posture,
|
||||
ra.state,
|
||||
ra.hostname,
|
||||
ra.container_id,
|
||||
ra.registered_at,
|
||||
ra.last_heartbeat_at,
|
||||
(ra.statistics->>'eventsCollected')::bigint AS events_collected,
|
||||
(ra.statistics->>'eventsTransmitted')::bigint AS events_transmitted,
|
||||
NOW() - ra.last_heartbeat_at AS time_since_heartbeat
|
||||
FROM signals.runtime_agents ra
|
||||
WHERE ra.state = 'running'
|
||||
AND ra.last_heartbeat_at > NOW() - INTERVAL '5 minutes';
|
||||
|
||||
COMMENT ON VIEW signals.active_agents IS 'Currently active runtime agents with recent heartbeats';
|
||||
|
||||
-- Runtime facts summary per artifact
|
||||
CREATE OR REPLACE VIEW signals.runtime_facts_summary AS
|
||||
SELECT
|
||||
rf.tenant_id,
|
||||
rf.artifact_digest,
|
||||
COUNT(*) AS unique_symbols_observed,
|
||||
SUM(rf.hit_count) AS total_observations,
|
||||
MIN(rf.first_seen) AS earliest_observation,
|
||||
MAX(rf.last_seen) AS latest_observation,
|
||||
COUNT(DISTINCT unnest(rf.agent_ids)) AS contributing_agents
|
||||
FROM signals.runtime_facts rf
|
||||
GROUP BY rf.tenant_id, rf.artifact_digest;
|
||||
|
||||
COMMENT ON VIEW signals.runtime_facts_summary IS 'Summary of runtime observations per artifact';
|
||||
|
||||
-- ============================================================================
|
||||
-- Functions for Runtime Agent Management
|
||||
-- ============================================================================
|
||||
|
||||
-- Upsert runtime fact (for batch ingestion)
|
||||
CREATE OR REPLACE FUNCTION signals.upsert_runtime_fact(
|
||||
p_tenant_id UUID,
|
||||
p_artifact_digest TEXT,
|
||||
p_canonical_symbol_id TEXT,
|
||||
p_display_name TEXT,
|
||||
p_hit_count BIGINT,
|
||||
p_first_seen TIMESTAMPTZ,
|
||||
p_last_seen TIMESTAMPTZ,
|
||||
p_contexts JSONB,
|
||||
p_agent_id UUID
|
||||
) RETURNS UUID AS $$
|
||||
DECLARE
|
||||
v_fact_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO signals.runtime_facts (
|
||||
tenant_id, artifact_digest, canonical_symbol_id, display_name,
|
||||
hit_count, first_seen, last_seen, contexts, agent_ids
|
||||
) VALUES (
|
||||
p_tenant_id, p_artifact_digest, p_canonical_symbol_id, p_display_name,
|
||||
p_hit_count, p_first_seen, p_last_seen, p_contexts, ARRAY[p_agent_id]
|
||||
)
|
||||
ON CONFLICT (tenant_id, artifact_digest, canonical_symbol_id)
|
||||
DO UPDATE SET
|
||||
hit_count = signals.runtime_facts.hit_count + EXCLUDED.hit_count,
|
||||
last_seen = GREATEST(signals.runtime_facts.last_seen, EXCLUDED.last_seen),
|
||||
first_seen = LEAST(signals.runtime_facts.first_seen, EXCLUDED.first_seen),
|
||||
contexts = signals.runtime_facts.contexts || EXCLUDED.contexts,
|
||||
agent_ids = ARRAY(SELECT DISTINCT unnest(signals.runtime_facts.agent_ids || EXCLUDED.agent_ids)),
|
||||
updated_at = NOW()
|
||||
RETURNING id INTO v_fact_id;
|
||||
|
||||
RETURN v_fact_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON FUNCTION signals.upsert_runtime_fact IS 'Upsert a runtime fact, aggregating hit counts and contexts';
|
||||
|
||||
-- Clean up stale agents
|
||||
CREATE OR REPLACE FUNCTION signals.cleanup_stale_agents(
|
||||
p_stale_threshold INTERVAL DEFAULT INTERVAL '1 hour'
|
||||
) RETURNS INT AS $$
|
||||
DECLARE
|
||||
v_count INT;
|
||||
BEGIN
|
||||
UPDATE signals.runtime_agents
|
||||
SET state = 'stopped'
|
||||
WHERE state = 'running'
|
||||
AND last_heartbeat_at < NOW() - p_stale_threshold;
|
||||
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
RETURN v_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON FUNCTION signals.cleanup_stale_agents IS 'Mark agents as stopped if no heartbeat received within threshold';
|
||||
|
||||
-- Prune old heartbeat history
|
||||
CREATE OR REPLACE FUNCTION signals.prune_heartbeat_history(
|
||||
p_retention_days INT DEFAULT 7
|
||||
) RETURNS INT AS $$
|
||||
DECLARE
|
||||
v_count INT;
|
||||
BEGIN
|
||||
DELETE FROM signals.agent_heartbeats
|
||||
WHERE recorded_at < NOW() - (p_retention_days || ' days')::INTERVAL;
|
||||
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
RETURN v_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMENT ON FUNCTION signals.prune_heartbeat_history IS 'Delete heartbeat records older than retention period';
|
||||
@@ -193,11 +193,203 @@ test.describe('Evidence Panel E2E', () => {
|
||||
test('should expand attestation chain node on click', async ({ page }) => {
|
||||
const node = page.locator('.chain-node').first();
|
||||
await node.click();
|
||||
|
||||
|
||||
await expect(node).toHaveClass(/chain-node--expanded/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Reachability tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Switch to Reachability tab
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
await page.waitForSelector('.reachability-tab');
|
||||
});
|
||||
|
||||
test('should display status badge with correct state', async ({ page }) => {
|
||||
const badge = page.locator('.status-badge');
|
||||
await expect(badge).toBeVisible();
|
||||
|
||||
// Badge should have one of the status classes
|
||||
const hasStatusClass = await badge.evaluate((el) =>
|
||||
el.classList.contains('status-badge--reachable') ||
|
||||
el.classList.contains('status-badge--unreachable') ||
|
||||
el.classList.contains('status-badge--partial') ||
|
||||
el.classList.contains('status-badge--unknown')
|
||||
);
|
||||
expect(hasStatusClass).toBe(true);
|
||||
});
|
||||
|
||||
test('should display confidence percentage', async ({ page }) => {
|
||||
const confidence = page.locator('.confidence-value');
|
||||
await expect(confidence).toBeVisible();
|
||||
|
||||
// Should match percentage format (e.g., "85%")
|
||||
await expect(confidence).toHaveText(/%$/);
|
||||
});
|
||||
|
||||
test('should have View Full Graph button', async ({ page }) => {
|
||||
const viewBtn = page.locator('.view-graph-btn');
|
||||
await expect(viewBtn).toBeVisible();
|
||||
await expect(viewBtn).toContainText('View Full Graph');
|
||||
});
|
||||
|
||||
test('should emit event on View Full Graph click', async ({ page }) => {
|
||||
// Set up navigation listener
|
||||
const navigationPromise = page.waitForURL(/\/reachgraph\//);
|
||||
|
||||
await page.click('.view-graph-btn');
|
||||
|
||||
// Should navigate to full graph view
|
||||
await navigationPromise;
|
||||
});
|
||||
|
||||
test('should display analysis method info', async ({ page }) => {
|
||||
await page.route('**/api/evidence/reachability/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
status: 'reachable',
|
||||
confidence: 0.85,
|
||||
analysisMethod: 'static',
|
||||
analysisTimestamp: '2026-01-10T12:00:00Z',
|
||||
paths: [],
|
||||
entryPoints: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.reload();
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
|
||||
const analysisInfo = page.locator('.analysis-info');
|
||||
await expect(analysisInfo).toBeVisible();
|
||||
await expect(analysisInfo).toContainText('Static Analysis');
|
||||
});
|
||||
|
||||
test('should display entry points when available', async ({ page }) => {
|
||||
await page.route('**/api/evidence/reachability/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
status: 'reachable',
|
||||
confidence: 0.75,
|
||||
analysisMethod: 'hybrid',
|
||||
entryPoints: ['main()', 'handleRequest()', 'processInput()'],
|
||||
paths: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.reload();
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
|
||||
const entryPoints = page.locator('.entry-points');
|
||||
await expect(entryPoints).toBeVisible();
|
||||
|
||||
const entryTags = page.locator('.entry-tag');
|
||||
await expect(entryTags).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('should truncate entry points when more than 5', async ({ page }) => {
|
||||
await page.route('**/api/evidence/reachability/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
status: 'reachable',
|
||||
confidence: 0.75,
|
||||
entryPoints: ['a()', 'b()', 'c()', 'd()', 'e()', 'f()', 'g()'],
|
||||
paths: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.reload();
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
|
||||
const entryTags = page.locator('.entry-tag');
|
||||
await expect(entryTags).toHaveCount(5);
|
||||
|
||||
const moreIndicator = page.locator('.entry-more');
|
||||
await expect(moreIndicator).toBeVisible();
|
||||
await expect(moreIndicator).toContainText('+2 more');
|
||||
});
|
||||
|
||||
test('should display path count when paths exist', async ({ page }) => {
|
||||
await page.route('**/api/evidence/reachability/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
status: 'reachable',
|
||||
confidence: 0.90,
|
||||
paths: [
|
||||
{ nodes: ['a', 'b', 'vulnerable'] },
|
||||
{ nodes: ['x', 'y', 'vulnerable'] },
|
||||
],
|
||||
entryPoints: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.reload();
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
|
||||
const pathCount = page.locator('.path-count');
|
||||
await expect(pathCount).toBeVisible();
|
||||
await expect(pathCount).toContainText('2 path(s) found');
|
||||
});
|
||||
|
||||
test('should show empty state when no data', async ({ page }) => {
|
||||
await page.route('**/api/evidence/reachability/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(null),
|
||||
});
|
||||
});
|
||||
await page.reload();
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
|
||||
const emptyState = page.locator('.empty-state');
|
||||
await expect(emptyState).toBeVisible();
|
||||
await expect(emptyState).toContainText('No reachability data available');
|
||||
});
|
||||
|
||||
test('should display correct badge color for reachable status', async ({ page }) => {
|
||||
await page.route('**/api/evidence/reachability/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
status: 'reachable',
|
||||
confidence: 0.95,
|
||||
paths: [],
|
||||
entryPoints: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.reload();
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
|
||||
const badge = page.locator('.status-badge');
|
||||
await expect(badge).toHaveClass(/status-badge--reachable/);
|
||||
await expect(badge).toContainText('Reachable');
|
||||
});
|
||||
|
||||
test('should display correct badge color for unreachable status', async ({ page }) => {
|
||||
await page.route('**/api/evidence/reachability/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
status: 'unreachable',
|
||||
confidence: 0.98,
|
||||
paths: [],
|
||||
entryPoints: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.reload();
|
||||
await page.click('[aria-controls="panel-reachability"]');
|
||||
|
||||
const badge = page.locator('.status-badge');
|
||||
await expect(badge).toHaveClass(/status-badge--unreachable/);
|
||||
await expect(badge).toContainText('Unreachable');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Copy JSON functionality', () => {
|
||||
test('should have copy JSON button in provenance tab', async ({ page }) => {
|
||||
const copyBtn = page.locator('.copy-json-btn');
|
||||
|
||||
276
src/Web/StellaOps.Web/e2e/playbook-suggestions.e2e.spec.ts
Normal file
276
src/Web/StellaOps.Web/e2e/playbook-suggestions.e2e.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @file playbook-suggestions.e2e.spec.ts
|
||||
* @sprint SPRINT_20260107_006_005_FE (OM-FE-006)
|
||||
* @description E2E tests for OpsMemory playbook suggestions in decision drawer.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Playbook Suggestions in Decision Drawer', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock the OpsMemory API
|
||||
await page.route('**/api/v1/opsmemory/suggestions*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
suggestions: [
|
||||
{
|
||||
suggestedAction: 'accept_risk',
|
||||
confidence: 0.85,
|
||||
rationale: 'Similar situations resolved successfully with risk acceptance',
|
||||
evidenceCount: 5,
|
||||
matchingFactors: ['severity', 'reachability', 'componentType'],
|
||||
evidence: [
|
||||
{
|
||||
memoryId: 'mem-abc123',
|
||||
cveId: 'CVE-2023-44487',
|
||||
action: 'accept_risk',
|
||||
outcome: 'success',
|
||||
resolutionTime: 'PT4H',
|
||||
similarity: 0.92,
|
||||
},
|
||||
{
|
||||
memoryId: 'mem-def456',
|
||||
cveId: 'CVE-2023-12345',
|
||||
action: 'accept_risk',
|
||||
outcome: 'success',
|
||||
resolutionTime: 'PT2H',
|
||||
similarity: 0.87,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
suggestedAction: 'target_fix',
|
||||
confidence: 0.65,
|
||||
rationale: 'Some similar situations required fixes',
|
||||
evidenceCount: 2,
|
||||
matchingFactors: ['severity'],
|
||||
evidence: [
|
||||
{
|
||||
memoryId: 'mem-ghi789',
|
||||
cveId: 'CVE-2023-99999',
|
||||
action: 'target_fix',
|
||||
outcome: 'success',
|
||||
resolutionTime: 'P1DT4H',
|
||||
similarity: 0.70,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
situationHash: 'abc123def456',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate to triage page (mock or real)
|
||||
await page.goto('/triage/findings/test-finding-123');
|
||||
});
|
||||
|
||||
test('playbook panel appears in decision drawer', async ({ page }) => {
|
||||
// Open the decision drawer
|
||||
await page.click('[data-testid="open-decision-drawer"]');
|
||||
|
||||
// Check playbook panel is visible
|
||||
const playbookPanel = page.locator('stellaops-playbook-suggestion');
|
||||
await expect(playbookPanel).toBeVisible();
|
||||
|
||||
// Check header text
|
||||
await expect(playbookPanel.locator('.playbook-panel__title')).toContainText(
|
||||
'Past Decisions'
|
||||
);
|
||||
});
|
||||
|
||||
test('shows suggestions with confidence badges', async ({ page }) => {
|
||||
await page.click('[data-testid="open-decision-drawer"]');
|
||||
|
||||
// Wait for suggestions to load
|
||||
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
|
||||
|
||||
// Check first suggestion
|
||||
const firstSuggestion = page.locator('.playbook-suggestion').first();
|
||||
await expect(firstSuggestion).toBeVisible();
|
||||
|
||||
// Check action badge
|
||||
await expect(
|
||||
firstSuggestion.locator('.playbook-suggestion__action')
|
||||
).toContainText('Accept Risk');
|
||||
|
||||
// Check confidence
|
||||
await expect(
|
||||
firstSuggestion.locator('.playbook-suggestion__confidence')
|
||||
).toContainText('85%');
|
||||
});
|
||||
|
||||
test('clicking "Use This Approach" pre-fills decision form', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.click('[data-testid="open-decision-drawer"]');
|
||||
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
|
||||
|
||||
// Click "Use This Approach" on first suggestion
|
||||
await page.click('.playbook-btn--use');
|
||||
|
||||
// Verify form was pre-filled
|
||||
const statusRadio = page.locator('input[name="status"][value="not_affected"]');
|
||||
await expect(statusRadio).toBeChecked();
|
||||
|
||||
// Check reason notes contain suggestion context
|
||||
const reasonText = page.locator('.reason-text');
|
||||
await expect(reasonText).toContainText('similar past decisions');
|
||||
});
|
||||
|
||||
test('expanding evidence details shows past decisions', async ({ page }) => {
|
||||
await page.click('[data-testid="open-decision-drawer"]');
|
||||
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
|
||||
|
||||
// Click "Show Details" on first suggestion
|
||||
await page.click('.playbook-btn--expand');
|
||||
|
||||
// Check evidence cards are visible
|
||||
const evidenceCards = page.locator('stellaops-evidence-card');
|
||||
await expect(evidenceCards).toHaveCount(2);
|
||||
|
||||
// Check evidence content
|
||||
const firstCard = evidenceCards.first();
|
||||
await expect(firstCard.locator('.evidence-card__cve')).toContainText(
|
||||
'CVE-2023-44487'
|
||||
);
|
||||
await expect(firstCard.locator('.evidence-card__similarity')).toContainText(
|
||||
'92%'
|
||||
);
|
||||
});
|
||||
|
||||
test('shows empty state when no suggestions', async ({ page }) => {
|
||||
// Override route to return empty
|
||||
await page.route('**/api/v1/opsmemory/suggestions*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
suggestions: [],
|
||||
situationHash: 'empty123',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.click('[data-testid="open-decision-drawer"]');
|
||||
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
|
||||
|
||||
// Check empty state message
|
||||
await expect(page.locator('.playbook-empty')).toContainText(
|
||||
'No similar past decisions'
|
||||
);
|
||||
});
|
||||
|
||||
test('handles API errors gracefully', async ({ page }) => {
|
||||
// Override route to return error
|
||||
await page.route('**/api/v1/opsmemory/suggestions*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Internal server error' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.click('[data-testid="open-decision-drawer"]');
|
||||
|
||||
// Wait for error state
|
||||
await expect(page.locator('.playbook-error')).toBeVisible();
|
||||
|
||||
// Check retry button
|
||||
const retryBtn = page.locator('.playbook-error__retry');
|
||||
await expect(retryBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('retry button refetches suggestions', async ({ page }) => {
|
||||
let callCount = 0;
|
||||
|
||||
await page.route('**/api/v1/opsmemory/suggestions*', async (route) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
await route.fulfill({ status: 500 });
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
suggestions: [
|
||||
{
|
||||
suggestedAction: 'accept_risk',
|
||||
confidence: 0.85,
|
||||
rationale: 'Test',
|
||||
evidenceCount: 1,
|
||||
matchingFactors: [],
|
||||
evidence: [],
|
||||
},
|
||||
],
|
||||
situationHash: 'retry123',
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.click('[data-testid="open-decision-drawer"]');
|
||||
|
||||
// Wait for error state
|
||||
await expect(page.locator('.playbook-error')).toBeVisible();
|
||||
|
||||
// Click retry
|
||||
await page.click('.playbook-error__retry');
|
||||
|
||||
// Should now show suggestions
|
||||
await expect(page.locator('.playbook-suggestion')).toBeVisible();
|
||||
});
|
||||
|
||||
test('keyboard navigation works', async ({ page }) => {
|
||||
await page.click('[data-testid="open-decision-drawer"]');
|
||||
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
|
||||
|
||||
// Focus on playbook panel header
|
||||
await page.focus('.playbook-panel__header');
|
||||
|
||||
// Press Enter to toggle (if collapsed)
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Tab to first suggestion's "Use This Approach" button
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Press Enter to use suggestion
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Verify form was pre-filled
|
||||
const statusRadio = page.locator('input[name="status"][value="not_affected"]');
|
||||
await expect(statusRadio).toBeChecked();
|
||||
});
|
||||
|
||||
test('panel can be collapsed and expanded', async ({ page }) => {
|
||||
await page.click('[data-testid="open-decision-drawer"]');
|
||||
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
|
||||
|
||||
// Panel content should be visible
|
||||
await expect(page.locator('.playbook-panel__content')).toBeVisible();
|
||||
|
||||
// Click header to collapse
|
||||
await page.click('.playbook-panel__header');
|
||||
|
||||
// Content should be hidden
|
||||
await expect(page.locator('.playbook-panel__content')).not.toBeVisible();
|
||||
|
||||
// Click again to expand
|
||||
await page.click('.playbook-panel__header');
|
||||
|
||||
// Content should be visible again
|
||||
await expect(page.locator('.playbook-panel__content')).toBeVisible();
|
||||
});
|
||||
|
||||
test('matching factors are displayed', async ({ page }) => {
|
||||
await page.click('[data-testid="open-decision-drawer"]');
|
||||
await page.waitForResponse('**/api/v1/opsmemory/suggestions*');
|
||||
|
||||
const factors = page.locator('.playbook-suggestion__factor');
|
||||
await expect(factors).toHaveCount(3);
|
||||
await expect(factors.first()).toContainText('severity');
|
||||
});
|
||||
});
|
||||
610
src/Web/StellaOps.Web/e2e/sbom-evidence.e2e.spec.ts
Normal file
610
src/Web/StellaOps.Web/e2e/sbom-evidence.e2e.spec.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// sbom-evidence.e2e.spec.ts
|
||||
// Sprint: SPRINT_20260107_005_004_FE
|
||||
// Task: UI-012 — E2E Tests for CycloneDX evidence and pedigree UI components
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for SBOM Evidence and Pedigree UI components.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Evidence panel interaction
|
||||
* - Pedigree timeline click-through
|
||||
* - Diff viewer expand/collapse
|
||||
* - Keyboard navigation
|
||||
*
|
||||
* Test data: Uses mock API responses intercepted via Playwright's route handler.
|
||||
*/
|
||||
test.describe('SBOM Evidence Components E2E', () => {
|
||||
// Mock data for tests
|
||||
const mockEvidence = {
|
||||
identity: {
|
||||
field: 'purl',
|
||||
confidence: 0.95,
|
||||
methods: [
|
||||
{
|
||||
technique: 'manifest-analysis',
|
||||
confidence: 0.95,
|
||||
value: 'package.json:42',
|
||||
},
|
||||
{
|
||||
technique: 'hash-comparison',
|
||||
confidence: 0.90,
|
||||
value: 'sha256:abc123...',
|
||||
},
|
||||
],
|
||||
},
|
||||
occurrences: [
|
||||
{ location: '/node_modules/lodash/index.js', line: 1 },
|
||||
{ location: '/node_modules/lodash/lodash.min.js' },
|
||||
{ location: '/node_modules/lodash/package.json', line: 42 },
|
||||
],
|
||||
licenses: [
|
||||
{ license: { id: 'MIT' }, acknowledgement: 'declared' },
|
||||
],
|
||||
copyright: [
|
||||
{ text: 'Copyright (c) JS Foundation and contributors' },
|
||||
],
|
||||
};
|
||||
|
||||
const mockPedigree = {
|
||||
ancestors: [
|
||||
{
|
||||
type: 'library',
|
||||
name: 'openssl',
|
||||
version: '1.1.1n',
|
||||
purl: 'pkg:generic/openssl@1.1.1n',
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
type: 'library',
|
||||
name: 'openssl',
|
||||
version: '1.1.1n-0+deb11u5',
|
||||
purl: 'pkg:deb/debian/openssl@1.1.1n-0+deb11u5',
|
||||
},
|
||||
],
|
||||
commits: [
|
||||
{
|
||||
uid: 'abc123def456789',
|
||||
url: 'https://github.com/openssl/openssl/commit/abc123def456789',
|
||||
author: {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
timestamp: '2024-01-15T10:30:00Z',
|
||||
},
|
||||
message: 'Fix buffer overflow in SSL handshake\n\nThis commit addresses CVE-2024-1234.',
|
||||
},
|
||||
],
|
||||
patches: [
|
||||
{
|
||||
type: 'backport',
|
||||
diff: {
|
||||
url: 'https://github.com/openssl/openssl/commit/abc123.patch',
|
||||
text: '--- a/ssl/ssl_lib.c\n+++ b/ssl/ssl_lib.c\n@@ -100,7 +100,7 @@\n- buffer[size] = data;\n+ if (size < MAX_SIZE) buffer[size] = data;',
|
||||
},
|
||||
resolves: [
|
||||
{ id: 'CVE-2024-1234', type: 'security', name: 'Buffer overflow vulnerability' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'cherry-pick',
|
||||
resolves: [
|
||||
{ id: 'CVE-2024-5678', type: 'security' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Intercept API calls and return mock data
|
||||
await page.route('**/api/sbom/evidence/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockEvidence),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/sbom/pedigree/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockPedigree),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate to component detail page
|
||||
await page.goto('/sbom/components/pkg:deb/debian/openssl@1.1.1n-0+deb11u5');
|
||||
await page.waitForSelector('.component-detail-page');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Evidence Panel Tests
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Evidence Panel', () => {
|
||||
test('should display evidence panel with identity section', async ({ page }) => {
|
||||
const panel = page.locator('.cdx-evidence-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const identitySection = panel.locator('.evidence-section--identity');
|
||||
await expect(identitySection).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display confidence badge with correct tier', async ({ page }) => {
|
||||
const badge = page.locator('.evidence-confidence-badge').first();
|
||||
await expect(badge).toBeVisible();
|
||||
|
||||
// Should show green for 95% confidence (Tier 1)
|
||||
await expect(badge).toHaveClass(/tier-1/);
|
||||
});
|
||||
|
||||
test('should display occurrence count', async ({ page }) => {
|
||||
const occurrenceHeader = page.locator('.evidence-section__title').filter({ hasText: 'Occurrences' });
|
||||
await expect(occurrenceHeader).toContainText('(3)');
|
||||
});
|
||||
|
||||
test('should list all occurrences', async ({ page }) => {
|
||||
const occurrences = page.locator('.occurrence-item');
|
||||
await expect(occurrences).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('should display license information', async ({ page }) => {
|
||||
const licenseSection = page.locator('.evidence-section--licenses');
|
||||
await expect(licenseSection).toBeVisible();
|
||||
await expect(licenseSection).toContainText('MIT');
|
||||
});
|
||||
|
||||
test('should display copyright information', async ({ page }) => {
|
||||
const copyrightSection = page.locator('.evidence-section--copyright');
|
||||
await expect(copyrightSection).toContainText('JS Foundation');
|
||||
});
|
||||
|
||||
test('should collapse/expand sections on click', async ({ page }) => {
|
||||
// Find a collapsible section header
|
||||
const identityHeader = page.locator('.evidence-section__header').first();
|
||||
const identityContent = page.locator('.evidence-section__content').first();
|
||||
|
||||
// Should be expanded by default
|
||||
await expect(identityContent).toBeVisible();
|
||||
|
||||
// Click to collapse
|
||||
await identityHeader.click();
|
||||
await expect(identityContent).not.toBeVisible();
|
||||
|
||||
// Click to expand
|
||||
await identityHeader.click();
|
||||
await expect(identityContent).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open evidence drawer on occurrence click', async ({ page }) => {
|
||||
const occurrence = page.locator('.occurrence-item').first();
|
||||
await occurrence.click();
|
||||
|
||||
const drawer = page.locator('.evidence-detail-drawer');
|
||||
await expect(drawer).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Evidence Detail Drawer Tests
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Evidence Detail Drawer', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Open the drawer by clicking an occurrence
|
||||
await page.locator('.occurrence-item').first().click();
|
||||
await page.waitForSelector('.evidence-detail-drawer');
|
||||
});
|
||||
|
||||
test('should display detection method chain', async ({ page }) => {
|
||||
const methodChain = page.locator('.method-chain');
|
||||
await expect(methodChain).toBeVisible();
|
||||
|
||||
const methods = page.locator('.method-chain__item');
|
||||
await expect(methods).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('should display technique labels correctly', async ({ page }) => {
|
||||
const techniques = page.locator('.method-chain__technique');
|
||||
await expect(techniques.first()).toContainText('Manifest Analysis');
|
||||
});
|
||||
|
||||
test('should close on escape key', async ({ page }) => {
|
||||
const drawer = page.locator('.evidence-detail-drawer');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should close on backdrop click', async ({ page }) => {
|
||||
const overlay = page.locator('.drawer-overlay');
|
||||
await overlay.click({ position: { x: 10, y: 10 } }); // Click on overlay, not drawer
|
||||
|
||||
await expect(page.locator('.evidence-detail-drawer')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should copy evidence JSON to clipboard', async ({ page }) => {
|
||||
const copyBtn = page.locator('.reference-card .copy-btn');
|
||||
await copyBtn.click();
|
||||
|
||||
await expect(copyBtn).toContainText('Copied!');
|
||||
|
||||
// Verify clipboard content
|
||||
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clipboardText).toContain('"field": "purl"');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pedigree Timeline Tests
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Pedigree Timeline', () => {
|
||||
test('should display pedigree timeline', async ({ page }) => {
|
||||
const timeline = page.locator('.pedigree-timeline');
|
||||
await expect(timeline).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show all timeline nodes', async ({ page }) => {
|
||||
const nodes = page.locator('.timeline-node');
|
||||
await expect(nodes).toHaveCount(3); // ancestor + variant + current
|
||||
});
|
||||
|
||||
test('should display stage labels', async ({ page }) => {
|
||||
const stages = page.locator('.timeline-stage');
|
||||
await expect(stages.filter({ hasText: 'Upstream' })).toBeVisible();
|
||||
await expect(stages.filter({ hasText: 'Distro' })).toBeVisible();
|
||||
await expect(stages.filter({ hasText: 'Local' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should highlight current node', async ({ page }) => {
|
||||
const currentNode = page.locator('.timeline-node--current');
|
||||
await expect(currentNode).toBeVisible();
|
||||
await expect(currentNode).toHaveClass(/highlighted/);
|
||||
});
|
||||
|
||||
test('should show version differences', async ({ page }) => {
|
||||
const ancestorVersion = page.locator('.timeline-node--ancestor .timeline-node__version');
|
||||
const variantVersion = page.locator('.timeline-node--variant .timeline-node__version');
|
||||
|
||||
await expect(ancestorVersion).toContainText('1.1.1n');
|
||||
await expect(variantVersion).toContainText('1.1.1n-0+deb11u5');
|
||||
});
|
||||
|
||||
test('should emit event on node click', async ({ page }) => {
|
||||
const ancestorNode = page.locator('.timeline-node--ancestor');
|
||||
await ancestorNode.click();
|
||||
|
||||
// Should show some detail or navigation
|
||||
// (implementation-specific behavior)
|
||||
await expect(ancestorNode).toHaveClass(/selected/);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Patch List Tests
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Patch List', () => {
|
||||
test('should display patch count in header', async ({ page }) => {
|
||||
const header = page.locator('.patch-list__title');
|
||||
await expect(header).toContainText('Patches Applied (2)');
|
||||
});
|
||||
|
||||
test('should show patch type badges', async ({ page }) => {
|
||||
const backportBadge = page.locator('.patch-badge--backport');
|
||||
const cherryPickBadge = page.locator('.patch-badge--cherry-pick');
|
||||
|
||||
await expect(backportBadge).toBeVisible();
|
||||
await expect(cherryPickBadge).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display CVE tags', async ({ page }) => {
|
||||
const cveTags = page.locator('.cve-tag');
|
||||
await expect(cveTags.filter({ hasText: 'CVE-2024-1234' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show confidence badges', async ({ page }) => {
|
||||
const badges = page.locator('.patch-item .evidence-confidence-badge');
|
||||
await expect(badges).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('should expand patch details on click', async ({ page }) => {
|
||||
const expandBtn = page.locator('.patch-expand-btn').first();
|
||||
await expandBtn.click();
|
||||
|
||||
const details = page.locator('.patch-item__details').first();
|
||||
await expect(details).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show resolved issues when expanded', async ({ page }) => {
|
||||
const expandBtn = page.locator('.patch-expand-btn').first();
|
||||
await expandBtn.click();
|
||||
|
||||
const resolvedList = page.locator('.resolved-list').first();
|
||||
await expect(resolvedList).toBeVisible();
|
||||
await expect(resolvedList).toContainText('CVE-2024-1234');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Diff Viewer Tests
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Diff Viewer', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Open diff viewer by clicking View Diff button
|
||||
const viewDiffBtn = page.locator('.patch-action-btn').first();
|
||||
await viewDiffBtn.click();
|
||||
await page.waitForSelector('.diff-viewer');
|
||||
});
|
||||
|
||||
test('should display diff viewer modal', async ({ page }) => {
|
||||
const viewer = page.locator('.diff-viewer');
|
||||
await expect(viewer).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show unified view by default', async ({ page }) => {
|
||||
const unifiedBtn = page.locator('.view-mode-btn').filter({ hasText: 'Unified' });
|
||||
await expect(unifiedBtn).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('should switch to side-by-side view', async ({ page }) => {
|
||||
const sideBySideBtn = page.locator('.view-mode-btn').filter({ hasText: 'Side-by-Side' });
|
||||
await sideBySideBtn.click();
|
||||
|
||||
await expect(sideBySideBtn).toHaveClass(/active/);
|
||||
|
||||
const sideBySideContainer = page.locator('.diff-side-by-side');
|
||||
await expect(sideBySideContainer).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display line numbers', async ({ page }) => {
|
||||
const lineNumbers = page.locator('.line-number');
|
||||
await expect(lineNumbers.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should highlight additions in green', async ({ page }) => {
|
||||
const additions = page.locator('.diff-line--addition');
|
||||
await expect(additions).toBeVisible();
|
||||
});
|
||||
|
||||
test('should highlight deletions in red', async ({ page }) => {
|
||||
const deletions = page.locator('.diff-line--deletion');
|
||||
await expect(deletions).toBeVisible();
|
||||
});
|
||||
|
||||
test('should copy diff on button click', async ({ page }) => {
|
||||
const copyBtn = page.locator('.copy-diff-btn');
|
||||
await copyBtn.click();
|
||||
|
||||
await expect(copyBtn).toContainText('Copied!');
|
||||
});
|
||||
|
||||
test('should close diff viewer on close button', async ({ page }) => {
|
||||
const closeBtn = page.locator('.diff-viewer .close-btn');
|
||||
await closeBtn.click();
|
||||
|
||||
await expect(page.locator('.diff-viewer')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should close diff viewer on escape key', async ({ page }) => {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.diff-viewer')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Commit Info Tests
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Commit Info', () => {
|
||||
test('should display commit section', async ({ page }) => {
|
||||
const commitSection = page.locator('.commits-list');
|
||||
await expect(commitSection).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show short SHA', async ({ page }) => {
|
||||
const sha = page.locator('.commit-sha__value');
|
||||
await expect(sha).toContainText('abc123d'); // First 7 chars
|
||||
});
|
||||
|
||||
test('should copy full SHA on click', async ({ page }) => {
|
||||
const copyBtn = page.locator('.commit-sha__copy');
|
||||
await copyBtn.click();
|
||||
|
||||
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clipboardText).toBe('abc123def456789');
|
||||
});
|
||||
|
||||
test('should link to upstream repository', async ({ page }) => {
|
||||
const link = page.locator('.commit-sha__link');
|
||||
await expect(link).toHaveAttribute('href', 'https://github.com/openssl/openssl/commit/abc123def456789');
|
||||
});
|
||||
|
||||
test('should display author information', async ({ page }) => {
|
||||
const author = page.locator('.commit-identity__name');
|
||||
await expect(author.first()).toContainText('John Doe');
|
||||
});
|
||||
|
||||
test('should expand truncated commit message', async ({ page }) => {
|
||||
// If message is truncated, there should be an expand button
|
||||
const messageContainer = page.locator('.commit-message');
|
||||
const expandBtn = messageContainer.locator('.expand-btn');
|
||||
|
||||
if (await expandBtn.isVisible()) {
|
||||
const truncatedMessage = await messageContainer.locator('.message-content').textContent();
|
||||
await expandBtn.click();
|
||||
const fullMessage = await messageContainer.locator('.message-content').textContent();
|
||||
expect(fullMessage?.length).toBeGreaterThan(truncatedMessage?.length ?? 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Keyboard Navigation Tests
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Keyboard Navigation', () => {
|
||||
test('should navigate evidence sections with Tab', async ({ page }) => {
|
||||
// Focus the first focusable element in evidence panel
|
||||
await page.locator('.cdx-evidence-panel').locator('button').first().focus();
|
||||
|
||||
// Tab through sections
|
||||
await page.keyboard.press('Tab');
|
||||
const focusedElement = page.locator(':focus');
|
||||
await expect(focusedElement).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate timeline nodes with arrow keys', async ({ page }) => {
|
||||
// Focus the timeline
|
||||
await page.locator('.timeline-node').first().focus();
|
||||
|
||||
// Arrow right to next node
|
||||
await page.keyboard.press('ArrowRight');
|
||||
const focused = page.locator('.timeline-node:focus');
|
||||
await expect(focused).toHaveClass(/variant|current/);
|
||||
});
|
||||
|
||||
test('should expand/collapse patch with Enter', async ({ page }) => {
|
||||
const expandBtn = page.locator('.patch-expand-btn').first();
|
||||
await expandBtn.focus();
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
const details = page.locator('.patch-item__details').first();
|
||||
await expect(details).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(details).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should support screen reader announcements', async ({ page }) => {
|
||||
// Verify ARIA attributes are present
|
||||
const panel = page.locator('.cdx-evidence-panel');
|
||||
await expect(panel).toHaveAttribute('aria-label', /Evidence/);
|
||||
|
||||
const timeline = page.locator('.pedigree-timeline');
|
||||
await expect(timeline).toHaveAttribute('aria-label', /Pedigree/);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Empty State Tests
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Empty States', () => {
|
||||
test('should show empty state when no evidence', async ({ page }) => {
|
||||
// Override route to return empty evidence
|
||||
await page.route('**/api/sbom/evidence/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/sbom/components/pkg:npm/empty-pkg@1.0.0');
|
||||
await page.waitForSelector('.component-detail-page');
|
||||
|
||||
const emptyState = page.locator('.empty-state');
|
||||
await expect(emptyState).toBeVisible();
|
||||
await expect(emptyState).toContainText('No Evidence Data');
|
||||
});
|
||||
|
||||
test('should show empty state when no pedigree', async ({ page }) => {
|
||||
await page.route('**/api/sbom/pedigree/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/sbom/components/pkg:npm/no-pedigree@1.0.0');
|
||||
await page.waitForSelector('.component-detail-page');
|
||||
|
||||
// Should not show pedigree timeline
|
||||
const timeline = page.locator('.pedigree-timeline');
|
||||
await expect(timeline).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should show empty patch list message', async ({ page }) => {
|
||||
await page.route('**/api/sbom/pedigree/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ patches: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/sbom/components/pkg:npm/no-patches@1.0.0');
|
||||
await page.waitForSelector('.component-detail-page');
|
||||
|
||||
const emptyPatchMsg = page.locator('.patch-list__empty');
|
||||
await expect(emptyPatchMsg).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error Handling Tests
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Error Handling', () => {
|
||||
test('should display error state on API failure', async ({ page }) => {
|
||||
await page.route('**/api/sbom/evidence/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Internal server error' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/sbom/components/pkg:npm/error-pkg@1.0.0');
|
||||
await page.waitForSelector('.component-detail-page');
|
||||
|
||||
const errorState = page.locator('.error-state');
|
||||
await expect(errorState).toBeVisible();
|
||||
});
|
||||
|
||||
test('should provide retry button on error', async ({ page }) => {
|
||||
let callCount = 0;
|
||||
await page.route('**/api/sbom/evidence/**', async (route) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Internal server error' }),
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockEvidence),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/sbom/components/pkg:npm/retry-pkg@1.0.0');
|
||||
await page.waitForSelector('.error-state');
|
||||
|
||||
const retryBtn = page.locator('.retry-btn');
|
||||
await retryBtn.click();
|
||||
|
||||
// Should now show evidence panel
|
||||
await expect(page.locator('.cdx-evidence-panel')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle network timeout gracefully', async ({ page }) => {
|
||||
await page.route('**/api/sbom/evidence/**', async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000)); // Simulate timeout
|
||||
await route.abort('timedout');
|
||||
});
|
||||
|
||||
// Set shorter timeout for the page
|
||||
await page.goto('/sbom/components/pkg:npm/timeout-pkg@1.0.0', { timeout: 5000 }).catch(() => {
|
||||
// Expected to timeout
|
||||
});
|
||||
|
||||
// Should show loading or error state
|
||||
const loadingOrError = page.locator('.loading-state, .error-state');
|
||||
await expect(loadingOrError).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,18 @@ import {
|
||||
ExceptionEventsHttpClient,
|
||||
MockExceptionEventsApiService,
|
||||
} from './core/api/exception-events.client';
|
||||
import {
|
||||
EVIDENCE_PACK_API,
|
||||
EVIDENCE_PACK_API_BASE_URL,
|
||||
EvidencePackHttpClient,
|
||||
MockEvidencePackClient,
|
||||
} from './core/api/evidence-pack.client';
|
||||
import {
|
||||
AI_RUNS_API,
|
||||
AI_RUNS_API_BASE_URL,
|
||||
AiRunsHttpClient,
|
||||
MockAiRunsClient,
|
||||
} from './core/api/ai-runs.client';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -450,6 +462,48 @@ export const appConfig: ApplicationConfig = {
|
||||
useFactory: (config: AppConfigService, http: ExceptionApiHttpClient, mock: MockExceptionApiService) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
},
|
||||
{
|
||||
provide: EVIDENCE_PACK_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
useFactory: (config: AppConfigService) => {
|
||||
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
|
||||
try {
|
||||
return new URL('/v1/evidence-packs', gatewayBase).toString();
|
||||
} catch {
|
||||
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
|
||||
return `${normalized}/v1/evidence-packs`;
|
||||
}
|
||||
},
|
||||
},
|
||||
EvidencePackHttpClient,
|
||||
MockEvidencePackClient,
|
||||
{
|
||||
provide: EVIDENCE_PACK_API,
|
||||
deps: [AppConfigService, EvidencePackHttpClient, MockEvidencePackClient],
|
||||
useFactory: (config: AppConfigService, http: EvidencePackHttpClient, mock: MockEvidencePackClient) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
},
|
||||
{
|
||||
provide: AI_RUNS_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
useFactory: (config: AppConfigService) => {
|
||||
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
|
||||
try {
|
||||
return new URL('/v1/runs', gatewayBase).toString();
|
||||
} catch {
|
||||
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
|
||||
return `${normalized}/v1/runs`;
|
||||
}
|
||||
},
|
||||
},
|
||||
AiRunsHttpClient,
|
||||
MockAiRunsClient,
|
||||
{
|
||||
provide: AI_RUNS_API,
|
||||
deps: [AppConfigService, AiRunsHttpClient, MockAiRunsClient],
|
||||
useFactory: (config: AppConfigService, http: AiRunsHttpClient, mock: MockAiRunsClient) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
},
|
||||
{
|
||||
provide: CONSOLE_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
|
||||
@@ -464,6 +464,40 @@ export const routes: Routes = [
|
||||
(m) => m.PatchMapComponent
|
||||
),
|
||||
},
|
||||
// Evidence Packs (SPRINT_20260109_011_005)
|
||||
{
|
||||
path: 'evidence-packs',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/evidence-pack/evidence-pack-list.component').then(
|
||||
(m) => m.EvidencePackListComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'evidence-packs/:packId',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/evidence-pack/evidence-pack-viewer.component').then(
|
||||
(m) => m.EvidencePackViewerComponent
|
||||
),
|
||||
},
|
||||
// AI Runs (SPRINT_20260109_011_003)
|
||||
{
|
||||
path: 'ai-runs',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/ai-runs/ai-runs-list.component').then(
|
||||
(m) => m.AiRunsListComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'ai-runs/:runId',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/ai-runs/ai-run-viewer.component').then(
|
||||
(m) => m.AiRunViewerComponent
|
||||
),
|
||||
},
|
||||
// Fallback for unknown routes
|
||||
{
|
||||
path: '**',
|
||||
|
||||
524
src/Web/StellaOps.Web/src/app/core/api/ai-runs.client.ts
Normal file
524
src/Web/StellaOps.Web/src/app/core/api/ai-runs.client.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* AI Runs API client.
|
||||
* Sprint: SPRINT_20260109_011_003 Task: Frontend Unblock
|
||||
*
|
||||
* Provides access to AI Run endpoints for creating, managing,
|
||||
* and auditing AI-assisted conversations and decisions.
|
||||
*/
|
||||
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||
import { Observable, of, delay, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import {
|
||||
AiRun,
|
||||
AiRunSummary,
|
||||
AiRunStatus,
|
||||
AiRunListResponse,
|
||||
AiRunQuery,
|
||||
AiRunQueryOptions,
|
||||
CreateAiRunRequest,
|
||||
AddTurnRequest,
|
||||
ProposeActionRequest,
|
||||
ApprovalDecision,
|
||||
RunEvent,
|
||||
RunArtifact,
|
||||
RunAttestation,
|
||||
} from './ai-runs.models';
|
||||
|
||||
// ========== API Interface ==========
|
||||
|
||||
export interface AiRunsApi {
|
||||
/** Creates a new AI Run */
|
||||
create(request: CreateAiRunRequest, options?: AiRunQueryOptions): Observable<AiRun>;
|
||||
|
||||
/** Gets an AI Run by ID */
|
||||
get(runId: string, options?: AiRunQueryOptions): Observable<AiRun>;
|
||||
|
||||
/** Lists AI Runs with optional filters */
|
||||
list(query?: AiRunQuery, options?: AiRunQueryOptions): Observable<AiRunListResponse>;
|
||||
|
||||
/** Gets the timeline for a run */
|
||||
getTimeline(runId: string, options?: AiRunQueryOptions): Observable<RunEvent[]>;
|
||||
|
||||
/** Gets artifacts for a run */
|
||||
getArtifacts(runId: string, options?: AiRunQueryOptions): Observable<RunArtifact[]>;
|
||||
|
||||
/** Adds a turn to a run */
|
||||
addTurn(runId: string, request: AddTurnRequest, options?: AiRunQueryOptions): Observable<RunEvent>;
|
||||
|
||||
/** Proposes an action within a run */
|
||||
proposeAction(runId: string, request: ProposeActionRequest, options?: AiRunQueryOptions): Observable<RunEvent>;
|
||||
|
||||
/** Approves or denies a pending action */
|
||||
submitApproval(runId: string, actionId: string, decision: ApprovalDecision, options?: AiRunQueryOptions): Observable<RunEvent>;
|
||||
|
||||
/** Completes a run */
|
||||
complete(runId: string, options?: AiRunQueryOptions): Observable<AiRun>;
|
||||
|
||||
/** Creates an attestation for the run */
|
||||
createAttestation(runId: string, options?: AiRunQueryOptions): Observable<RunAttestation>;
|
||||
|
||||
/** Cancels a run */
|
||||
cancel(runId: string, reason?: string, options?: AiRunQueryOptions): Observable<AiRun>;
|
||||
}
|
||||
|
||||
// ========== DI Tokens ==========
|
||||
|
||||
export const AI_RUNS_API = new InjectionToken<AiRunsApi>('AI_RUNS_API');
|
||||
export const AI_RUNS_API_BASE_URL = new InjectionToken<string>('AI_RUNS_API_BASE_URL');
|
||||
|
||||
// ========== HTTP Implementation ==========
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AiRunsHttpClient implements AiRunsApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
private readonly baseUrl = inject(AI_RUNS_API_BASE_URL, { optional: true }) ?? '/v1/advisory-ai/runs';
|
||||
|
||||
create(request: CreateAiRunRequest, options: AiRunQueryOptions = {}): Observable<AiRun> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.post<AiRun>(this.baseUrl, request, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
get(runId: string, options: AiRunQueryOptions = {}): Observable<AiRun> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.get<AiRun>(`${this.baseUrl}/${runId}`, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
list(query: AiRunQuery = {}, options: AiRunQueryOptions = {}): Observable<AiRunListResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const params = this.buildQueryParams(query);
|
||||
return this.http
|
||||
.get<AiRunListResponse>(this.baseUrl, { headers: this.buildHeaders(traceId), params })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
getTimeline(runId: string, options: AiRunQueryOptions = {}): Observable<RunEvent[]> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.get<RunEvent[]>(`${this.baseUrl}/${runId}/timeline`, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
getArtifacts(runId: string, options: AiRunQueryOptions = {}): Observable<RunArtifact[]> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.get<RunArtifact[]>(`${this.baseUrl}/${runId}/artifacts`, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
addTurn(runId: string, request: AddTurnRequest, options: AiRunQueryOptions = {}): Observable<RunEvent> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.post<RunEvent>(`${this.baseUrl}/${runId}/turns`, request, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
proposeAction(runId: string, request: ProposeActionRequest, options: AiRunQueryOptions = {}): Observable<RunEvent> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.post<RunEvent>(`${this.baseUrl}/${runId}/actions`, request, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
submitApproval(runId: string, actionId: string, decision: ApprovalDecision, options: AiRunQueryOptions = {}): Observable<RunEvent> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.post<RunEvent>(`${this.baseUrl}/${runId}/actions/${actionId}/approval`, decision, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
complete(runId: string, options: AiRunQueryOptions = {}): Observable<AiRun> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.post<AiRun>(`${this.baseUrl}/${runId}/complete`, {}, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
createAttestation(runId: string, options: AiRunQueryOptions = {}): Observable<RunAttestation> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.post<RunAttestation>(`${this.baseUrl}/${runId}/attestation`, {}, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
cancel(runId: string, reason?: string, options: AiRunQueryOptions = {}): Observable<AiRun> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.post<AiRun>(`${this.baseUrl}/${runId}/cancel`, { reason }, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
private buildQueryParams(query: AiRunQuery): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
if (query.status) params['status'] = query.status;
|
||||
if (query.userId) params['userId'] = query.userId;
|
||||
if (query.conversationId) params['conversationId'] = query.conversationId;
|
||||
if (query.fromDate) params['fromDate'] = query.fromDate;
|
||||
if (query.toDate) params['toDate'] = query.toDate;
|
||||
if (query.limit) params['limit'] = query.limit.toString();
|
||||
if (query.offset) params['offset'] = query.offset.toString();
|
||||
return params;
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
return err instanceof Error
|
||||
? new Error(`[${traceId}] AI Runs error: ${err.message}`)
|
||||
: new Error(`[${traceId}] AI Runs error: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Mock Implementation ==========
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAiRunsClient implements AiRunsApi {
|
||||
private runs: Map<string, AiRun> = new Map();
|
||||
private eventCounter = 0;
|
||||
|
||||
constructor() {
|
||||
this.initializeSampleData();
|
||||
}
|
||||
|
||||
create(request: CreateAiRunRequest): Observable<AiRun> {
|
||||
const runId = `run-${Date.now().toString(36)}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const run: AiRun = {
|
||||
runId,
|
||||
tenantId: 'mock-tenant',
|
||||
userId: 'user:alice@example.com',
|
||||
conversationId: request.conversationId,
|
||||
status: 'created',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
timeline: [this.createEvent('created', { kind: 'generic', description: 'Run created' })],
|
||||
artifacts: [],
|
||||
metadata: request.metadata,
|
||||
};
|
||||
|
||||
this.runs.set(runId, run);
|
||||
return of(run).pipe(delay(200));
|
||||
}
|
||||
|
||||
get(runId: string): Observable<AiRun> {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
|
||||
}
|
||||
return of(run).pipe(delay(100));
|
||||
}
|
||||
|
||||
list(query: AiRunQuery = {}): Observable<AiRunListResponse> {
|
||||
let runs = Array.from(this.runs.values());
|
||||
|
||||
if (query.status) {
|
||||
runs = runs.filter((r) => r.status === query.status);
|
||||
}
|
||||
if (query.userId) {
|
||||
runs = runs.filter((r) => r.userId === query.userId);
|
||||
}
|
||||
if (query.conversationId) {
|
||||
runs = runs.filter((r) => r.conversationId === query.conversationId);
|
||||
}
|
||||
|
||||
const limit = query.limit ?? 50;
|
||||
const offset = query.offset ?? 0;
|
||||
const sliced = runs.slice(offset, offset + limit);
|
||||
|
||||
return of({
|
||||
count: sliced.length,
|
||||
runs: sliced.map((r) => this.toSummary(r)),
|
||||
}).pipe(delay(150));
|
||||
}
|
||||
|
||||
getTimeline(runId: string): Observable<RunEvent[]> {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
|
||||
}
|
||||
return of(run.timeline).pipe(delay(100));
|
||||
}
|
||||
|
||||
getArtifacts(runId: string): Observable<RunArtifact[]> {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
|
||||
}
|
||||
return of(run.artifacts).pipe(delay(100));
|
||||
}
|
||||
|
||||
addTurn(runId: string, request: AddTurnRequest): Observable<RunEvent> {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
|
||||
}
|
||||
|
||||
const eventType = request.role === 'user' ? 'user_turn' : 'assistant_turn';
|
||||
const event = this.createEvent(eventType, {
|
||||
kind: request.role === 'user' ? 'user_turn' : 'assistant_turn',
|
||||
turnId: `turn-${this.eventCounter}`,
|
||||
message: request.content,
|
||||
groundingScore: request.groundingScore,
|
||||
citations: request.citations,
|
||||
} as any);
|
||||
|
||||
run.timeline.push(event);
|
||||
run.status = 'active';
|
||||
run.updatedAt = new Date().toISOString();
|
||||
|
||||
return of(event).pipe(delay(200));
|
||||
}
|
||||
|
||||
proposeAction(runId: string, request: ProposeActionRequest): Observable<RunEvent> {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
|
||||
}
|
||||
|
||||
const event = this.createEvent('action_proposed', {
|
||||
kind: 'action',
|
||||
actionId: `action-${this.eventCounter}`,
|
||||
actionType: request.actionType,
|
||||
targetResource: request.targetResource,
|
||||
description: request.description,
|
||||
requiresApproval: true,
|
||||
});
|
||||
|
||||
run.timeline.push(event);
|
||||
run.status = 'pending_approval';
|
||||
run.updatedAt = new Date().toISOString();
|
||||
|
||||
return of(event).pipe(delay(200));
|
||||
}
|
||||
|
||||
submitApproval(runId: string, actionId: string, decision: ApprovalDecision): Observable<RunEvent> {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
|
||||
}
|
||||
|
||||
const eventType = decision.decision === 'approved' ? 'approval_granted' : 'approval_denied';
|
||||
const event = this.createEvent(eventType, {
|
||||
kind: 'approval',
|
||||
actionId,
|
||||
decision: decision.decision,
|
||||
approver: 'user:alice@example.com',
|
||||
reason: decision.reason,
|
||||
});
|
||||
|
||||
run.timeline.push(event);
|
||||
run.status = decision.decision === 'approved' ? 'approved' : 'rejected';
|
||||
run.updatedAt = new Date().toISOString();
|
||||
|
||||
return of(event).pipe(delay(300));
|
||||
}
|
||||
|
||||
complete(runId: string): Observable<AiRun> {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
|
||||
}
|
||||
|
||||
const event = this.createEvent('completed', {
|
||||
kind: 'generic',
|
||||
description: 'Run completed successfully',
|
||||
});
|
||||
|
||||
run.timeline.push(event);
|
||||
run.status = 'complete';
|
||||
run.completedAt = new Date().toISOString();
|
||||
run.updatedAt = run.completedAt;
|
||||
|
||||
return of(run).pipe(delay(200));
|
||||
}
|
||||
|
||||
createAttestation(runId: string): Observable<RunAttestation> {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
|
||||
}
|
||||
|
||||
const attestation: RunAttestation = {
|
||||
attestationId: `attest-${runId}`,
|
||||
contentDigest: `sha256:mock-${runId}`,
|
||||
signedAt: new Date().toISOString(),
|
||||
signatureKeyId: 'mock-signing-key',
|
||||
envelope: {
|
||||
payloadType: 'application/vnd.stellaops.ai-run+json',
|
||||
payloadDigest: `sha256:mock-${runId}`,
|
||||
signatureCount: 1,
|
||||
},
|
||||
};
|
||||
|
||||
run.attestation = attestation;
|
||||
|
||||
const event = this.createEvent('attestation_created', {
|
||||
kind: 'attestation',
|
||||
attestationId: attestation.attestationId,
|
||||
type: 'ai-run',
|
||||
contentDigest: attestation.contentDigest,
|
||||
signed: true,
|
||||
});
|
||||
run.timeline.push(event);
|
||||
|
||||
return of(attestation).pipe(delay(300));
|
||||
}
|
||||
|
||||
cancel(runId: string, reason?: string): Observable<AiRun> {
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
return throwError(() => new Error(`Run not found: ${runId}`)).pipe(delay(100));
|
||||
}
|
||||
|
||||
run.status = 'cancelled';
|
||||
run.completedAt = new Date().toISOString();
|
||||
run.updatedAt = run.completedAt;
|
||||
|
||||
return of(run).pipe(delay(200));
|
||||
}
|
||||
|
||||
private createEvent(type: RunEvent['type'], content: RunEvent['content']): RunEvent {
|
||||
this.eventCounter++;
|
||||
return {
|
||||
eventId: `event-${this.eventCounter.toString().padStart(4, '0')}`,
|
||||
type,
|
||||
timestamp: new Date().toISOString(),
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
private toSummary(run: AiRun): AiRunSummary {
|
||||
return {
|
||||
runId: run.runId,
|
||||
tenantId: run.tenantId,
|
||||
userId: run.userId,
|
||||
status: run.status,
|
||||
createdAt: run.createdAt,
|
||||
updatedAt: run.updatedAt,
|
||||
completedAt: run.completedAt,
|
||||
eventCount: run.timeline.length,
|
||||
artifactCount: run.artifacts.length,
|
||||
hasAttestation: !!run.attestation,
|
||||
metadata: run.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
private initializeSampleData(): void {
|
||||
const sampleRun: AiRun = {
|
||||
runId: 'run-sample-001',
|
||||
tenantId: 'mock-tenant',
|
||||
userId: 'user:alice@example.com',
|
||||
conversationId: 'conv-sample-001',
|
||||
status: 'complete',
|
||||
createdAt: '2026-01-10T09:00:00Z',
|
||||
updatedAt: '2026-01-10T09:15:00Z',
|
||||
completedAt: '2026-01-10T09:15:00Z',
|
||||
timeline: [
|
||||
{
|
||||
eventId: 'event-0001',
|
||||
type: 'created',
|
||||
timestamp: '2026-01-10T09:00:00Z',
|
||||
content: { kind: 'generic', description: 'Run created' },
|
||||
},
|
||||
{
|
||||
eventId: 'event-0002',
|
||||
type: 'user_turn',
|
||||
timestamp: '2026-01-10T09:01:00Z',
|
||||
content: {
|
||||
kind: 'user_turn',
|
||||
turnId: 'turn-001',
|
||||
message: 'Is CVE-2023-44487 affecting our api-gateway service?',
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: 'event-0003',
|
||||
type: 'assistant_turn',
|
||||
timestamp: '2026-01-10T09:02:00Z',
|
||||
content: {
|
||||
kind: 'assistant_turn',
|
||||
turnId: 'turn-002',
|
||||
message: 'Based on my analysis, CVE-2023-44487 (HTTP/2 Rapid Reset) affects the api-gateway service. The vulnerable http2 library version 1.0.0 is present in the SBOM [sbom:scan-abc123] and reachability analysis confirms the vulnerable function is reachable [reach:api-gateway:grpc.Server].',
|
||||
groundingScore: 0.92,
|
||||
citations: [
|
||||
{ type: 'sbom', path: 'scan-abc123', resolvedUri: 'stella://sbom/scan-abc123' },
|
||||
{ type: 'reach', path: 'api-gateway:grpc.Server', resolvedUri: 'stella://reach/api-gateway:grpc.Server' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: 'event-0004',
|
||||
type: 'grounding_validated',
|
||||
timestamp: '2026-01-10T09:02:01Z',
|
||||
content: {
|
||||
kind: 'grounding',
|
||||
turnId: 'turn-002',
|
||||
score: 0.92,
|
||||
isAcceptable: true,
|
||||
validLinks: 2,
|
||||
totalClaims: 2,
|
||||
groundedClaims: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: 'event-0005',
|
||||
type: 'evidence_pack_created',
|
||||
timestamp: '2026-01-10T09:02:02Z',
|
||||
content: {
|
||||
kind: 'evidence_pack',
|
||||
packId: 'pack-sample-001',
|
||||
claimCount: 2,
|
||||
evidenceCount: 3,
|
||||
contentDigest: 'sha256:abc123',
|
||||
},
|
||||
},
|
||||
{
|
||||
eventId: 'event-0006',
|
||||
type: 'completed',
|
||||
timestamp: '2026-01-10T09:15:00Z',
|
||||
content: { kind: 'generic', description: 'Run completed successfully' },
|
||||
},
|
||||
],
|
||||
artifacts: [
|
||||
{
|
||||
artifactId: 'pack-sample-001',
|
||||
type: 'EvidencePack',
|
||||
name: 'Evidence Pack - CVE-2023-44487',
|
||||
contentDigest: 'sha256:abc123',
|
||||
uri: 'stella://evidence-pack/pack-sample-001',
|
||||
createdAt: '2026-01-10T09:02:02Z',
|
||||
},
|
||||
],
|
||||
attestation: {
|
||||
attestationId: 'attest-run-sample-001',
|
||||
contentDigest: 'sha256:run-sample-001',
|
||||
signedAt: '2026-01-10T09:15:01Z',
|
||||
signatureKeyId: 'ai-run-signing-key',
|
||||
envelope: {
|
||||
payloadType: 'application/vnd.stellaops.ai-run+json',
|
||||
payloadDigest: 'sha256:run-sample-001',
|
||||
signatureCount: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
this.runs.set(sampleRun.runId, sampleRun);
|
||||
}
|
||||
}
|
||||
229
src/Web/StellaOps.Web/src/app/core/api/ai-runs.models.ts
Normal file
229
src/Web/StellaOps.Web/src/app/core/api/ai-runs.models.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* AI Runs API models.
|
||||
* Sprint: SPRINT_20260109_011_003 Task: Frontend Unblock
|
||||
*
|
||||
* AI Runs are immutable records of AI-assisted conversations and decisions.
|
||||
* They provide audit trails, reproducibility, and governance capabilities.
|
||||
*/
|
||||
|
||||
// ========== Run Status ==========
|
||||
|
||||
export type AiRunStatus =
|
||||
| 'created'
|
||||
| 'active'
|
||||
| 'pending_approval'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'complete'
|
||||
| 'cancelled';
|
||||
|
||||
// ========== Timeline Events ==========
|
||||
|
||||
export type RunEventType =
|
||||
| 'created'
|
||||
| 'user_turn'
|
||||
| 'assistant_turn'
|
||||
| 'grounding_validated'
|
||||
| 'evidence_pack_created'
|
||||
| 'action_proposed'
|
||||
| 'approval_requested'
|
||||
| 'approval_granted'
|
||||
| 'approval_denied'
|
||||
| 'action_executed'
|
||||
| 'attestation_created'
|
||||
| 'completed';
|
||||
|
||||
export interface RunEvent {
|
||||
eventId: string;
|
||||
type: RunEventType;
|
||||
timestamp: string;
|
||||
content: RunEventContent;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type RunEventContent =
|
||||
| UserTurnContent
|
||||
| AssistantTurnContent
|
||||
| GroundingContent
|
||||
| EvidencePackContent
|
||||
| ActionContent
|
||||
| ApprovalContent
|
||||
| AttestationContent
|
||||
| GenericEventContent;
|
||||
|
||||
export interface UserTurnContent {
|
||||
kind: 'user_turn';
|
||||
turnId: string;
|
||||
message: string;
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
export interface AssistantTurnContent {
|
||||
kind: 'assistant_turn';
|
||||
turnId: string;
|
||||
message: string;
|
||||
groundingScore?: number;
|
||||
citations?: Citation[];
|
||||
}
|
||||
|
||||
export interface Citation {
|
||||
type: string;
|
||||
path: string;
|
||||
resolvedUri?: string;
|
||||
}
|
||||
|
||||
export interface GroundingContent {
|
||||
kind: 'grounding';
|
||||
turnId: string;
|
||||
score: number;
|
||||
isAcceptable: boolean;
|
||||
validLinks: number;
|
||||
totalClaims: number;
|
||||
groundedClaims: number;
|
||||
}
|
||||
|
||||
export interface EvidencePackContent {
|
||||
kind: 'evidence_pack';
|
||||
packId: string;
|
||||
claimCount: number;
|
||||
evidenceCount: number;
|
||||
contentDigest: string;
|
||||
}
|
||||
|
||||
export interface ActionContent {
|
||||
kind: 'action';
|
||||
actionId: string;
|
||||
actionType: string;
|
||||
targetResource: string;
|
||||
description: string;
|
||||
requiresApproval: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalContent {
|
||||
kind: 'approval';
|
||||
actionId: string;
|
||||
decision: 'approved' | 'denied';
|
||||
approver: string;
|
||||
reason?: string;
|
||||
policyGate?: string;
|
||||
}
|
||||
|
||||
export interface AttestationContent {
|
||||
kind: 'attestation';
|
||||
attestationId: string;
|
||||
type: string;
|
||||
contentDigest: string;
|
||||
signed: boolean;
|
||||
}
|
||||
|
||||
export interface GenericEventContent {
|
||||
kind: 'generic';
|
||||
description: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ========== Run Artifacts ==========
|
||||
|
||||
export type RunArtifactType = 'EvidencePack' | 'VexDecision' | 'PolicyAction' | 'Attestation' | 'Custom';
|
||||
|
||||
export interface RunArtifact {
|
||||
artifactId: string;
|
||||
type: RunArtifactType;
|
||||
name: string;
|
||||
contentDigest: string;
|
||||
uri: string;
|
||||
createdAt: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// ========== AI Run ==========
|
||||
|
||||
export interface AiRun {
|
||||
runId: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
conversationId?: string;
|
||||
status: AiRunStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
timeline: RunEvent[];
|
||||
artifacts: RunArtifact[];
|
||||
attestation?: RunAttestation;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RunAttestation {
|
||||
attestationId: string;
|
||||
contentDigest: string;
|
||||
signedAt?: string;
|
||||
signatureKeyId?: string;
|
||||
envelope?: DsseEnvelopeRef;
|
||||
}
|
||||
|
||||
export interface DsseEnvelopeRef {
|
||||
payloadType: string;
|
||||
payloadDigest: string;
|
||||
signatureCount: number;
|
||||
}
|
||||
|
||||
// ========== Summary for List Views ==========
|
||||
|
||||
export interface AiRunSummary {
|
||||
runId: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
status: AiRunStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
eventCount: number;
|
||||
artifactCount: number;
|
||||
hasAttestation: boolean;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// ========== API Request/Response ==========
|
||||
|
||||
export interface CreateAiRunRequest {
|
||||
conversationId?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AddTurnRequest {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
groundingScore?: number;
|
||||
citations?: Citation[];
|
||||
}
|
||||
|
||||
export interface ProposeActionRequest {
|
||||
actionType: string;
|
||||
targetResource: string;
|
||||
description: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ApprovalDecision {
|
||||
decision: 'approved' | 'denied';
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AiRunListResponse {
|
||||
count: number;
|
||||
runs: AiRunSummary[];
|
||||
}
|
||||
|
||||
export interface AiRunQuery {
|
||||
status?: AiRunStatus;
|
||||
userId?: string;
|
||||
conversationId?: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AiRunQueryOptions {
|
||||
traceId?: string;
|
||||
}
|
||||
401
src/Web/StellaOps.Web/src/app/core/api/evidence-pack.client.ts
Normal file
401
src/Web/StellaOps.Web/src/app/core/api/evidence-pack.client.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Evidence Pack API client.
|
||||
* Sprint: SPRINT_20260109_011_005 Task: EVPK-008 (Frontend Unblock)
|
||||
*
|
||||
* Provides access to Evidence Pack endpoints for creating, signing,
|
||||
* verifying, and exporting evidence packs.
|
||||
*/
|
||||
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||
import { Observable, of, delay, throwError } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import {
|
||||
EvidencePack,
|
||||
SignedEvidencePack,
|
||||
EvidencePackVerificationResult,
|
||||
EvidencePackExportFormat,
|
||||
CreateEvidencePackRequest,
|
||||
EvidencePackListResponse,
|
||||
EvidencePackQuery,
|
||||
EvidencePackQueryOptions,
|
||||
EvidencePackSummary,
|
||||
DsseEnvelope,
|
||||
EvidenceSubject,
|
||||
EvidenceClaim,
|
||||
EvidenceItem,
|
||||
} from './evidence-pack.models';
|
||||
|
||||
// ========== API Interface ==========
|
||||
|
||||
export interface EvidencePackApi {
|
||||
/** Creates a new evidence pack */
|
||||
create(request: CreateEvidencePackRequest, options?: EvidencePackQueryOptions): Observable<EvidencePack>;
|
||||
|
||||
/** Gets an evidence pack by ID */
|
||||
get(packId: string, options?: EvidencePackQueryOptions): Observable<EvidencePack>;
|
||||
|
||||
/** Lists evidence packs with optional filters */
|
||||
list(query?: EvidencePackQuery, options?: EvidencePackQueryOptions): Observable<EvidencePackListResponse>;
|
||||
|
||||
/** Lists evidence packs for a specific run */
|
||||
listByRun(runId: string, options?: EvidencePackQueryOptions): Observable<EvidencePackListResponse>;
|
||||
|
||||
/** Signs an evidence pack */
|
||||
sign(packId: string, options?: EvidencePackQueryOptions): Observable<SignedEvidencePack>;
|
||||
|
||||
/** Verifies a signed evidence pack */
|
||||
verify(packId: string, options?: EvidencePackQueryOptions): Observable<EvidencePackVerificationResult>;
|
||||
|
||||
/** Exports an evidence pack in the specified format */
|
||||
export(packId: string, format: EvidencePackExportFormat, options?: EvidencePackQueryOptions): Observable<Blob>;
|
||||
}
|
||||
|
||||
// ========== DI Tokens ==========
|
||||
|
||||
export const EVIDENCE_PACK_API = new InjectionToken<EvidencePackApi>('EVIDENCE_PACK_API');
|
||||
export const EVIDENCE_PACK_API_BASE_URL = new InjectionToken<string>('EVIDENCE_PACK_API_BASE_URL');
|
||||
|
||||
// ========== HTTP Implementation ==========
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EvidencePackHttpClient implements EvidencePackApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
private readonly baseUrl = inject(EVIDENCE_PACK_API_BASE_URL, { optional: true }) ?? '/v1/evidence-packs';
|
||||
|
||||
create(request: CreateEvidencePackRequest, options: EvidencePackQueryOptions = {}): Observable<EvidencePack> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.post<EvidencePack>(this.baseUrl, request, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
get(packId: string, options: EvidencePackQueryOptions = {}): Observable<EvidencePack> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.get<EvidencePack>(`${this.baseUrl}/${packId}`, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
list(query: EvidencePackQuery = {}, options: EvidencePackQueryOptions = {}): Observable<EvidencePackListResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const params = this.buildQueryParams(query);
|
||||
return this.http
|
||||
.get<EvidencePackListResponse>(this.baseUrl, { headers: this.buildHeaders(traceId), params })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
listByRun(runId: string, options: EvidencePackQueryOptions = {}): Observable<EvidencePackListResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.get<EvidencePackListResponse>(`/v1/runs/${runId}/evidence-packs`, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
sign(packId: string, options: EvidencePackQueryOptions = {}): Observable<SignedEvidencePack> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.post<SignedEvidencePack>(`${this.baseUrl}/${packId}/sign`, {}, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
verify(packId: string, options: EvidencePackQueryOptions = {}): Observable<EvidencePackVerificationResult> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
return this.http
|
||||
.post<EvidencePackVerificationResult>(`${this.baseUrl}/${packId}/verify`, {}, { headers: this.buildHeaders(traceId) })
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
export(packId: string, format: EvidencePackExportFormat, options: EvidencePackQueryOptions = {}): Observable<Blob> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const formatParam = format.toLowerCase();
|
||||
return this.http
|
||||
.get(`${this.baseUrl}/${packId}/export`, {
|
||||
headers: this.buildHeaders(traceId),
|
||||
params: { format: formatParam },
|
||||
responseType: 'blob',
|
||||
})
|
||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||
}
|
||||
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
private buildQueryParams(query: EvidencePackQuery): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
if (query.cveId) params['cveId'] = query.cveId;
|
||||
if (query.runId) params['runId'] = query.runId;
|
||||
if (query.limit) params['limit'] = query.limit.toString();
|
||||
if (query.offset) params['offset'] = query.offset.toString();
|
||||
return params;
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
return err instanceof Error
|
||||
? new Error(`[${traceId}] Evidence Pack error: ${err.message}`)
|
||||
: new Error(`[${traceId}] Evidence Pack error: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Mock Implementation ==========
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockEvidencePackClient implements EvidencePackApi {
|
||||
private packs: Map<string, EvidencePack> = new Map();
|
||||
private signedPacks: Map<string, SignedEvidencePack> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Initialize with sample data
|
||||
this.initializeSampleData();
|
||||
}
|
||||
|
||||
create(request: CreateEvidencePackRequest): Observable<EvidencePack> {
|
||||
const packId = `pack-${Date.now().toString(36)}`;
|
||||
const pack: EvidencePack = {
|
||||
packId,
|
||||
version: '1.0',
|
||||
createdAt: new Date().toISOString(),
|
||||
tenantId: 'mock-tenant',
|
||||
subject: request.subject,
|
||||
claims: request.claims.map((c, i) => ({ ...c, claimId: `claim-${i.toString().padStart(3, '0')}` })),
|
||||
evidence: request.evidence.map((e, i) => ({ ...e, evidenceId: `ev-${i.toString().padStart(3, '0')}` })),
|
||||
context: {
|
||||
runId: request.runId,
|
||||
conversationId: request.conversationId,
|
||||
generatedBy: 'MockClient',
|
||||
},
|
||||
};
|
||||
this.packs.set(packId, pack);
|
||||
return of(pack).pipe(delay(200));
|
||||
}
|
||||
|
||||
get(packId: string): Observable<EvidencePack> {
|
||||
const pack = this.packs.get(packId);
|
||||
if (!pack) {
|
||||
return throwError(() => new Error(`Pack not found: ${packId}`)).pipe(delay(100));
|
||||
}
|
||||
return of(pack).pipe(delay(100));
|
||||
}
|
||||
|
||||
list(query: EvidencePackQuery = {}): Observable<EvidencePackListResponse> {
|
||||
let packs = Array.from(this.packs.values());
|
||||
|
||||
if (query.cveId) {
|
||||
packs = packs.filter((p) => p.subject.cveId === query.cveId);
|
||||
}
|
||||
if (query.runId) {
|
||||
packs = packs.filter((p) => p.context?.runId === query.runId);
|
||||
}
|
||||
|
||||
const limit = query.limit ?? 50;
|
||||
const offset = query.offset ?? 0;
|
||||
const sliced = packs.slice(offset, offset + limit);
|
||||
|
||||
return of({
|
||||
count: sliced.length,
|
||||
packs: sliced.map((p) => this.toSummary(p)),
|
||||
}).pipe(delay(150));
|
||||
}
|
||||
|
||||
listByRun(runId: string): Observable<EvidencePackListResponse> {
|
||||
return this.list({ runId });
|
||||
}
|
||||
|
||||
sign(packId: string): Observable<SignedEvidencePack> {
|
||||
const pack = this.packs.get(packId);
|
||||
if (!pack) {
|
||||
return throwError(() => new Error(`Pack not found: ${packId}`)).pipe(delay(100));
|
||||
}
|
||||
|
||||
const signedPack: SignedEvidencePack = {
|
||||
pack,
|
||||
envelope: {
|
||||
payloadType: 'application/vnd.stellaops.evidence-pack+json',
|
||||
payload: btoa(JSON.stringify(pack)),
|
||||
payloadDigest: `sha256:mock-${packId}`,
|
||||
signatures: [{ keyId: 'mock-signing-key', sig: 'mock-signature-base64' }],
|
||||
},
|
||||
signedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.signedPacks.set(packId, signedPack);
|
||||
return of(signedPack).pipe(delay(300));
|
||||
}
|
||||
|
||||
verify(packId: string): Observable<EvidencePackVerificationResult> {
|
||||
const signedPack = this.signedPacks.get(packId);
|
||||
if (!signedPack) {
|
||||
return of({
|
||||
valid: false,
|
||||
issues: ['Pack is not signed'],
|
||||
evidenceResolutions: [],
|
||||
}).pipe(delay(200));
|
||||
}
|
||||
|
||||
return of({
|
||||
valid: true,
|
||||
packDigest: signedPack.envelope.payloadDigest,
|
||||
signatureKeyId: signedPack.envelope.signatures[0]?.keyId,
|
||||
issues: [],
|
||||
evidenceResolutions: signedPack.pack.evidence.map((e) => ({
|
||||
evidenceId: e.evidenceId,
|
||||
uri: e.uri,
|
||||
resolved: true,
|
||||
digestMatches: true,
|
||||
})),
|
||||
}).pipe(delay(400));
|
||||
}
|
||||
|
||||
export(packId: string, format: EvidencePackExportFormat): Observable<Blob> {
|
||||
const pack = this.packs.get(packId);
|
||||
if (!pack) {
|
||||
return throwError(() => new Error(`Pack not found: ${packId}`)).pipe(delay(100));
|
||||
}
|
||||
|
||||
let content: string;
|
||||
let contentType: string;
|
||||
|
||||
switch (format) {
|
||||
case 'Json':
|
||||
content = JSON.stringify(pack, null, 2);
|
||||
contentType = 'application/json';
|
||||
break;
|
||||
case 'Markdown':
|
||||
content = this.toMarkdown(pack);
|
||||
contentType = 'text/markdown';
|
||||
break;
|
||||
case 'Html':
|
||||
content = `<html><body><pre>${this.toMarkdown(pack)}</pre></body></html>`;
|
||||
contentType = 'text/html';
|
||||
break;
|
||||
default:
|
||||
content = JSON.stringify(pack, null, 2);
|
||||
contentType = 'application/json';
|
||||
}
|
||||
|
||||
return of(new Blob([content], { type: contentType })).pipe(delay(200));
|
||||
}
|
||||
|
||||
private toSummary(pack: EvidencePack): EvidencePackSummary {
|
||||
return {
|
||||
packId: pack.packId,
|
||||
tenantId: pack.tenantId,
|
||||
createdAt: pack.createdAt,
|
||||
subjectType: pack.subject.type,
|
||||
cveId: pack.subject.cveId,
|
||||
claimCount: pack.claims.length,
|
||||
evidenceCount: pack.evidence.length,
|
||||
};
|
||||
}
|
||||
|
||||
private toMarkdown(pack: EvidencePack): string {
|
||||
let md = `# Evidence Pack: ${pack.packId}\n\n`;
|
||||
md += `**Created:** ${pack.createdAt}\n`;
|
||||
md += `**Subject:** ${pack.subject.type} - ${pack.subject.cveId || pack.subject.findingId || 'N/A'}\n\n`;
|
||||
|
||||
md += `## Claims (${pack.claims.length})\n\n`;
|
||||
for (const claim of pack.claims) {
|
||||
md += `### ${claim.claimId}: ${claim.text}\n`;
|
||||
md += `- **Type:** ${claim.type}\n`;
|
||||
md += `- **Status:** ${claim.status}\n`;
|
||||
md += `- **Confidence:** ${(claim.confidence * 100).toFixed(0)}%\n\n`;
|
||||
}
|
||||
|
||||
md += `## Evidence (${pack.evidence.length})\n\n`;
|
||||
for (const evidence of pack.evidence) {
|
||||
md += `### ${evidence.evidenceId}: ${evidence.type}\n`;
|
||||
md += `- **URI:** \`${evidence.uri}\`\n`;
|
||||
md += `- **Digest:** \`${evidence.digest}\`\n\n`;
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
private initializeSampleData(): void {
|
||||
const samplePack: EvidencePack = {
|
||||
packId: 'pack-sample-001',
|
||||
version: '1.0',
|
||||
createdAt: '2026-01-10T10:00:00Z',
|
||||
tenantId: 'mock-tenant',
|
||||
subject: {
|
||||
type: 'Cve',
|
||||
cveId: 'CVE-2023-44487',
|
||||
component: 'pkg:npm/http2@1.0.0',
|
||||
},
|
||||
claims: [
|
||||
{
|
||||
claimId: 'claim-001',
|
||||
text: 'Component is affected by CVE-2023-44487 (HTTP/2 Rapid Reset)',
|
||||
type: 'VulnerabilityStatus',
|
||||
status: 'affected',
|
||||
confidence: 0.92,
|
||||
evidenceIds: ['ev-001', 'ev-002'],
|
||||
source: 'ai',
|
||||
},
|
||||
{
|
||||
claimId: 'claim-002',
|
||||
text: 'Vulnerable function is reachable from api-gateway',
|
||||
type: 'Reachability',
|
||||
status: 'reachable',
|
||||
confidence: 0.88,
|
||||
evidenceIds: ['ev-003'],
|
||||
source: 'ai',
|
||||
},
|
||||
],
|
||||
evidence: [
|
||||
{
|
||||
evidenceId: 'ev-001',
|
||||
type: 'Sbom',
|
||||
uri: 'stella://sbom/scan-2026-01-10-abc123',
|
||||
digest: 'sha256:abc123...',
|
||||
collectedAt: '2026-01-10T09:00:00Z',
|
||||
snapshot: {
|
||||
type: 'sbom',
|
||||
data: { format: 'cyclonedx', version: '1.4', componentCount: 100 },
|
||||
},
|
||||
},
|
||||
{
|
||||
evidenceId: 'ev-002',
|
||||
type: 'Vex',
|
||||
uri: 'stella://vex/nvd:CVE-2023-44487',
|
||||
digest: 'sha256:def456...',
|
||||
collectedAt: '2026-01-10T09:05:00Z',
|
||||
snapshot: {
|
||||
type: 'vex',
|
||||
data: { issuer: 'nvd', status: 'affected' },
|
||||
},
|
||||
},
|
||||
{
|
||||
evidenceId: 'ev-003',
|
||||
type: 'Reachability',
|
||||
uri: 'stella://reach/api-gateway:grpc.Server',
|
||||
digest: 'sha256:ghi789...',
|
||||
collectedAt: '2026-01-10T09:10:00Z',
|
||||
snapshot: {
|
||||
type: 'reachability',
|
||||
data: { latticeState: 'ConfirmedReachable', confidence: 0.88 },
|
||||
},
|
||||
},
|
||||
],
|
||||
context: {
|
||||
runId: 'run-sample-001',
|
||||
conversationId: 'conv-sample-001',
|
||||
userId: 'user:alice@example.com',
|
||||
generatedBy: 'AdvisoryAI v2.1',
|
||||
},
|
||||
};
|
||||
|
||||
this.packs.set(samplePack.packId, samplePack);
|
||||
}
|
||||
}
|
||||
178
src/Web/StellaOps.Web/src/app/core/api/evidence-pack.models.ts
Normal file
178
src/Web/StellaOps.Web/src/app/core/api/evidence-pack.models.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Evidence Pack API models.
|
||||
* Sprint: SPRINT_20260109_011_005 Task: EVPK-008 (Frontend Unblock)
|
||||
*/
|
||||
|
||||
// ========== Subject Types ==========
|
||||
|
||||
export type EvidenceSubjectType = 'Finding' | 'Cve' | 'Component' | 'Image' | 'Policy' | 'Custom';
|
||||
|
||||
export interface EvidenceSubject {
|
||||
type: EvidenceSubjectType;
|
||||
findingId?: string;
|
||||
cveId?: string;
|
||||
component?: string;
|
||||
imageDigest?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// ========== Claim Types ==========
|
||||
|
||||
export type ClaimType =
|
||||
| 'VulnerabilityStatus'
|
||||
| 'Reachability'
|
||||
| 'FixAvailability'
|
||||
| 'Severity'
|
||||
| 'Exploitability'
|
||||
| 'Compliance'
|
||||
| 'Custom';
|
||||
|
||||
export interface EvidenceClaim {
|
||||
claimId: string;
|
||||
text: string;
|
||||
type: ClaimType;
|
||||
status: string;
|
||||
confidence: number;
|
||||
evidenceIds: string[];
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// ========== Evidence Types ==========
|
||||
|
||||
export type EvidenceType =
|
||||
| 'Sbom'
|
||||
| 'Vex'
|
||||
| 'Reachability'
|
||||
| 'Runtime'
|
||||
| 'Attestation'
|
||||
| 'Advisory'
|
||||
| 'Patch'
|
||||
| 'Policy'
|
||||
| 'OpsMemory'
|
||||
| 'Custom';
|
||||
|
||||
export interface EvidenceSnapshot {
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EvidenceItem {
|
||||
evidenceId: string;
|
||||
type: EvidenceType;
|
||||
uri: string;
|
||||
digest: string;
|
||||
collectedAt: string;
|
||||
snapshot: EvidenceSnapshot;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// ========== Context ==========
|
||||
|
||||
export interface EvidencePackContext {
|
||||
tenantId?: string;
|
||||
runId?: string;
|
||||
conversationId?: string;
|
||||
userId?: string;
|
||||
generatedBy?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// ========== Pack ==========
|
||||
|
||||
export interface EvidencePack {
|
||||
packId: string;
|
||||
version: string;
|
||||
createdAt: string;
|
||||
tenantId: string;
|
||||
subject: EvidenceSubject;
|
||||
claims: EvidenceClaim[];
|
||||
evidence: EvidenceItem[];
|
||||
context?: EvidencePackContext;
|
||||
contentDigest?: string;
|
||||
}
|
||||
|
||||
// ========== Signed Pack ==========
|
||||
|
||||
export interface DsseSignature {
|
||||
keyId: string;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
export interface DsseEnvelope {
|
||||
payloadType: string;
|
||||
payload: string;
|
||||
payloadDigest: string;
|
||||
signatures: DsseSignature[];
|
||||
}
|
||||
|
||||
export interface SignedEvidencePack {
|
||||
pack: EvidencePack;
|
||||
envelope: DsseEnvelope;
|
||||
signedAt: string;
|
||||
}
|
||||
|
||||
// ========== Verification ==========
|
||||
|
||||
export interface EvidenceResolutionResult {
|
||||
evidenceId: string;
|
||||
uri: string;
|
||||
resolved: boolean;
|
||||
digestMatches: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface EvidencePackVerificationResult {
|
||||
valid: boolean;
|
||||
packDigest?: string;
|
||||
signatureKeyId?: string;
|
||||
issues: string[];
|
||||
evidenceResolutions: EvidenceResolutionResult[];
|
||||
}
|
||||
|
||||
// ========== Export ==========
|
||||
|
||||
export type EvidencePackExportFormat = 'Json' | 'SignedJson' | 'Markdown' | 'Html' | 'Pdf';
|
||||
|
||||
export interface EvidencePackExport {
|
||||
packId: string;
|
||||
format: EvidencePackExportFormat;
|
||||
content: Blob;
|
||||
contentType: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
// ========== API Request/Response ==========
|
||||
|
||||
export interface CreateEvidencePackRequest {
|
||||
subject: EvidenceSubject;
|
||||
claims: Omit<EvidenceClaim, 'claimId'>[];
|
||||
evidence: Omit<EvidenceItem, 'evidenceId'>[];
|
||||
runId?: string;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
export interface EvidencePackListResponse {
|
||||
count: number;
|
||||
packs: EvidencePackSummary[];
|
||||
}
|
||||
|
||||
export interface EvidencePackSummary {
|
||||
packId: string;
|
||||
tenantId: string;
|
||||
createdAt: string;
|
||||
subjectType: string;
|
||||
cveId?: string;
|
||||
claimCount: number;
|
||||
evidenceCount: number;
|
||||
}
|
||||
|
||||
export interface EvidencePackQuery {
|
||||
cveId?: string;
|
||||
runId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface EvidencePackQueryOptions {
|
||||
traceId?: string;
|
||||
}
|
||||
@@ -0,0 +1,931 @@
|
||||
/**
|
||||
* AI Run Viewer Component
|
||||
* Sprint: SPRINT_20260109_011_003 Task: Frontend Unblock
|
||||
*
|
||||
* Displays AI Run details including timeline events, artifacts,
|
||||
* and attestation status.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
OnInit,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
AiRun,
|
||||
RunEvent,
|
||||
RunArtifact,
|
||||
AiRunStatus,
|
||||
RunEventContent,
|
||||
UserTurnContent,
|
||||
AssistantTurnContent,
|
||||
GroundingContent,
|
||||
EvidencePackContent,
|
||||
ActionContent,
|
||||
ApprovalContent,
|
||||
AttestationContent,
|
||||
} from '../../core/api/ai-runs.models';
|
||||
import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
|
||||
@Component({
|
||||
selector: 'stellaops-ai-run-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="ai-run-viewer" [class.loading]="loading()">
|
||||
@if (loading()) {
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading AI run...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="error-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="error-icon">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<p>{{ error() }}</p>
|
||||
<button type="button" class="retry-btn" (click)="loadRun()">Retry</button>
|
||||
</div>
|
||||
} @else if (run()) {
|
||||
<!-- Header -->
|
||||
<header class="run-header">
|
||||
<div class="header-left">
|
||||
<h2 class="run-title">AI Run</h2>
|
||||
<span class="run-id">{{ run()!.runId }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="status-badge" [class]="'status-' + run()!.status">
|
||||
{{ run()!.status }}
|
||||
</span>
|
||||
@if (run()!.attestation) {
|
||||
<span class="attested-badge">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
<polyline points="9 12 12 15 15 9"/>
|
||||
</svg>
|
||||
Attested
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Run Info -->
|
||||
<section class="run-section info-section">
|
||||
<dl class="info-grid">
|
||||
<div class="info-item">
|
||||
<dt>Created</dt>
|
||||
<dd>{{ run()!.createdAt | date:'medium' }}</dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ run()!.updatedAt | date:'medium' }}</dd>
|
||||
</div>
|
||||
@if (run()!.completedAt) {
|
||||
<div class="info-item">
|
||||
<dt>Completed</dt>
|
||||
<dd>{{ run()!.completedAt | date:'medium' }}</dd>
|
||||
</div>
|
||||
}
|
||||
<div class="info-item">
|
||||
<dt>User</dt>
|
||||
<dd>{{ run()!.userId }}</dd>
|
||||
</div>
|
||||
@if (run()!.conversationId) {
|
||||
<div class="info-item">
|
||||
<dt>Conversation</dt>
|
||||
<dd class="monospace">{{ run()!.conversationId }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- Timeline -->
|
||||
<section class="run-section timeline-section">
|
||||
<h3 class="section-title">
|
||||
Timeline
|
||||
<span class="count-badge">{{ run()!.timeline.length }}</span>
|
||||
</h3>
|
||||
<div class="timeline">
|
||||
@for (event of run()!.timeline; track event.eventId) {
|
||||
<div class="timeline-item" [class]="'event-' + event.type">
|
||||
<div class="timeline-marker">
|
||||
<div class="marker-dot"></div>
|
||||
<div class="marker-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="event-header">
|
||||
<span class="event-type">{{ formatEventType(event.type) }}</span>
|
||||
<span class="event-time">{{ event.timestamp | date:'shortTime' }}</span>
|
||||
</div>
|
||||
<div class="event-body">
|
||||
@switch (event.content.kind) {
|
||||
@case ('user_turn') {
|
||||
<div class="turn-content user-turn">
|
||||
<p class="turn-message">{{ asUserTurn(event.content).message }}</p>
|
||||
</div>
|
||||
}
|
||||
@case ('assistant_turn') {
|
||||
<div class="turn-content assistant-turn">
|
||||
<p class="turn-message">{{ asAssistantTurn(event.content).message }}</p>
|
||||
@if (asAssistantTurn(event.content).groundingScore !== undefined) {
|
||||
<span class="grounding-score">
|
||||
Grounding: {{ (asAssistantTurn(event.content).groundingScore! * 100).toFixed(0) }}%
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('grounding') {
|
||||
<div class="grounding-content">
|
||||
<span class="grounding-score" [class.acceptable]="asGrounding(event.content).isAcceptable">
|
||||
Score: {{ (asGrounding(event.content).score * 100).toFixed(0) }}%
|
||||
</span>
|
||||
<span class="grounding-detail">
|
||||
{{ asGrounding(event.content).groundedClaims }}/{{ asGrounding(event.content).totalClaims }} claims grounded
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@case ('evidence_pack') {
|
||||
<div class="evidence-pack-content">
|
||||
<button
|
||||
type="button"
|
||||
class="pack-link"
|
||||
(click)="onNavigateToEvidencePack(asEvidencePack(event.content).packId)">
|
||||
{{ asEvidencePack(event.content).packId }}
|
||||
</button>
|
||||
<span class="pack-stats">
|
||||
{{ asEvidencePack(event.content).claimCount }} claims,
|
||||
{{ asEvidencePack(event.content).evidenceCount }} evidence items
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@case ('action') {
|
||||
<div class="action-content">
|
||||
<span class="action-type">{{ asAction(event.content).actionType }}</span>
|
||||
<p class="action-description">{{ asAction(event.content).description }}</p>
|
||||
<span class="action-target">Target: {{ asAction(event.content).targetResource }}</span>
|
||||
@if (asAction(event.content).requiresApproval) {
|
||||
<span class="requires-approval">Requires Approval</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('approval') {
|
||||
<div class="approval-content" [class]="asApproval(event.content).decision">
|
||||
<span class="approval-decision">{{ asApproval(event.content).decision }}</span>
|
||||
<span class="approval-by">by {{ asApproval(event.content).approver }}</span>
|
||||
@if (asApproval(event.content).reason) {
|
||||
<p class="approval-reason">{{ asApproval(event.content).reason }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('attestation') {
|
||||
<div class="attestation-content">
|
||||
<span class="attestation-type">{{ asAttestation(event.content).type }}</span>
|
||||
<span class="attestation-id">{{ asAttestation(event.content).attestationId }}</span>
|
||||
@if (asAttestation(event.content).signed) {
|
||||
<span class="signed-indicator">Signed</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
<div class="generic-content">
|
||||
<p>{{ event.content | json }}</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Artifacts -->
|
||||
@if (run()!.artifacts.length > 0) {
|
||||
<section class="run-section artifacts-section">
|
||||
<h3 class="section-title">
|
||||
Artifacts
|
||||
<span class="count-badge">{{ run()!.artifacts.length }}</span>
|
||||
</h3>
|
||||
<div class="artifacts-list">
|
||||
@for (artifact of run()!.artifacts; track artifact.artifactId) {
|
||||
<div class="artifact-card">
|
||||
<div class="artifact-header">
|
||||
<span class="artifact-type">{{ artifact.type }}</span>
|
||||
<span class="artifact-date">{{ artifact.createdAt | date:'shortDate' }}</span>
|
||||
</div>
|
||||
<div class="artifact-body">
|
||||
<span class="artifact-name">{{ artifact.name }}</span>
|
||||
<code class="artifact-uri">{{ artifact.uri }}</code>
|
||||
</div>
|
||||
<div class="artifact-footer">
|
||||
<code class="artifact-digest">{{ artifact.contentDigest }}</code>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Attestation -->
|
||||
@if (run()!.attestation) {
|
||||
<section class="run-section attestation-section">
|
||||
<h3 class="section-title">Attestation</h3>
|
||||
<dl class="info-grid">
|
||||
<div class="info-item">
|
||||
<dt>Attestation ID</dt>
|
||||
<dd class="monospace">{{ run()!.attestation!.attestationId }}</dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>Content Digest</dt>
|
||||
<dd class="monospace">{{ run()!.attestation!.contentDigest }}</dd>
|
||||
</div>
|
||||
@if (run()!.attestation!.signedAt) {
|
||||
<div class="info-item">
|
||||
<dt>Signed At</dt>
|
||||
<dd>{{ run()!.attestation!.signedAt | date:'medium' }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (run()!.attestation!.signatureKeyId) {
|
||||
<div class="info-item">
|
||||
<dt>Signature Key</dt>
|
||||
<dd class="monospace">{{ run()!.attestation!.signatureKeyId }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Actions -->
|
||||
@if (canApprove()) {
|
||||
<section class="run-section actions-section">
|
||||
<h3 class="section-title">Pending Approval</h3>
|
||||
<div class="approval-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="approve-btn"
|
||||
(click)="onApprove()"
|
||||
[disabled]="processing()">
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="reject-btn"
|
||||
(click)="onReject()"
|
||||
[disabled]="processing()">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="run-footer">
|
||||
<span class="footer-item">Tenant: {{ run()!.tenantId }}</span>
|
||||
</footer>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<p>No AI run loaded</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.ai-run-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.loading-state, .error-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-color, #e0e0e0);
|
||||
border-top-color: var(--primary-color, #3b82f6);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--error-color, #ef4444);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.run-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.run-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.run-id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-created { background: #e0e7ff; color: #3730a3; }
|
||||
.status-active { background: #dbeafe; color: #1e40af; }
|
||||
.status-pending_approval { background: #fef3c7; color: #92400e; }
|
||||
.status-approved { background: #dcfce7; color: #166534; }
|
||||
.status-rejected { background: #fee2e2; color: #991b1b; }
|
||||
.status-complete { background: #d1fae5; color: #065f46; }
|
||||
.status-cancelled { background: #f3f4f6; color: #4b5563; }
|
||||
|
||||
.attested-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.attested-badge svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.run-section {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111);
|
||||
margin: 0 0 0.75rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
color: var(--text-secondary, #666);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-item dt {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.info-item dd {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Timeline styles */
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-item:last-child .marker-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.marker-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color, #3b82f6);
|
||||
border: 2px solid var(--bg-primary, #fff);
|
||||
box-shadow: 0 0 0 2px var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.marker-line {
|
||||
flex: 1;
|
||||
width: 2px;
|
||||
background: var(--border-color, #e0e0e0);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.event-body {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.turn-content {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.turn-message {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.user-turn {
|
||||
border-left: 3px solid var(--info-color, #0ea5e9);
|
||||
}
|
||||
|
||||
.assistant-turn {
|
||||
border-left: 3px solid var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.grounding-score {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--bg-tertiary, #f3f4f6);
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.grounding-score.acceptable {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.grounding-content, .evidence-pack-content, .action-content,
|
||||
.approval-content, .attestation-content, .generic-content {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pack-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.pack-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pack-stats {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.action-type {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--bg-tertiary, #f3f4f6);
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.action-description {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.action-target {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.requires-approval {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.approval-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.approval-content.approved .approval-decision {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.approval-content.denied .approval-decision {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.approval-decision {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.approval-by {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.approval-reason {
|
||||
width: 100%;
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.attestation-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.attestation-type, .attestation-id {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--bg-tertiary, #f3f4f6);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.attestation-id {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.signed-indicator {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Artifacts */
|
||||
.artifacts-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.artifact-card {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.artifact-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.artifact-type {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.artifact-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.artifact-name {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.artifact-uri {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.artifact-footer {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.artifact-digest {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-tertiary, #999);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.approval-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.approve-btn, .reject-btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.approve-btn {
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.approve-btn:hover:not(:disabled) {
|
||||
background: #15803d;
|
||||
}
|
||||
|
||||
.reject-btn {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.reject-btn:hover:not(:disabled) {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.approve-btn:disabled, .reject-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.run-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class AiRunViewerComponent implements OnInit, OnChanges {
|
||||
private readonly api = inject(AI_RUNS_API);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@Input() runId?: string;
|
||||
@Input() initialRun?: AiRun;
|
||||
|
||||
@Output() navigateToEvidencePack = new EventEmitter<string>();
|
||||
@Output() approved = new EventEmitter<string>();
|
||||
@Output() rejected = new EventEmitter<string>();
|
||||
|
||||
readonly run = signal<AiRun | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly processing = signal(false);
|
||||
|
||||
readonly canApprove = computed(() => {
|
||||
const r = this.run();
|
||||
return r !== null && r.status === 'pending_approval';
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
// Read runId from route params if not provided via Input
|
||||
this.route.paramMap.subscribe((params) => {
|
||||
const routeRunId = params.get('runId');
|
||||
if (routeRunId && !this.runId) {
|
||||
this.runId = routeRunId;
|
||||
this.loadRun();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['runId'] && this.runId) {
|
||||
this.loadRun();
|
||||
} else if (changes['initialRun'] && this.initialRun) {
|
||||
this.run.set(this.initialRun);
|
||||
}
|
||||
}
|
||||
|
||||
loadRun(): void {
|
||||
if (!this.runId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.api.get(this.runId).subscribe({
|
||||
next: (run) => {
|
||||
this.run.set(run);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to load AI run');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onApprove(): void {
|
||||
const r = this.run();
|
||||
if (!r) return;
|
||||
|
||||
this.processing.set(true);
|
||||
this.api.submitApproval(r.runId, { decision: 'approved' }).subscribe({
|
||||
next: (updated) => {
|
||||
this.run.set(updated);
|
||||
this.processing.set(false);
|
||||
this.approved.emit(r.runId);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to approve run:', err);
|
||||
this.processing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onReject(): void {
|
||||
const r = this.run();
|
||||
if (!r) return;
|
||||
|
||||
this.processing.set(true);
|
||||
this.api.submitApproval(r.runId, { decision: 'denied', reason: 'Rejected by user' }).subscribe({
|
||||
next: (updated) => {
|
||||
this.run.set(updated);
|
||||
this.processing.set(false);
|
||||
this.rejected.emit(r.runId);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to reject run:', err);
|
||||
this.processing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onNavigateToEvidencePack(packId: string): void {
|
||||
this.navigateToEvidencePack.emit(packId);
|
||||
this.router.navigate(['/evidence-packs', packId]);
|
||||
}
|
||||
|
||||
formatEventType(type: string): string {
|
||||
return type.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
// Type guard helpers for template
|
||||
asUserTurn(content: RunEventContent): UserTurnContent {
|
||||
return content as UserTurnContent;
|
||||
}
|
||||
|
||||
asAssistantTurn(content: RunEventContent): AssistantTurnContent {
|
||||
return content as AssistantTurnContent;
|
||||
}
|
||||
|
||||
asGrounding(content: RunEventContent): GroundingContent {
|
||||
return content as GroundingContent;
|
||||
}
|
||||
|
||||
asEvidencePack(content: RunEventContent): EvidencePackContent {
|
||||
return content as EvidencePackContent;
|
||||
}
|
||||
|
||||
asAction(content: RunEventContent): ActionContent {
|
||||
return content as ActionContent;
|
||||
}
|
||||
|
||||
asApproval(content: RunEventContent): ApprovalContent {
|
||||
return content as ApprovalContent;
|
||||
}
|
||||
|
||||
asAttestation(content: RunEventContent): AttestationContent {
|
||||
return content as AttestationContent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* AI Runs List Component
|
||||
* Sprint: SPRINT_20260109_011_003 Task: Frontend Unblock
|
||||
*
|
||||
* Displays a list of AI runs with filtering by status and pagination.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
inject,
|
||||
signal,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
AiRunSummary,
|
||||
AiRunStatus,
|
||||
AiRunQuery,
|
||||
} from '../../core/api/ai-runs.models';
|
||||
import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
|
||||
@Component({
|
||||
selector: 'stellaops-ai-runs-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="ai-runs-list">
|
||||
<!-- Header with filters -->
|
||||
<header class="list-header">
|
||||
<h2 class="list-title">AI Runs</h2>
|
||||
<div class="filters">
|
||||
<select
|
||||
class="filter-select"
|
||||
[(ngModel)]="filterStatus"
|
||||
(ngModelChange)="onFilterChange()">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="created">Created</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending_approval">Pending Approval</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="complete">Complete</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
class="filter-input"
|
||||
placeholder="Filter by user..."
|
||||
[(ngModel)]="filterUserId"
|
||||
(ngModelChange)="onFilterChange()"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- List content -->
|
||||
<div class="list-content">
|
||||
@if (loading()) {
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading AI runs...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="error-state">
|
||||
<p>{{ error() }}</p>
|
||||
<button type="button" class="retry-btn" (click)="loadRuns()">Retry</button>
|
||||
</div>
|
||||
} @else if (runs().length === 0) {
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="empty-icon">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
<p>No AI runs found</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="runs-table-container">
|
||||
<table class="runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run ID</th>
|
||||
<th>Status</th>
|
||||
<th>User</th>
|
||||
<th>Events</th>
|
||||
<th>Artifacts</th>
|
||||
<th>Attested</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (run of runs(); track run.runId) {
|
||||
<tr
|
||||
class="run-row"
|
||||
[class.selected]="selectedRunId === run.runId"
|
||||
(click)="onSelect(run)">
|
||||
<td class="run-id-cell">
|
||||
<code>{{ run.runId.substring(0, 12) }}...</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" [class]="'status-' + run.status">
|
||||
{{ run.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="user-cell">{{ run.userId }}</td>
|
||||
<td class="count-cell">{{ run.eventCount }}</td>
|
||||
<td class="count-cell">{{ run.artifactCount }}</td>
|
||||
<td class="attested-cell">
|
||||
@if (run.hasAttestation) {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="check-icon">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<span class="no-attestation">-</span>
|
||||
}
|
||||
</td>
|
||||
<td class="date-cell">{{ run.createdAt | date:'shortDate' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
@if (totalCount() > pageSize) {
|
||||
<div class="pagination">
|
||||
<button
|
||||
type="button"
|
||||
class="page-btn"
|
||||
[disabled]="currentPage() === 0"
|
||||
(click)="goToPage(currentPage() - 1)">
|
||||
Previous
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Page {{ currentPage() + 1 }} of {{ totalPages() }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="page-btn"
|
||||
[disabled]="currentPage() >= totalPages() - 1"
|
||||
(click)="goToPage(currentPage() + 1)">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.ai-runs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-select, .filter-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.filter-select:focus, .filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.list-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.loading-state, .error-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-color, #e0e0e0);
|
||||
border-top-color: var(--primary-color, #3b82f6);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--text-tertiary, #999);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runs-table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.runs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.runs-table th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary, #666);
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.runs-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.run-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.run-row:hover {
|
||||
background: var(--bg-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
.run-row.selected {
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.run-id-cell code {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-created { background: #e0e7ff; color: #3730a3; }
|
||||
.status-active { background: #dbeafe; color: #1e40af; }
|
||||
.status-pending_approval { background: #fef3c7; color: #92400e; }
|
||||
.status-approved { background: #dcfce7; color: #166534; }
|
||||
.status-rejected { background: #fee2e2; color: #991b1b; }
|
||||
.status-complete { background: #d1fae5; color: #065f46; }
|
||||
.status-cancelled { background: #f3f4f6; color: #4b5563; }
|
||||
|
||||
.user-cell {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.count-cell {
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.attested-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.no-attestation {
|
||||
color: var(--text-tertiary, #999);
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
color: var(--text-secondary, #666);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-btn:not(:disabled):hover {
|
||||
background: var(--bg-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class AiRunsListComponent implements OnInit {
|
||||
private readonly api = inject(AI_RUNS_API);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@Input() selectedRunId?: string;
|
||||
@Input() pageSize = 20;
|
||||
|
||||
@Output() runSelected = new EventEmitter<AiRunSummary>();
|
||||
|
||||
readonly runs = signal<AiRunSummary[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly totalCount = signal(0);
|
||||
readonly currentPage = signal(0);
|
||||
readonly totalPages = signal(0);
|
||||
|
||||
filterStatus = '';
|
||||
filterUserId = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRuns();
|
||||
}
|
||||
|
||||
loadRuns(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const query: AiRunQuery = {
|
||||
limit: this.pageSize,
|
||||
offset: this.currentPage() * this.pageSize,
|
||||
};
|
||||
|
||||
if (this.filterStatus) {
|
||||
query.status = this.filterStatus as AiRunStatus;
|
||||
}
|
||||
if (this.filterUserId) {
|
||||
query.userId = this.filterUserId;
|
||||
}
|
||||
|
||||
this.api.list(query).subscribe({
|
||||
next: (response) => {
|
||||
this.runs.set(response.runs);
|
||||
this.totalCount.set(response.count);
|
||||
this.totalPages.set(Math.ceil(response.count / this.pageSize));
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to load AI runs');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.currentPage.set(0);
|
||||
this.loadRuns();
|
||||
}
|
||||
|
||||
goToPage(page: number): void {
|
||||
this.currentPage.set(page);
|
||||
this.loadRuns();
|
||||
}
|
||||
|
||||
onSelect(run: AiRunSummary): void {
|
||||
this.runSelected.emit(run);
|
||||
this.router.navigate(['/ai-runs', run.runId]);
|
||||
}
|
||||
}
|
||||
7
src/Web/StellaOps.Web/src/app/features/ai-runs/index.ts
Normal file
7
src/Web/StellaOps.Web/src/app/features/ai-runs/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* AI Runs Feature Module
|
||||
* Sprint: SPRINT_20260109_011_003 Task: Frontend Unblock
|
||||
*/
|
||||
|
||||
export * from './ai-run-viewer.component';
|
||||
export * from './ai-runs-list.component';
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Evidence Pack List Component
|
||||
* Sprint: SPRINT_20260109_011_005 Task: EVPK-008 (Frontend Unblock)
|
||||
*
|
||||
* Displays a list of evidence packs with filtering and pagination.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
inject,
|
||||
signal,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
EvidencePackSummary,
|
||||
EvidencePackQuery,
|
||||
} from '../../core/api/evidence-pack.models';
|
||||
import { EVIDENCE_PACK_API } from '../../core/api/evidence-pack.client';
|
||||
|
||||
@Component({
|
||||
selector: 'stellaops-evidence-pack-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="evidence-pack-list">
|
||||
<!-- Header with filters -->
|
||||
<header class="list-header">
|
||||
<h2 class="list-title">Evidence Packs</h2>
|
||||
<div class="filters">
|
||||
<input
|
||||
type="text"
|
||||
class="filter-input"
|
||||
placeholder="Filter by CVE ID..."
|
||||
[(ngModel)]="filterCveId"
|
||||
(ngModelChange)="onFilterChange()"
|
||||
/>
|
||||
@if (runId) {
|
||||
<span class="run-filter">Run: {{ runId }}</span>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- List content -->
|
||||
<div class="list-content">
|
||||
@if (loading()) {
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading evidence packs...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="error-state">
|
||||
<p>{{ error() }}</p>
|
||||
<button type="button" class="retry-btn" (click)="loadPacks()">Retry</button>
|
||||
</div>
|
||||
} @else if (packs().length === 0) {
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="empty-icon">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
<p>No evidence packs found</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="pack-grid">
|
||||
@for (pack of packs(); track pack.packId) {
|
||||
<button
|
||||
type="button"
|
||||
class="pack-card"
|
||||
[class.selected]="selectedPackId === pack.packId"
|
||||
(click)="onSelect(pack)">
|
||||
<div class="pack-card-header">
|
||||
<span class="pack-subject-type">{{ pack.subjectType }}</span>
|
||||
<span class="pack-date">{{ pack.createdAt | date:'shortDate' }}</span>
|
||||
</div>
|
||||
<div class="pack-card-body">
|
||||
@if (pack.cveId) {
|
||||
<span class="pack-cve">{{ pack.cveId }}</span>
|
||||
} @else {
|
||||
<span class="pack-id">{{ pack.packId.substring(0, 16) }}...</span>
|
||||
}
|
||||
</div>
|
||||
<div class="pack-card-footer">
|
||||
<span class="pack-stat">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
{{ pack.claimCount }} claims
|
||||
</span>
|
||||
<span class="pack-stat">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 16v-4"/>
|
||||
<path d="M12 8h.01"/>
|
||||
</svg>
|
||||
{{ pack.evidenceCount }} evidence
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
@if (totalCount() > pageSize) {
|
||||
<div class="pagination">
|
||||
<button
|
||||
type="button"
|
||||
class="page-btn"
|
||||
[disabled]="currentPage() === 0"
|
||||
(click)="goToPage(currentPage() - 1)">
|
||||
Previous
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Page {{ currentPage() + 1 }} of {{ totalPages() }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="page-btn"
|
||||
[disabled]="currentPage() >= totalPages() - 1"
|
||||
(click)="goToPage(currentPage() + 1)">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-pack-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.run-filter {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.list-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.loading-state, .error-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-color, #e0e0e0);
|
||||
border-top-color: var(--primary-color, #3b82f6);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--text-tertiary, #999);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pack-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pack-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pack-card:hover {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.pack-card.selected {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.pack-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pack-subject-type {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.pack-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.pack-card-body {
|
||||
flex: 1;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.pack-cve {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.pack-id {
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.pack-card-footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.pack-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.pack-stat svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-btn:not(:disabled):hover {
|
||||
background: var(--bg-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class EvidencePackListComponent implements OnInit {
|
||||
private readonly api = inject(EVIDENCE_PACK_API);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@Input() runId?: string;
|
||||
@Input() selectedPackId?: string;
|
||||
@Input() pageSize = 20;
|
||||
|
||||
@Output() packSelected = new EventEmitter<EvidencePackSummary>();
|
||||
|
||||
readonly packs = signal<EvidencePackSummary[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly totalCount = signal(0);
|
||||
readonly currentPage = signal(0);
|
||||
|
||||
readonly totalPages = signal(0);
|
||||
|
||||
filterCveId = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadPacks();
|
||||
}
|
||||
|
||||
loadPacks(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const query: EvidencePackQuery = {
|
||||
limit: this.pageSize,
|
||||
offset: this.currentPage() * this.pageSize,
|
||||
};
|
||||
|
||||
if (this.filterCveId) {
|
||||
query.cveId = this.filterCveId;
|
||||
}
|
||||
|
||||
const request$ = this.runId
|
||||
? this.api.listByRun(this.runId)
|
||||
: this.api.list(query);
|
||||
|
||||
request$.subscribe({
|
||||
next: (response) => {
|
||||
this.packs.set(response.packs);
|
||||
this.totalCount.set(response.count);
|
||||
this.totalPages.set(Math.ceil(response.count / this.pageSize));
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to load evidence packs');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.currentPage.set(0);
|
||||
this.loadPacks();
|
||||
}
|
||||
|
||||
goToPage(page: number): void {
|
||||
this.currentPage.set(page);
|
||||
this.loadPacks();
|
||||
}
|
||||
|
||||
onSelect(pack: EvidencePackSummary): void {
|
||||
this.packSelected.emit(pack);
|
||||
this.router.navigate(['/evidence-packs', pack.packId]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
/**
|
||||
* Evidence Pack Viewer Component
|
||||
* Sprint: SPRINT_20260109_011_005 Task: EVPK-008 (Frontend Unblock)
|
||||
*
|
||||
* Displays Evidence Pack details including subject, claims, evidence items,
|
||||
* and DSSE signature verification status.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
SimpleChanges,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
EvidencePack,
|
||||
EvidenceClaim,
|
||||
EvidenceItem,
|
||||
SignedEvidencePack,
|
||||
EvidencePackVerificationResult,
|
||||
EvidencePackExportFormat,
|
||||
} from '../../core/api/evidence-pack.models';
|
||||
import { EVIDENCE_PACK_API } from '../../core/api/evidence-pack.client';
|
||||
|
||||
@Component({
|
||||
selector: 'stellaops-evidence-pack-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="evidence-pack-viewer" [class.loading]="loading()">
|
||||
@if (loading()) {
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading evidence pack...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="error-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="error-icon">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<p>{{ error() }}</p>
|
||||
<button type="button" class="retry-btn" (click)="loadPack()">Retry</button>
|
||||
</div>
|
||||
} @else if (pack()) {
|
||||
<!-- Header -->
|
||||
<header class="pack-header">
|
||||
<div class="header-left">
|
||||
<h2 class="pack-title">Evidence Pack</h2>
|
||||
<span class="pack-id">{{ pack()!.packId }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@if (isSigned()) {
|
||||
<span class="signed-badge verified">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
<polyline points="9 12 12 15 15 9"/>
|
||||
</svg>
|
||||
Signed
|
||||
</span>
|
||||
} @else {
|
||||
<span class="signed-badge unsigned">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Unsigned
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="action-btn" (click)="onSign()" [disabled]="isSigned() || signing()">
|
||||
{{ signing() ? 'Signing...' : 'Sign Pack' }}
|
||||
</button>
|
||||
<button type="button" class="action-btn" (click)="onVerify()" [disabled]="verifying()">
|
||||
{{ verifying() ? 'Verifying...' : 'Verify' }}
|
||||
</button>
|
||||
<div class="export-dropdown">
|
||||
<button type="button" class="action-btn export-btn" (click)="toggleExportMenu()">
|
||||
Export
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
@if (showExportMenu()) {
|
||||
<div class="export-menu">
|
||||
<button type="button" (click)="onExport('Json')">JSON</button>
|
||||
<button type="button" (click)="onExport('SignedJson')">Signed JSON</button>
|
||||
<button type="button" (click)="onExport('Markdown')">Markdown</button>
|
||||
<button type="button" (click)="onExport('Html')">HTML</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Subject Section -->
|
||||
<section class="pack-section subject-section">
|
||||
<h3 class="section-title">Subject</h3>
|
||||
<div class="subject-details">
|
||||
<dl class="details-grid">
|
||||
<div class="detail-item">
|
||||
<dt>Type</dt>
|
||||
<dd>{{ pack()!.subject.type }}</dd>
|
||||
</div>
|
||||
@if (pack()!.subject.cveId) {
|
||||
<div class="detail-item">
|
||||
<dt>CVE ID</dt>
|
||||
<dd class="cve-id">{{ pack()!.subject.cveId }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (pack()!.subject.findingId) {
|
||||
<div class="detail-item">
|
||||
<dt>Finding ID</dt>
|
||||
<dd>{{ pack()!.subject.findingId }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (pack()!.subject.component) {
|
||||
<div class="detail-item">
|
||||
<dt>Component</dt>
|
||||
<dd class="monospace">{{ pack()!.subject.component }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Claims Section -->
|
||||
<section class="pack-section claims-section">
|
||||
<h3 class="section-title">
|
||||
Claims
|
||||
<span class="count-badge">{{ pack()!.claims.length }}</span>
|
||||
</h3>
|
||||
<div class="claims-list">
|
||||
@for (claim of pack()!.claims; track claim.claimId) {
|
||||
<div class="claim-card" [class]="'claim-' + claim.status">
|
||||
<div class="claim-header">
|
||||
<span class="claim-type">{{ claim.type }}</span>
|
||||
<span class="claim-confidence" [title]="'Confidence: ' + (claim.confidence * 100).toFixed(0) + '%'">
|
||||
{{ (claim.confidence * 100).toFixed(0) }}%
|
||||
</span>
|
||||
</div>
|
||||
<p class="claim-text">{{ claim.text }}</p>
|
||||
<div class="claim-meta">
|
||||
<span class="claim-status">{{ claim.status }}</span>
|
||||
@if (claim.source) {
|
||||
<span class="claim-source">Source: {{ claim.source }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (claim.evidenceIds.length > 0) {
|
||||
<div class="claim-evidence">
|
||||
<span class="evidence-label">Evidence:</span>
|
||||
@for (evId of claim.evidenceIds; track evId) {
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-link"
|
||||
(click)="scrollToEvidence(evId)">
|
||||
{{ evId }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Evidence Section -->
|
||||
<section class="pack-section evidence-section">
|
||||
<h3 class="section-title">
|
||||
Evidence Items
|
||||
<span class="count-badge">{{ pack()!.evidence.length }}</span>
|
||||
</h3>
|
||||
<div class="evidence-list">
|
||||
@for (ev of pack()!.evidence; track ev.evidenceId) {
|
||||
<div class="evidence-card" [id]="'ev-' + ev.evidenceId">
|
||||
<div class="evidence-header">
|
||||
<span class="evidence-type">{{ ev.type }}</span>
|
||||
<span class="evidence-id">{{ ev.evidenceId }}</span>
|
||||
</div>
|
||||
<div class="evidence-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">URI:</span>
|
||||
<code class="detail-value uri">{{ ev.uri }}</code>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Digest:</span>
|
||||
<code class="detail-value digest">{{ ev.digest }}</code>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Collected:</span>
|
||||
<span class="detail-value">{{ ev.collectedAt | date:'medium' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (ev.snapshot) {
|
||||
<details class="snapshot-details">
|
||||
<summary>Snapshot Data</summary>
|
||||
<pre class="snapshot-json">{{ ev.snapshot | json }}</pre>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Verification Results -->
|
||||
@if (verificationResult()) {
|
||||
<section class="pack-section verification-section">
|
||||
<h3 class="section-title">
|
||||
Verification Result
|
||||
@if (verificationResult()!.valid) {
|
||||
<span class="result-badge valid">Valid</span>
|
||||
} @else {
|
||||
<span class="result-badge invalid">Invalid</span>
|
||||
}
|
||||
</h3>
|
||||
<div class="verification-details">
|
||||
@if (verificationResult()!.packDigest) {
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Pack Digest:</span>
|
||||
<code class="detail-value">{{ verificationResult()!.packDigest }}</code>
|
||||
</div>
|
||||
}
|
||||
@if (verificationResult()!.signatureKeyId) {
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Signing Key:</span>
|
||||
<code class="detail-value">{{ verificationResult()!.signatureKeyId }}</code>
|
||||
</div>
|
||||
}
|
||||
@if (verificationResult()!.issues.length > 0) {
|
||||
<div class="issues-list">
|
||||
<h4>Issues:</h4>
|
||||
<ul>
|
||||
@for (issue of verificationResult()!.issues; track issue) {
|
||||
<li class="issue-item">{{ issue }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Context Section -->
|
||||
@if (pack()!.context) {
|
||||
<section class="pack-section context-section">
|
||||
<h3 class="section-title">Context</h3>
|
||||
<dl class="details-grid">
|
||||
@if (pack()!.context!.runId) {
|
||||
<div class="detail-item">
|
||||
<dt>Run ID</dt>
|
||||
<dd>
|
||||
<button type="button" class="link-btn" (click)="onNavigateToRun(pack()!.context!.runId!)">
|
||||
{{ pack()!.context!.runId }}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
@if (pack()!.context!.conversationId) {
|
||||
<div class="detail-item">
|
||||
<dt>Conversation</dt>
|
||||
<dd>{{ pack()!.context!.conversationId }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (pack()!.context!.userId) {
|
||||
<div class="detail-item">
|
||||
<dt>User</dt>
|
||||
<dd>{{ pack()!.context!.userId }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (pack()!.context!.generatedBy) {
|
||||
<div class="detail-item">
|
||||
<dt>Generated By</dt>
|
||||
<dd>{{ pack()!.context!.generatedBy }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Metadata -->
|
||||
<footer class="pack-footer">
|
||||
<span class="footer-item">Created: {{ pack()!.createdAt | date:'medium' }}</span>
|
||||
<span class="footer-item">Version: {{ pack()!.version }}</span>
|
||||
@if (pack()!.contentDigest) {
|
||||
<span class="footer-item digest">{{ pack()!.contentDigest }}</span>
|
||||
}
|
||||
</footer>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<p>No evidence pack loaded</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-pack-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.loading-state, .error-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-color, #e0e0e0);
|
||||
border-top-color: var(--primary-color, #3b82f6);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--error-color, #ef4444);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.retry-btn, .action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.retry-btn:hover, .action-btn:hover {
|
||||
background: var(--bg-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pack-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.pack-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pack-id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.signed-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signed-badge svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.signed-badge.verified {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.signed-badge.unsigned {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.export-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.export-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.export-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.export-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.export-menu button:hover {
|
||||
background: var(--bg-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
.pack-section {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111);
|
||||
margin: 0 0 0.75rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.count-badge, .result-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.result-badge.valid {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.result-badge.invalid {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-item dt {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-item dd {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cve-id {
|
||||
font-weight: 600;
|
||||
color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.claims-list, .evidence-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.claim-card, .evidence-card {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.claim-header, .evidence-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.claim-type, .evidence-type {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.evidence-id {
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.claim-confidence {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.claim-text {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.claim-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.claim-status {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.claim-evidence {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.evidence-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.evidence-link {
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary, #fff);
|
||||
cursor: pointer;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.evidence-link:hover {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.evidence-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-value.uri {
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.detail-value.digest {
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.snapshot-details {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.snapshot-details summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.snapshot-json {
|
||||
margin: 0.5rem 0 0 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.verification-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.issues-list h4 {
|
||||
font-size: 0.875rem;
|
||||
margin: 0.5rem 0 0.25rem 0;
|
||||
color: var(--error-color, #ef4444);
|
||||
}
|
||||
|
||||
.issues-list ul {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.issue-item {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--error-color, #ef4444);
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pack-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.footer-item.digest {
|
||||
font-family: monospace;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class EvidencePackViewerComponent implements OnInit, OnChanges {
|
||||
private readonly api = inject(EVIDENCE_PACK_API);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@Input() packId?: string;
|
||||
@Input() initialPack?: EvidencePack;
|
||||
|
||||
@Output() navigateToRun = new EventEmitter<string>();
|
||||
@Output() exported = new EventEmitter<{ format: EvidencePackExportFormat; blob: Blob }>();
|
||||
|
||||
readonly pack = signal<EvidencePack | null>(null);
|
||||
readonly signedPack = signal<SignedEvidencePack | null>(null);
|
||||
readonly verificationResult = signal<EvidencePackVerificationResult | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly signing = signal(false);
|
||||
readonly verifying = signal(false);
|
||||
readonly showExportMenu = signal(false);
|
||||
|
||||
readonly isSigned = computed(() => this.signedPack() !== null);
|
||||
|
||||
ngOnInit(): void {
|
||||
// Read packId from route params if not provided via Input
|
||||
this.route.paramMap.subscribe((params) => {
|
||||
const routePackId = params.get('packId');
|
||||
if (routePackId && !this.packId) {
|
||||
this.packId = routePackId;
|
||||
this.loadPack();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['packId'] && this.packId) {
|
||||
this.loadPack();
|
||||
} else if (changes['initialPack'] && this.initialPack) {
|
||||
this.pack.set(this.initialPack);
|
||||
}
|
||||
}
|
||||
|
||||
loadPack(): void {
|
||||
if (!this.packId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.api.get(this.packId).subscribe({
|
||||
next: (pack) => {
|
||||
this.pack.set(pack);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to load evidence pack');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onSign(): void {
|
||||
const p = this.pack();
|
||||
if (!p) return;
|
||||
|
||||
this.signing.set(true);
|
||||
this.api.sign(p.packId).subscribe({
|
||||
next: (signed) => {
|
||||
this.signedPack.set(signed);
|
||||
this.signing.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to sign pack:', err);
|
||||
this.signing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onVerify(): void {
|
||||
const p = this.pack();
|
||||
if (!p) return;
|
||||
|
||||
this.verifying.set(true);
|
||||
this.api.verify(p.packId).subscribe({
|
||||
next: (result) => {
|
||||
this.verificationResult.set(result);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to verify pack:', err);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleExportMenu(): void {
|
||||
this.showExportMenu.update((v) => !v);
|
||||
}
|
||||
|
||||
onExport(format: EvidencePackExportFormat): void {
|
||||
const p = this.pack();
|
||||
if (!p) return;
|
||||
|
||||
this.showExportMenu.set(false);
|
||||
this.api.export(p.packId, format).subscribe({
|
||||
next: (blob) => {
|
||||
this.exported.emit({ format, blob });
|
||||
this.downloadBlob(blob, `evidence-pack-${p.packId}.${this.getExtension(format)}`);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to export pack:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
scrollToEvidence(evidenceId: string): void {
|
||||
const el = document.getElementById(`ev-${evidenceId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('highlight');
|
||||
setTimeout(() => el.classList.remove('highlight'), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
onNavigateToRun(runId: string): void {
|
||||
this.navigateToRun.emit(runId);
|
||||
this.router.navigate(['/ai-runs', runId]);
|
||||
}
|
||||
|
||||
private downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private getExtension(format: EvidencePackExportFormat): string {
|
||||
switch (format) {
|
||||
case 'Json':
|
||||
case 'SignedJson':
|
||||
return 'json';
|
||||
case 'Markdown':
|
||||
return 'md';
|
||||
case 'Html':
|
||||
return 'html';
|
||||
case 'Pdf':
|
||||
return 'pdf';
|
||||
default:
|
||||
return 'json';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Evidence Pack Feature Module
|
||||
* Sprint: SPRINT_20260109_011_005 Task: EVPK-008 (Frontend Unblock)
|
||||
*/
|
||||
|
||||
export * from './evidence-pack-viewer.component';
|
||||
export * from './evidence-pack-list.component';
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file evidence-card.component.spec.ts
|
||||
* @sprint SPRINT_20260107_006_005_FE (OM-FE-005)
|
||||
* @description Unit tests for EvidenceCardComponent.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { EvidenceCardComponent } from './evidence-card.component';
|
||||
import { PlaybookEvidence } from '../../models/playbook.models';
|
||||
|
||||
describe('EvidenceCardComponent', () => {
|
||||
let component: EvidenceCardComponent;
|
||||
let fixture: ComponentFixture<EvidenceCardComponent>;
|
||||
|
||||
const mockEvidence: PlaybookEvidence = {
|
||||
memoryId: 'mem-abc123',
|
||||
cveId: 'CVE-2023-44487',
|
||||
action: 'accept_risk',
|
||||
outcome: 'success',
|
||||
resolutionTime: 'PT4H',
|
||||
similarity: 0.92,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidenceCardComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EvidenceCardComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('display', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display CVE ID', () => {
|
||||
const cve = fixture.nativeElement.querySelector('.evidence-card__cve');
|
||||
expect(cve.textContent).toBe('CVE-2023-44487');
|
||||
});
|
||||
|
||||
it('should display similarity percentage', () => {
|
||||
const similarity = fixture.nativeElement.querySelector(
|
||||
'.evidence-card__similarity'
|
||||
);
|
||||
expect(similarity.textContent).toContain('92%');
|
||||
});
|
||||
|
||||
it('should display action label', () => {
|
||||
expect(component.actionLabel()).toBe('Accept Risk');
|
||||
});
|
||||
|
||||
it('should display outcome status', () => {
|
||||
expect(component.outcomeDisplay().label).toBe('Successful');
|
||||
expect(component.outcomeDisplay().color).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolution time formatting', () => {
|
||||
it('should format hours', () => {
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
expect(component.formattedResolutionTime()).toBe('4h');
|
||||
});
|
||||
|
||||
it('should format days and hours', () => {
|
||||
const evidenceWithDays: PlaybookEvidence = {
|
||||
...mockEvidence,
|
||||
resolutionTime: 'P1DT4H',
|
||||
};
|
||||
fixture.componentRef.setInput('evidence', evidenceWithDays);
|
||||
fixture.detectChanges();
|
||||
expect(component.formattedResolutionTime()).toBe('1d 4h');
|
||||
});
|
||||
|
||||
it('should format minutes', () => {
|
||||
const evidenceWithMinutes: PlaybookEvidence = {
|
||||
...mockEvidence,
|
||||
resolutionTime: 'PT30M',
|
||||
};
|
||||
fixture.componentRef.setInput('evidence', evidenceWithMinutes);
|
||||
fixture.detectChanges();
|
||||
expect(component.formattedResolutionTime()).toBe('30m');
|
||||
});
|
||||
|
||||
it('should return Immediate for very short durations', () => {
|
||||
const evidenceImmediate: PlaybookEvidence = {
|
||||
...mockEvidence,
|
||||
resolutionTime: 'PT0S',
|
||||
};
|
||||
fixture.componentRef.setInput('evidence', evidenceImmediate);
|
||||
fixture.detectChanges();
|
||||
expect(component.formattedResolutionTime()).toBe('Immediate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcome colors', () => {
|
||||
it('should show success styling for successful outcomes', () => {
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
|
||||
const card = fixture.nativeElement.querySelector('.evidence-card');
|
||||
expect(card.classList.contains('evidence-card--success')).toBe(true);
|
||||
});
|
||||
|
||||
it('should show warning styling for partial success', () => {
|
||||
const partialEvidence: PlaybookEvidence = {
|
||||
...mockEvidence,
|
||||
outcome: 'partial_success',
|
||||
};
|
||||
fixture.componentRef.setInput('evidence', partialEvidence);
|
||||
fixture.detectChanges();
|
||||
|
||||
const card = fixture.nativeElement.querySelector('.evidence-card');
|
||||
expect(card.classList.contains('evidence-card--warning')).toBe(true);
|
||||
});
|
||||
|
||||
it('should show error styling for failed outcomes', () => {
|
||||
const failedEvidence: PlaybookEvidence = {
|
||||
...mockEvidence,
|
||||
outcome: 'failure',
|
||||
};
|
||||
fixture.componentRef.setInput('evidence', failedEvidence);
|
||||
fixture.detectChanges();
|
||||
|
||||
const card = fixture.nativeElement.querySelector('.evidence-card');
|
||||
expect(card.classList.contains('evidence-card--error')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('view details', () => {
|
||||
it('should emit viewDetails event when link clicked', () => {
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
|
||||
const detailsSpy = jest.spyOn(component.viewDetails, 'emit');
|
||||
component.onViewDetails();
|
||||
|
||||
expect(detailsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have accessible link', () => {
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
|
||||
const link = fixture.nativeElement.querySelector('.evidence-card__link');
|
||||
expect(link.getAttribute('aria-label')).toContain('mem-abc123');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @file evidence-card.component.ts
|
||||
* @sprint SPRINT_20260107_006_005_FE (OM-FE-004)
|
||||
* @description Component for displaying a single past decision evidence from OpsMemory.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
PlaybookEvidence,
|
||||
getActionLabel,
|
||||
getOutcomeDisplay,
|
||||
} from '../../models/playbook.models';
|
||||
|
||||
/**
|
||||
* Component to display individual past decision evidence.
|
||||
* Shows CVE, action taken, outcome status, resolution time, and similarity score.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stellaops-evidence-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
class="evidence-card"
|
||||
[class.evidence-card--success]="outcomeDisplay().color === 'success'"
|
||||
[class.evidence-card--warning]="outcomeDisplay().color === 'warning'"
|
||||
[class.evidence-card--error]="outcomeDisplay().color === 'error'"
|
||||
>
|
||||
<div class="evidence-card__header">
|
||||
<span class="evidence-card__cve">{{ evidence().cveId }}</span>
|
||||
<span class="evidence-card__similarity">{{ similarityPercent() }}% similar</span>
|
||||
</div>
|
||||
|
||||
<div class="evidence-card__body">
|
||||
<div class="evidence-card__row">
|
||||
<span class="evidence-card__label">Action:</span>
|
||||
<span class="evidence-card__value">{{ actionLabel() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="evidence-card__row">
|
||||
<span class="evidence-card__label">Outcome:</span>
|
||||
<span
|
||||
class="evidence-card__outcome"
|
||||
[class]="'evidence-card__outcome--' + outcomeDisplay().color"
|
||||
>
|
||||
<span class="evidence-card__outcome-icon">
|
||||
@switch (outcomeDisplay().color) {
|
||||
@case ('success') {
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<path d="M10.28 2.28L4.5 8.06 2.22 5.78a.75.75 0 00-1.06 1.06l3 3a.75.75 0 001.06 0l6.5-6.5a.75.75 0 00-1.06-1.06z"/>
|
||||
</svg>
|
||||
}
|
||||
@case ('error') {
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<path d="M3.28 2.22a.75.75 0 00-1.06 1.06L4.94 6 2.22 8.72a.75.75 0 101.06 1.06L6 7.06l2.72 2.72a.75.75 0 101.06-1.06L7.06 6l2.72-2.72a.75.75 0 00-1.06-1.06L6 4.94 3.28 2.22z"/>
|
||||
</svg>
|
||||
}
|
||||
@default {
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<circle cx="6" cy="6" r="4"/>
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
{{ outcomeDisplay().label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="evidence-card__row">
|
||||
<span class="evidence-card__label">Resolution:</span>
|
||||
<span class="evidence-card__value">{{ formattedResolutionTime() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="evidence-card__link"
|
||||
(click)="onViewDetails()"
|
||||
[attr.aria-label]="'View details for decision ' + evidence().memoryId"
|
||||
>
|
||||
View Original Decision
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<path d="M3.5 1.5a.75.75 0 00-.75.75v7.5c0 .414.336.75.75.75h5a.75.75 0 00.75-.75V6.25a.75.75 0 011.5 0v3.5A2.25 2.25 0 018.5 12h-5A2.25 2.25 0 011.25 9.75v-7.5A2.25 2.25 0 013.5 0h3.5a.75.75 0 010 1.5H3.5zm5.25-.25a.75.75 0 01.75-.75h2a.75.75 0 01.75.75v2a.75.75 0 01-1.5 0V2.56L7.28 6.03a.75.75 0 01-1.06-1.06l3.47-3.47H8.75a.75.75 0 01-.75-.75z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.evidence-card {
|
||||
background: var(--surface-secondary, #f8f9fa);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border-left: 3px solid var(--border-default, #ddd);
|
||||
}
|
||||
|
||||
.evidence-card--success {
|
||||
border-left-color: var(--semantic-success, #2e7d32);
|
||||
}
|
||||
|
||||
.evidence-card--warning {
|
||||
border-left-color: var(--semantic-warning, #f57c00);
|
||||
}
|
||||
|
||||
.evidence-card--error {
|
||||
border-left-color: var(--semantic-error, #c62828);
|
||||
}
|
||||
|
||||
.evidence-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.evidence-card__cve {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.evidence-card__similarity {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
background: var(--accent-primary, #1976d2);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.evidence-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.evidence-card__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.evidence-card__label {
|
||||
color: var(--text-secondary, #666);
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.evidence-card__value {
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.evidence-card__outcome {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.evidence-card__outcome--success {
|
||||
background: var(--semantic-success-light, #e8f5e9);
|
||||
color: var(--semantic-success, #2e7d32);
|
||||
}
|
||||
|
||||
.evidence-card__outcome--warning {
|
||||
background: var(--semantic-warning-light, #fff3e0);
|
||||
color: var(--semantic-warning, #f57c00);
|
||||
}
|
||||
|
||||
.evidence-card__outcome--error {
|
||||
background: var(--semantic-error-light, #ffebee);
|
||||
color: var(--semantic-error, #c62828);
|
||||
}
|
||||
|
||||
.evidence-card__outcome--neutral {
|
||||
background: var(--surface-secondary, #f5f5f5);
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.evidence-card__outcome-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.evidence-card__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent-primary, #1976d2);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.evidence-card__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class EvidenceCardComponent {
|
||||
/** The evidence to display */
|
||||
readonly evidence = input.required<PlaybookEvidence>();
|
||||
|
||||
/** Emits when user wants to view full details */
|
||||
readonly viewDetails = output<void>();
|
||||
|
||||
/** Computed action label */
|
||||
readonly actionLabel = computed(() => getActionLabel(this.evidence().action));
|
||||
|
||||
/** Computed outcome display */
|
||||
readonly outcomeDisplay = computed(() =>
|
||||
getOutcomeDisplay(this.evidence().outcome)
|
||||
);
|
||||
|
||||
/** Similarity as percentage */
|
||||
readonly similarityPercent = computed(() =>
|
||||
Math.round(this.evidence().similarity * 100)
|
||||
);
|
||||
|
||||
/** Formatted resolution time */
|
||||
readonly formattedResolutionTime = computed(() => {
|
||||
const duration = this.evidence().resolutionTime;
|
||||
return this.formatIsoDuration(duration);
|
||||
});
|
||||
|
||||
/**
|
||||
* Emit view details event.
|
||||
*/
|
||||
onViewDetails(): void {
|
||||
this.viewDetails.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ISO 8601 duration to human-readable string.
|
||||
* Handles formats like PT4H, PT30M, PT1H30M, P1D, etc.
|
||||
*/
|
||||
private formatIsoDuration(iso: string): string {
|
||||
if (!iso || !iso.startsWith('P')) {
|
||||
return iso || 'N/A';
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Extract days
|
||||
const dayMatch = iso.match(/(\d+)D/);
|
||||
if (dayMatch) {
|
||||
const days = parseInt(dayMatch[1], 10);
|
||||
parts.push(`${days}d`);
|
||||
}
|
||||
|
||||
// Extract hours
|
||||
const hourMatch = iso.match(/(\d+)H/);
|
||||
if (hourMatch) {
|
||||
const hours = parseInt(hourMatch[1], 10);
|
||||
parts.push(`${hours}h`);
|
||||
}
|
||||
|
||||
// Extract minutes
|
||||
const minMatch = iso.match(/(\d+)M/);
|
||||
if (minMatch && !iso.includes('M/')) {
|
||||
const mins = parseInt(minMatch[1], 10);
|
||||
parts.push(`${mins}m`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : 'Immediate';
|
||||
}
|
||||
}
|
||||
14
src/Web/StellaOps.Web/src/app/features/opsmemory/index.ts
Normal file
14
src/Web/StellaOps.Web/src/app/features/opsmemory/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @sprint SPRINT_20260107_006_005_FE
|
||||
* @description OpsMemory feature module exports.
|
||||
*/
|
||||
|
||||
// Models
|
||||
export * from './models/playbook.models';
|
||||
|
||||
// Services
|
||||
export { PlaybookSuggestionService } from './services/playbook-suggestion.service';
|
||||
|
||||
// Components
|
||||
export { EvidenceCardComponent } from './components/evidence-card/evidence-card.component';
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @file playbook.models.ts
|
||||
* @sprint SPRINT_20260107_006_005_FE (OM-FE-001)
|
||||
* @description TypeScript interfaces matching OpsMemory API responses.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A single playbook suggestion from OpsMemory.
|
||||
*/
|
||||
export interface PlaybookSuggestion {
|
||||
/** The recommended action to take */
|
||||
suggestedAction: DecisionAction;
|
||||
/** Confidence score (0-1) */
|
||||
confidence: number;
|
||||
/** Human-readable rationale for the suggestion */
|
||||
rationale: string;
|
||||
/** Number of similar past decisions */
|
||||
evidenceCount: number;
|
||||
/** Factors that contributed to the match */
|
||||
matchingFactors: string[];
|
||||
/** Evidence from past decisions */
|
||||
evidence: PlaybookEvidence[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evidence from a past decision that supports the suggestion.
|
||||
*/
|
||||
export interface PlaybookEvidence {
|
||||
/** OpsMemory record ID */
|
||||
memoryId: string;
|
||||
/** CVE ID from the past decision */
|
||||
cveId: string;
|
||||
/** Action that was taken */
|
||||
action: DecisionAction;
|
||||
/** Outcome of the decision */
|
||||
outcome: OutcomeStatus;
|
||||
/** Time to resolution (ISO 8601 duration) */
|
||||
resolutionTime: string;
|
||||
/** Similarity score to current situation */
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from the suggestions API.
|
||||
*/
|
||||
export interface PlaybookSuggestionsResponse {
|
||||
/** List of suggestions, ordered by confidence */
|
||||
suggestions: PlaybookSuggestion[];
|
||||
/** Hash of the situation for caching */
|
||||
situationHash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters for the suggestions API.
|
||||
*/
|
||||
export interface PlaybookSuggestionsQuery {
|
||||
/** Tenant ID (required) */
|
||||
tenantId: string;
|
||||
/** CVE ID to get suggestions for */
|
||||
cveId?: string;
|
||||
/** Severity level */
|
||||
severity?: 'critical' | 'high' | 'medium' | 'low';
|
||||
/** Reachability status */
|
||||
reachability?: 'reachable' | 'unreachable' | 'unknown';
|
||||
/** Component type (ecosystem) */
|
||||
componentType?: string;
|
||||
/** Context tags (comma-separated) */
|
||||
contextTags?: string;
|
||||
/** Maximum number of suggestions (default: 3) */
|
||||
maxResults?: number;
|
||||
/** Minimum confidence threshold (default: 0.5) */
|
||||
minConfidence?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision action types.
|
||||
*/
|
||||
export type DecisionAction =
|
||||
| 'accept_risk'
|
||||
| 'target_fix'
|
||||
| 'quarantine'
|
||||
| 'patch_now'
|
||||
| 'defer'
|
||||
| 'investigate';
|
||||
|
||||
/**
|
||||
* Outcome status types.
|
||||
*/
|
||||
export type OutcomeStatus =
|
||||
| 'success'
|
||||
| 'partial_success'
|
||||
| 'failure'
|
||||
| 'pending'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Maps decision action to display label.
|
||||
*/
|
||||
export function getActionLabel(action: DecisionAction): string {
|
||||
const labels: Record<DecisionAction, string> = {
|
||||
accept_risk: 'Accept Risk',
|
||||
target_fix: 'Target Fix',
|
||||
quarantine: 'Quarantine',
|
||||
patch_now: 'Patch Now',
|
||||
defer: 'Defer',
|
||||
investigate: 'Investigate',
|
||||
};
|
||||
return labels[action] ?? action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps outcome status to display label and color.
|
||||
*/
|
||||
export function getOutcomeDisplay(outcome: OutcomeStatus): {
|
||||
label: string;
|
||||
color: 'success' | 'warning' | 'error' | 'neutral';
|
||||
} {
|
||||
switch (outcome) {
|
||||
case 'success':
|
||||
return { label: 'Successful', color: 'success' };
|
||||
case 'partial_success':
|
||||
return { label: 'Partial Success', color: 'warning' };
|
||||
case 'failure':
|
||||
return { label: 'Failed', color: 'error' };
|
||||
case 'pending':
|
||||
return { label: 'Pending', color: 'neutral' };
|
||||
default:
|
||||
return { label: 'Unknown', color: 'neutral' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @file playbook-suggestion.service.spec.ts
|
||||
* @sprint SPRINT_20260107_006_005_FE (OM-FE-005)
|
||||
* @description Unit tests for PlaybookSuggestionService.
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
HttpClientTestingModule,
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing';
|
||||
import { PlaybookSuggestionService } from './playbook-suggestion.service';
|
||||
import {
|
||||
PlaybookSuggestionsResponse,
|
||||
PlaybookSuggestionsQuery,
|
||||
} from '../models/playbook.models';
|
||||
|
||||
describe('PlaybookSuggestionService', () => {
|
||||
let service: PlaybookSuggestionService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
const mockResponse: PlaybookSuggestionsResponse = {
|
||||
suggestions: [
|
||||
{
|
||||
suggestedAction: 'accept_risk',
|
||||
confidence: 0.85,
|
||||
rationale: 'Similar situations resolved successfully with risk acceptance',
|
||||
evidenceCount: 5,
|
||||
matchingFactors: ['severity', 'reachability'],
|
||||
evidence: [
|
||||
{
|
||||
memoryId: 'mem-abc123',
|
||||
cveId: 'CVE-2023-44487',
|
||||
action: 'accept_risk',
|
||||
outcome: 'success',
|
||||
resolutionTime: 'PT4H',
|
||||
similarity: 0.92,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
situationHash: 'hash123',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [PlaybookSuggestionService],
|
||||
});
|
||||
|
||||
service = TestBed.inject(PlaybookSuggestionService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('getSuggestions', () => {
|
||||
const query: PlaybookSuggestionsQuery = {
|
||||
tenantId: 'tenant-123',
|
||||
cveId: 'CVE-2023-44487',
|
||||
severity: 'high',
|
||||
reachability: 'reachable',
|
||||
};
|
||||
|
||||
it('should fetch suggestions with query parameters', (done) => {
|
||||
service.getSuggestions(query).subscribe((suggestions) => {
|
||||
expect(suggestions).toEqual(mockResponse.suggestions);
|
||||
done();
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne((r) =>
|
||||
r.url.includes('/api/v1/opsmemory/suggestions')
|
||||
);
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.params.get('tenantId')).toBe('tenant-123');
|
||||
expect(req.request.params.get('cveId')).toBe('CVE-2023-44487');
|
||||
expect(req.request.params.get('severity')).toBe('high');
|
||||
expect(req.request.params.get('reachability')).toBe('reachable');
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
|
||||
it('should cache responses', (done) => {
|
||||
// First call
|
||||
service.getSuggestions(query).subscribe(() => {
|
||||
// Second call should use cache
|
||||
service.getSuggestions(query).subscribe((suggestions) => {
|
||||
expect(suggestions).toEqual(mockResponse.suggestions);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
// Only one HTTP request should be made
|
||||
const req = httpMock.expectOne((r) =>
|
||||
r.url.includes('/api/v1/opsmemory/suggestions')
|
||||
);
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', (done) => {
|
||||
service.getSuggestions(query).subscribe({
|
||||
error: (error) => {
|
||||
expect(error.message).toBe('Failed to fetch playbook suggestions');
|
||||
done();
|
||||
},
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne((r) =>
|
||||
r.url.includes('/api/v1/opsmemory/suggestions')
|
||||
);
|
||||
req.flush('Server error', { status: 500, statusText: 'Server Error' });
|
||||
});
|
||||
|
||||
it('should handle 401 unauthorized', (done) => {
|
||||
service.getSuggestions(query).subscribe({
|
||||
error: (error) => {
|
||||
expect(error.message).toBe('Not authorized to access OpsMemory');
|
||||
done();
|
||||
},
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne((r) =>
|
||||
r.url.includes('/api/v1/opsmemory/suggestions')
|
||||
);
|
||||
req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('should include optional parameters', (done) => {
|
||||
const fullQuery: PlaybookSuggestionsQuery = {
|
||||
...query,
|
||||
componentType: 'npm',
|
||||
contextTags: 'production,payment',
|
||||
maxResults: 5,
|
||||
minConfidence: 0.7,
|
||||
};
|
||||
|
||||
service.getSuggestions(fullQuery).subscribe(() => done());
|
||||
|
||||
const req = httpMock.expectOne((r) =>
|
||||
r.url.includes('/api/v1/opsmemory/suggestions')
|
||||
);
|
||||
expect(req.request.params.get('componentType')).toBe('npm');
|
||||
expect(req.request.params.get('contextTags')).toBe('production,payment');
|
||||
expect(req.request.params.get('maxResults')).toBe('5');
|
||||
expect(req.request.params.get('minConfidence')).toBe('0.7');
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCache', () => {
|
||||
it('should clear all cached entries', (done) => {
|
||||
const query: PlaybookSuggestionsQuery = { tenantId: 'tenant-123' };
|
||||
|
||||
// First call to populate cache
|
||||
service.getSuggestions(query).subscribe(() => {
|
||||
service.clearCache();
|
||||
|
||||
// After clearing, should make new request
|
||||
service.getSuggestions(query).subscribe(() => done());
|
||||
|
||||
const req = httpMock.expectOne((r) =>
|
||||
r.url.includes('/api/v1/opsmemory/suggestions')
|
||||
);
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
|
||||
// First request
|
||||
const req1 = httpMock.expectOne((r) =>
|
||||
r.url.includes('/api/v1/opsmemory/suggestions')
|
||||
);
|
||||
req1.flush(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidate', () => {
|
||||
it('should invalidate specific cache entry', (done) => {
|
||||
const query: PlaybookSuggestionsQuery = { tenantId: 'tenant-123' };
|
||||
|
||||
// First call to populate cache
|
||||
service.getSuggestions(query).subscribe(() => {
|
||||
service.invalidate(query);
|
||||
|
||||
// After invalidating, should make new request
|
||||
service.getSuggestions(query).subscribe(() => done());
|
||||
|
||||
const req = httpMock.expectOne((r) =>
|
||||
r.url.includes('/api/v1/opsmemory/suggestions')
|
||||
);
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
|
||||
// First request
|
||||
const req1 = httpMock.expectOne((r) =>
|
||||
r.url.includes('/api/v1/opsmemory/suggestions')
|
||||
);
|
||||
req1.flush(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @file playbook-suggestion.service.ts
|
||||
* @sprint SPRINT_20260107_006_005_FE (OM-FE-001)
|
||||
* @description Angular service to fetch playbook suggestions from OpsMemory API.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams, HttpErrorResponse } from '@angular/common/http';
|
||||
import {
|
||||
Observable,
|
||||
catchError,
|
||||
map,
|
||||
retry,
|
||||
shareReplay,
|
||||
throwError,
|
||||
of,
|
||||
timer,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
PlaybookSuggestionsResponse,
|
||||
PlaybookSuggestionsQuery,
|
||||
PlaybookSuggestion,
|
||||
} from '../models/playbook.models';
|
||||
|
||||
/**
|
||||
* Cache entry for suggestions.
|
||||
*/
|
||||
interface CacheEntry {
|
||||
response: PlaybookSuggestionsResponse;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for fetching playbook suggestions from OpsMemory.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PlaybookSuggestionService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/opsmemory';
|
||||
private readonly cacheDurationMs = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly maxRetries = 2;
|
||||
private readonly retryDelayMs = 1000;
|
||||
|
||||
/** Cache keyed by situation hash */
|
||||
private readonly cache = new Map<string, CacheEntry>();
|
||||
|
||||
/**
|
||||
* Get playbook suggestions for a given situation.
|
||||
*/
|
||||
getSuggestions(
|
||||
query: PlaybookSuggestionsQuery
|
||||
): Observable<PlaybookSuggestion[]> {
|
||||
const cacheKey = this.buildCacheKey(query);
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
// Return cached if valid
|
||||
if (cached && Date.now() - cached.timestamp < this.cacheDurationMs) {
|
||||
return of(cached.response.suggestions);
|
||||
}
|
||||
|
||||
const params = this.buildParams(query);
|
||||
|
||||
return this.http
|
||||
.get<PlaybookSuggestionsResponse>(`${this.baseUrl}/suggestions`, {
|
||||
params,
|
||||
})
|
||||
.pipe(
|
||||
retry({
|
||||
count: this.maxRetries,
|
||||
delay: (error, retryCount) => {
|
||||
// Only retry on transient errors
|
||||
if (this.isTransientError(error)) {
|
||||
return timer(this.retryDelayMs * retryCount);
|
||||
}
|
||||
return throwError(() => error);
|
||||
},
|
||||
}),
|
||||
map((response) => {
|
||||
// Cache the response
|
||||
this.cache.set(cacheKey, {
|
||||
response,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return response.suggestions;
|
||||
}),
|
||||
catchError((error) => this.handleError(error)),
|
||||
shareReplay({ bufferSize: 1, refCount: true })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the suggestion cache.
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached entry for a specific query.
|
||||
*/
|
||||
invalidate(query: PlaybookSuggestionsQuery): void {
|
||||
const cacheKey = this.buildCacheKey(query);
|
||||
this.cache.delete(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTTP params from query object.
|
||||
*/
|
||||
private buildParams(query: PlaybookSuggestionsQuery): HttpParams {
|
||||
let params = new HttpParams().set('tenantId', query.tenantId);
|
||||
|
||||
if (query.cveId) {
|
||||
params = params.set('cveId', query.cveId);
|
||||
}
|
||||
if (query.severity) {
|
||||
params = params.set('severity', query.severity);
|
||||
}
|
||||
if (query.reachability) {
|
||||
params = params.set('reachability', query.reachability);
|
||||
}
|
||||
if (query.componentType) {
|
||||
params = params.set('componentType', query.componentType);
|
||||
}
|
||||
if (query.contextTags) {
|
||||
params = params.set('contextTags', query.contextTags);
|
||||
}
|
||||
if (query.maxResults !== undefined) {
|
||||
params = params.set('maxResults', query.maxResults.toString());
|
||||
}
|
||||
if (query.minConfidence !== undefined) {
|
||||
params = params.set('minConfidence', query.minConfidence.toString());
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key from query parameters.
|
||||
*/
|
||||
private buildCacheKey(query: PlaybookSuggestionsQuery): string {
|
||||
return JSON.stringify({
|
||||
tenantId: query.tenantId,
|
||||
cveId: query.cveId,
|
||||
severity: query.severity,
|
||||
reachability: query.reachability,
|
||||
componentType: query.componentType,
|
||||
contextTags: query.contextTags,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is transient and worth retrying.
|
||||
*/
|
||||
private isTransientError(error: HttpErrorResponse): boolean {
|
||||
// Retry on 5xx server errors or network errors
|
||||
return (
|
||||
error.status === 0 || // Network error
|
||||
error.status === 502 || // Bad Gateway
|
||||
error.status === 503 || // Service Unavailable
|
||||
error.status === 504 // Gateway Timeout
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP errors.
|
||||
*/
|
||||
private handleError(error: HttpErrorResponse): Observable<never> {
|
||||
let message = 'Failed to fetch playbook suggestions';
|
||||
|
||||
if (error.status === 0) {
|
||||
message = 'Unable to connect to OpsMemory service';
|
||||
} else if (error.status === 401) {
|
||||
message = 'Not authorized to access OpsMemory';
|
||||
} else if (error.status === 404) {
|
||||
message = 'OpsMemory service not found';
|
||||
} else if (error.error?.message) {
|
||||
message = error.error.message;
|
||||
}
|
||||
|
||||
console.error('PlaybookSuggestionService error:', message, error);
|
||||
return throwError(() => new Error(message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* @file cdx-evidence-panel.component.spec.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE (UI-011)
|
||||
* @description Unit tests for CdxEvidencePanelComponent.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { CdxEvidencePanelComponent } from './cdx-evidence-panel.component';
|
||||
import { ComponentEvidence, OccurrenceEvidence } from '../../models/cyclonedx-evidence.models';
|
||||
|
||||
describe('CdxEvidencePanelComponent', () => {
|
||||
let component: CdxEvidencePanelComponent;
|
||||
let fixture: ComponentFixture<CdxEvidencePanelComponent>;
|
||||
|
||||
const mockEvidence: ComponentEvidence = {
|
||||
identity: {
|
||||
field: 'purl',
|
||||
confidence: 0.95,
|
||||
methods: [
|
||||
{ technique: 'manifest-analysis', confidence: 0.95, value: 'pkg:npm/lodash@4.17.21' },
|
||||
],
|
||||
},
|
||||
occurrences: [
|
||||
{ location: '/node_modules/lodash/index.js', line: 42 },
|
||||
{ location: '/node_modules/lodash/lodash.min.js' },
|
||||
{ location: '/node_modules/lodash/package.json' },
|
||||
],
|
||||
licenses: [
|
||||
{
|
||||
license: { id: 'MIT', url: 'https://opensource.org/licenses/MIT' },
|
||||
acknowledgement: 'declared',
|
||||
},
|
||||
],
|
||||
copyright: [{ text: 'Copyright 2024 Lodash Contributors' }],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CdxEvidencePanelComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CdxEvidencePanelComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should create', () => {
|
||||
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display panel header', () => {
|
||||
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
|
||||
fixture.detectChanges();
|
||||
|
||||
const header = fixture.nativeElement.querySelector('.evidence-panel__title');
|
||||
expect(header.textContent).toBe('EVIDENCE');
|
||||
});
|
||||
|
||||
it('should show empty state when no evidence', () => {
|
||||
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
|
||||
fixture.componentRef.setInput('evidence', undefined);
|
||||
fixture.detectChanges();
|
||||
|
||||
const empty = fixture.nativeElement.querySelector('.evidence-empty');
|
||||
expect(empty).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('identity section', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display identity section', () => {
|
||||
const identitySection = fixture.nativeElement.querySelector('.identity-card');
|
||||
expect(identitySection).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display confidence badge', () => {
|
||||
const badge = fixture.nativeElement.querySelector('app-evidence-confidence-badge');
|
||||
expect(badge).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display detection methods', () => {
|
||||
const methods = fixture.nativeElement.querySelectorAll('.identity-method');
|
||||
expect(methods.length).toBe(1);
|
||||
expect(methods[0].textContent).toContain('Manifest Analysis');
|
||||
});
|
||||
|
||||
it('should be expanded by default', () => {
|
||||
expect(component.isSectionExpanded('identity')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('occurrences section', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display occurrences count in header', () => {
|
||||
// First expand the section
|
||||
component.toggleSection('occurrences');
|
||||
fixture.detectChanges();
|
||||
|
||||
const header = fixture.nativeElement.querySelector(
|
||||
'[aria-controls="occurrences-content"]'
|
||||
);
|
||||
expect(header.textContent).toContain('Occurrences (3)');
|
||||
});
|
||||
|
||||
it('should display occurrence items when expanded', () => {
|
||||
component.toggleSection('occurrences');
|
||||
fixture.detectChanges();
|
||||
|
||||
const items = fixture.nativeElement.querySelectorAll('.occurrence-item');
|
||||
expect(items.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should display line number when available', () => {
|
||||
component.toggleSection('occurrences');
|
||||
fixture.detectChanges();
|
||||
|
||||
const lineNumbers = fixture.nativeElement.querySelectorAll('.occurrence-item__line');
|
||||
expect(lineNumbers.length).toBe(1);
|
||||
expect(lineNumbers[0].textContent).toBe(':42');
|
||||
});
|
||||
|
||||
it('should emit viewOccurrence when View button clicked', () => {
|
||||
component.toggleSection('occurrences');
|
||||
fixture.detectChanges();
|
||||
|
||||
const emitSpy = jest.spyOn(component.viewOccurrence, 'emit');
|
||||
|
||||
const viewBtn = fixture.nativeElement.querySelector('.occurrence-item__view-btn');
|
||||
viewBtn.click();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(mockEvidence.occurrences![0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('licenses section', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display license items when expanded', () => {
|
||||
component.toggleSection('licenses');
|
||||
fixture.detectChanges();
|
||||
|
||||
const items = fixture.nativeElement.querySelectorAll('.license-item');
|
||||
expect(items.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should display license ID', () => {
|
||||
component.toggleSection('licenses');
|
||||
fixture.detectChanges();
|
||||
|
||||
const licenseId = fixture.nativeElement.querySelector('.license-item__id');
|
||||
expect(licenseId.textContent).toBe('MIT');
|
||||
});
|
||||
|
||||
it('should display acknowledgement', () => {
|
||||
component.toggleSection('licenses');
|
||||
fixture.detectChanges();
|
||||
|
||||
const ack = fixture.nativeElement.querySelector('.license-item__ack');
|
||||
expect(ack.textContent).toContain('declared');
|
||||
});
|
||||
|
||||
it('should display external link when URL available', () => {
|
||||
component.toggleSection('licenses');
|
||||
fixture.detectChanges();
|
||||
|
||||
const link = fixture.nativeElement.querySelector('.license-item__link');
|
||||
expect(link).toBeTruthy();
|
||||
expect(link.getAttribute('href')).toBe('https://opensource.org/licenses/MIT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyright section', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display copyright items when expanded', () => {
|
||||
component.toggleSection('copyright');
|
||||
fixture.detectChanges();
|
||||
|
||||
const items = fixture.nativeElement.querySelectorAll('.copyright-item');
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0].textContent).toContain('Copyright 2024 Lodash Contributors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('section toggling', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should toggle section expansion', () => {
|
||||
expect(component.isSectionExpanded('occurrences')).toBe(false);
|
||||
|
||||
component.toggleSection('occurrences');
|
||||
expect(component.isSectionExpanded('occurrences')).toBe(true);
|
||||
|
||||
component.toggleSection('occurrences');
|
||||
expect(component.isSectionExpanded('occurrences')).toBe(false);
|
||||
});
|
||||
|
||||
it('should expand all sections with toggleAll', () => {
|
||||
// Collapse identity first
|
||||
component.toggleSection('identity');
|
||||
fixture.detectChanges();
|
||||
|
||||
// Toggle all
|
||||
component.toggleAll();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isSectionExpanded('identity')).toBe(true);
|
||||
expect(component.isSectionExpanded('occurrences')).toBe(true);
|
||||
expect(component.isSectionExpanded('licenses')).toBe(true);
|
||||
expect(component.isSectionExpanded('copyright')).toBe(true);
|
||||
});
|
||||
|
||||
it('should collapse all sections with toggleAll when all expanded', () => {
|
||||
// Expand all first
|
||||
component.toggleSection('occurrences');
|
||||
component.toggleSection('licenses');
|
||||
component.toggleSection('copyright');
|
||||
fixture.detectChanges();
|
||||
|
||||
// Toggle all (should collapse)
|
||||
component.toggleAll();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isSectionExpanded('identity')).toBe(false);
|
||||
expect(component.isSectionExpanded('occurrences')).toBe(false);
|
||||
expect(component.isSectionExpanded('licenses')).toBe(false);
|
||||
expect(component.isSectionExpanded('copyright')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('purl', 'pkg:npm/lodash@4.17.21');
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should have aria-label on panel', () => {
|
||||
const panel = fixture.nativeElement.querySelector('.evidence-panel');
|
||||
expect(panel.getAttribute('aria-label')).toContain('Evidence for');
|
||||
});
|
||||
|
||||
it('should have aria-expanded on section headers', () => {
|
||||
const header = fixture.nativeElement.querySelector('[aria-controls="identity-content"]');
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('should have aria-label on occurrence view buttons', () => {
|
||||
component.toggleSection('occurrences');
|
||||
fixture.detectChanges();
|
||||
|
||||
const viewBtn = fixture.nativeElement.querySelector('.occurrence-item__view-btn');
|
||||
expect(viewBtn.getAttribute('aria-label')).toContain('View');
|
||||
});
|
||||
});
|
||||
|
||||
describe('technique labels', () => {
|
||||
it('should return correct label for manifest-analysis', () => {
|
||||
expect(component.getTechniqueLabel('manifest-analysis')).toBe('Manifest Analysis');
|
||||
});
|
||||
|
||||
it('should return correct label for binary-analysis', () => {
|
||||
expect(component.getTechniqueLabel('binary-analysis')).toBe('Binary Analysis');
|
||||
});
|
||||
|
||||
it('should return Other for unknown technique', () => {
|
||||
expect(component.getTechniqueLabel('unknown-technique' as any)).toBe('Other');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* @file cdx-evidence-panel.component.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE (UI-001)
|
||||
* @description Panel component for displaying CycloneDX 1.7 evidence data.
|
||||
* Shows identity evidence, occurrences, licenses, and copyright information.
|
||||
*/
|
||||
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ComponentEvidence,
|
||||
IdentityEvidence,
|
||||
OccurrenceEvidence,
|
||||
LicenseEvidence,
|
||||
CopyrightEvidence,
|
||||
getIdentityTechniqueLabel,
|
||||
} from '../../models/cyclonedx-evidence.models';
|
||||
import { EvidenceConfidenceBadgeComponent } from '../evidence-confidence-badge/evidence-confidence-badge.component';
|
||||
|
||||
type SectionId = 'identity' | 'occurrences' | 'licenses' | 'copyright';
|
||||
|
||||
/**
|
||||
* Panel component for displaying CycloneDX 1.7 component evidence.
|
||||
*
|
||||
* Features:
|
||||
* - Identity evidence with confidence badge and detection methods
|
||||
* - Occurrence list with file paths and line numbers
|
||||
* - License evidence with acknowledgement status
|
||||
* - Copyright evidence list
|
||||
* - Collapsible sections
|
||||
* - Full accessibility support (ARIA labels, keyboard navigation)
|
||||
*
|
||||
* @example
|
||||
* <app-cdx-evidence-panel
|
||||
* [purl]="component.purl"
|
||||
* [evidence]="component.evidence"
|
||||
* (viewOccurrence)="onViewOccurrence($event)"
|
||||
* />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-cdx-evidence-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EvidenceConfidenceBadgeComponent],
|
||||
template: `
|
||||
<section class="evidence-panel" [attr.aria-label]="'Evidence for ' + purl()">
|
||||
<header class="evidence-panel__header">
|
||||
<h3 class="evidence-panel__title">EVIDENCE</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-panel__expand-btn"
|
||||
[attr.aria-expanded]="allExpanded()"
|
||||
(click)="toggleAll()"
|
||||
>
|
||||
{{ allExpanded() ? 'Collapse All' : 'Expand All' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (evidence(); as ev) {
|
||||
<!-- Identity Section -->
|
||||
@if (ev.identity) {
|
||||
<section class="evidence-section" [attr.aria-expanded]="isSectionExpanded('identity')">
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-section__header"
|
||||
[attr.aria-controls]="'identity-content'"
|
||||
[attr.aria-expanded]="isSectionExpanded('identity')"
|
||||
(click)="toggleSection('identity')"
|
||||
>
|
||||
<span class="evidence-section__title">Identity</span>
|
||||
<span class="evidence-section__icon" [class.rotated]="isSectionExpanded('identity')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4.5 6L8 9.5L11.5 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@if (isSectionExpanded('identity')) {
|
||||
<div id="identity-content" class="evidence-section__content">
|
||||
<div class="identity-card">
|
||||
<div class="identity-card__row">
|
||||
<span class="identity-card__label">{{ ev.identity.field | uppercase }}:</span>
|
||||
<code class="identity-card__value">{{ identityValue() }}</code>
|
||||
<app-evidence-confidence-badge
|
||||
[confidence]="ev.identity.confidence"
|
||||
[showPercentage]="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (ev.identity.methods && ev.identity.methods.length > 0) {
|
||||
<div class="identity-card__methods">
|
||||
<span class="identity-card__methods-label">Methods:</span>
|
||||
@for (method of ev.identity.methods; track method.technique) {
|
||||
<span class="identity-method">
|
||||
{{ getTechniqueLabel(method.technique) }}
|
||||
@if (method.confidence !== undefined) {
|
||||
<span class="identity-method__conf">
|
||||
({{ (method.confidence * 100) | number:'1.0-0' }}%)
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Occurrences Section -->
|
||||
@if (ev.occurrences && ev.occurrences.length > 0) {
|
||||
<section class="evidence-section" [attr.aria-expanded]="isSectionExpanded('occurrences')">
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-section__header"
|
||||
[attr.aria-controls]="'occurrences-content'"
|
||||
[attr.aria-expanded]="isSectionExpanded('occurrences')"
|
||||
(click)="toggleSection('occurrences')"
|
||||
>
|
||||
<span class="evidence-section__title">
|
||||
Occurrences ({{ ev.occurrences.length }})
|
||||
</span>
|
||||
<span class="evidence-section__icon" [class.rotated]="isSectionExpanded('occurrences')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4.5 6L8 9.5L11.5 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@if (isSectionExpanded('occurrences')) {
|
||||
<div id="occurrences-content" class="evidence-section__content">
|
||||
<ul class="occurrence-list" role="list">
|
||||
@for (occurrence of ev.occurrences; track occurrence.location; let i = $index) {
|
||||
<li class="occurrence-item">
|
||||
<code class="occurrence-item__path">{{ occurrence.location }}</code>
|
||||
@if (occurrence.line) {
|
||||
<span class="occurrence-item__line">:{{ occurrence.line }}</span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="occurrence-item__view-btn"
|
||||
(click)="onViewOccurrence(occurrence)"
|
||||
[attr.aria-label]="'View ' + occurrence.location"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Licenses Section -->
|
||||
@if (ev.licenses && ev.licenses.length > 0) {
|
||||
<section class="evidence-section" [attr.aria-expanded]="isSectionExpanded('licenses')">
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-section__header"
|
||||
[attr.aria-controls]="'licenses-content'"
|
||||
[attr.aria-expanded]="isSectionExpanded('licenses')"
|
||||
(click)="toggleSection('licenses')"
|
||||
>
|
||||
<span class="evidence-section__title">Licenses</span>
|
||||
<span class="evidence-section__icon" [class.rotated]="isSectionExpanded('licenses')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4.5 6L8 9.5L11.5 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@if (isSectionExpanded('licenses')) {
|
||||
<div id="licenses-content" class="evidence-section__content">
|
||||
<ul class="license-list" role="list">
|
||||
@for (license of ev.licenses; track license.license.id ?? license.license.name) {
|
||||
<li class="license-item">
|
||||
<span class="license-item__id">
|
||||
{{ license.license.id ?? license.license.name }}
|
||||
</span>
|
||||
<span class="license-item__ack" [class]="'ack--' + license.acknowledgement">
|
||||
({{ license.acknowledgement }})
|
||||
</span>
|
||||
@if (license.license.url) {
|
||||
<a
|
||||
class="license-item__link"
|
||||
[href]="license.license.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
[attr.aria-label]="'View ' + (license.license.id ?? license.license.name) + ' license'"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M10 2H14V6M14 2L7 9M12 9V14H2V4H7"/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Copyright Section -->
|
||||
@if (ev.copyright && ev.copyright.length > 0) {
|
||||
<section class="evidence-section" [attr.aria-expanded]="isSectionExpanded('copyright')">
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-section__header"
|
||||
[attr.aria-controls]="'copyright-content'"
|
||||
[attr.aria-expanded]="isSectionExpanded('copyright')"
|
||||
(click)="toggleSection('copyright')"
|
||||
>
|
||||
<span class="evidence-section__title">Copyright</span>
|
||||
<span class="evidence-section__icon" [class.rotated]="isSectionExpanded('copyright')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4.5 6L8 9.5L11.5 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@if (isSectionExpanded('copyright')) {
|
||||
<div id="copyright-content" class="evidence-section__content">
|
||||
<ul class="copyright-list" role="list">
|
||||
@for (cr of ev.copyright; track cr.text) {
|
||||
<li class="copyright-item">{{ cr.text }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- No evidence message -->
|
||||
@if (!ev.identity && (!ev.occurrences || ev.occurrences.length === 0) &&
|
||||
(!ev.licenses || ev.licenses.length === 0) &&
|
||||
(!ev.copyright || ev.copyright.length === 0)) {
|
||||
<div class="evidence-empty">
|
||||
<p>No evidence data available for this component.</p>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="evidence-empty">
|
||||
<p>No evidence data available.</p>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-panel {
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.evidence-panel__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border-bottom: 1px solid var(--border-default, #e5e7eb);
|
||||
}
|
||||
|
||||
.evidence-panel__title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.evidence-panel__expand-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-link, #2563eb);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-link-hover, #1d4ed8);
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-section {
|
||||
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-section__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--focus-ring, #3b82f6);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-section__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.evidence-section__icon {
|
||||
transition: transform 0.2s;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-section__content {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
|
||||
/* Identity Card */
|
||||
.identity-card {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.identity-card__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.identity-card__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.identity-card__value {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--code-bg, #f3f4f6);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.identity-card__methods {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.identity-card__methods-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.identity-method {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--badge-bg, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.identity-method__conf {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
/* Occurrence List */
|
||||
.occurrence-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.occurrence-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.occurrence-item__path {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary, #111827);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.occurrence-item__line {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.occurrence-item__view-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-link, #2563eb);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
border-color: var(--text-link, #2563eb);
|
||||
}
|
||||
}
|
||||
|
||||
/* License List */
|
||||
.license-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.license-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.license-item__id {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.license-item__ack {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
|
||||
&.ack--declared {
|
||||
color: var(--semantic-info, #2563eb);
|
||||
}
|
||||
|
||||
&.ack--concluded {
|
||||
color: var(--semantic-success, #16a34a);
|
||||
}
|
||||
}
|
||||
|
||||
.license-item__link {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-link, #2563eb);
|
||||
}
|
||||
}
|
||||
|
||||
/* Copyright List */
|
||||
.copyright-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.copyright-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary, #111827);
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.evidence-empty {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class CdxEvidencePanelComponent {
|
||||
/** Component PURL */
|
||||
readonly purl = input.required<string>();
|
||||
|
||||
/** CycloneDX evidence data */
|
||||
readonly evidence = input<ComponentEvidence | undefined>(undefined);
|
||||
|
||||
/** Emits when user clicks View on an occurrence */
|
||||
readonly viewOccurrence = output<OccurrenceEvidence>();
|
||||
|
||||
/** Emits when user requests to close the panel */
|
||||
readonly close = output<void>();
|
||||
|
||||
/** Expanded sections */
|
||||
private readonly expandedSections = signal<Set<SectionId>>(new Set(['identity']));
|
||||
|
||||
/** Check if section is expanded */
|
||||
isSectionExpanded(section: SectionId): boolean {
|
||||
return this.expandedSections().has(section);
|
||||
}
|
||||
|
||||
/** Toggle section expansion */
|
||||
toggleSection(section: SectionId): void {
|
||||
this.expandedSections.update((sections) => {
|
||||
const newSections = new Set(sections);
|
||||
if (newSections.has(section)) {
|
||||
newSections.delete(section);
|
||||
} else {
|
||||
newSections.add(section);
|
||||
}
|
||||
return newSections;
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if all sections are expanded */
|
||||
readonly allExpanded = computed(() => {
|
||||
const expanded = this.expandedSections();
|
||||
const ev = this.evidence();
|
||||
if (!ev) return false;
|
||||
|
||||
const availableSections: SectionId[] = [];
|
||||
if (ev.identity) availableSections.push('identity');
|
||||
if (ev.occurrences?.length) availableSections.push('occurrences');
|
||||
if (ev.licenses?.length) availableSections.push('licenses');
|
||||
if (ev.copyright?.length) availableSections.push('copyright');
|
||||
|
||||
return availableSections.every((s) => expanded.has(s));
|
||||
});
|
||||
|
||||
/** Toggle all sections */
|
||||
toggleAll(): void {
|
||||
const ev = this.evidence();
|
||||
if (!ev) return;
|
||||
|
||||
if (this.allExpanded()) {
|
||||
this.expandedSections.set(new Set());
|
||||
} else {
|
||||
const allSections: SectionId[] = [];
|
||||
if (ev.identity) allSections.push('identity');
|
||||
if (ev.occurrences?.length) allSections.push('occurrences');
|
||||
if (ev.licenses?.length) allSections.push('licenses');
|
||||
if (ev.copyright?.length) allSections.push('copyright');
|
||||
this.expandedSections.set(new Set(allSections));
|
||||
}
|
||||
}
|
||||
|
||||
/** Identity value to display */
|
||||
readonly identityValue = computed(() => {
|
||||
const ev = this.evidence();
|
||||
if (!ev?.identity) return '';
|
||||
|
||||
// Try to extract value from methods
|
||||
const method = ev.identity.methods?.find((m) => m.value);
|
||||
if (method?.value) return method.value;
|
||||
|
||||
// Fallback to PURL
|
||||
return this.purl();
|
||||
});
|
||||
|
||||
/** Get human-readable technique label */
|
||||
getTechniqueLabel(technique: string): string {
|
||||
return getIdentityTechniqueLabel(technique as Parameters<typeof getIdentityTechniqueLabel>[0]);
|
||||
}
|
||||
|
||||
/** Handle view occurrence click */
|
||||
onViewOccurrence(occurrence: OccurrenceEvidence): void {
|
||||
this.viewOccurrence.emit(occurrence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* @file commit-info.component.spec.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE (UI-011)
|
||||
* @description Unit tests for CommitInfoComponent.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { CommitInfoComponent } from './commit-info.component';
|
||||
import { PedigreeCommit } from '../../models/cyclonedx-evidence.models';
|
||||
|
||||
describe('CommitInfoComponent', () => {
|
||||
let component: CommitInfoComponent;
|
||||
let fixture: ComponentFixture<CommitInfoComponent>;
|
||||
|
||||
const mockCommit: PedigreeCommit = {
|
||||
uid: 'abc123def456789012345678901234567890abcd',
|
||||
url: 'https://github.com/example/repo/commit/abc123def456789012345678901234567890abcd',
|
||||
author: {
|
||||
name: 'Jane Developer',
|
||||
email: 'jane@example.com',
|
||||
timestamp: '2025-12-15T10:30:00Z',
|
||||
},
|
||||
committer: {
|
||||
name: 'Build Bot',
|
||||
email: 'bot@example.com',
|
||||
timestamp: '2025-12-15T11:00:00Z',
|
||||
},
|
||||
message: `Fix security vulnerability CVE-2025-1234
|
||||
|
||||
This patch addresses a critical buffer overflow in the parsing module.
|
||||
The fix implements proper bounds checking before memory access.
|
||||
|
||||
Signed-off-by: Jane Developer <jane@example.com>`,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommitInfoComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CommitInfoComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('commit display', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('commit', mockCommit);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display short SHA', () => {
|
||||
const shaElement = fixture.nativeElement.querySelector('.commit-sha__value');
|
||||
expect(shaElement.textContent).toBe('abc123d');
|
||||
});
|
||||
|
||||
it('should display author name', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('Jane Developer');
|
||||
});
|
||||
|
||||
it('should display author email', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('jane@example.com');
|
||||
});
|
||||
|
||||
it('should display commit message', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('Fix security vulnerability');
|
||||
});
|
||||
|
||||
it('should display committer when different from author', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('Build Bot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('short SHA computation', () => {
|
||||
it('should return first 7 characters', () => {
|
||||
fixture.componentRef.setInput('commit', mockCommit);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.shortSha()).toBe('abc123d');
|
||||
});
|
||||
|
||||
it('should return empty string for no commit', () => {
|
||||
fixture.componentRef.setInput('commit', undefined);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.shortSha()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('repository host extraction', () => {
|
||||
it('should extract github.com from URL', () => {
|
||||
fixture.componentRef.setInput('commit', mockCommit);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.repoHost()).toBe('github.com');
|
||||
});
|
||||
|
||||
it('should extract gitlab.com from URL', () => {
|
||||
const gitlabCommit = {
|
||||
...mockCommit,
|
||||
url: 'https://gitlab.com/group/project/-/commit/abc123',
|
||||
};
|
||||
fixture.componentRef.setInput('commit', gitlabCommit);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.repoHost()).toBe('gitlab.com');
|
||||
});
|
||||
|
||||
it('should return empty string for no URL', () => {
|
||||
const noUrlCommit = { ...mockCommit, url: undefined };
|
||||
fixture.componentRef.setInput('commit', noUrlCommit);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.repoHost()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timestamp formatting', () => {
|
||||
it('should format ISO timestamp', () => {
|
||||
const formatted = component.formatTimestamp('2025-12-15T10:30:00Z');
|
||||
expect(formatted).toContain('2025');
|
||||
expect(formatted).toContain('Dec');
|
||||
});
|
||||
|
||||
it('should return original for invalid timestamp', () => {
|
||||
const invalid = 'not-a-date';
|
||||
// The formatTimestamp catches errors and returns original
|
||||
expect(component.formatTimestamp(invalid)).toBe(invalid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('author vs committer', () => {
|
||||
it('should detect different author and committer', () => {
|
||||
expect(
|
||||
component.isDifferentFromAuthor(
|
||||
mockCommit.author,
|
||||
mockCommit.committer
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not show committer if same as author', () => {
|
||||
const sameCommit: PedigreeCommit = {
|
||||
...mockCommit,
|
||||
committer: mockCommit.author,
|
||||
};
|
||||
fixture.componentRef.setInput('commit', sameCommit);
|
||||
fixture.detectChanges();
|
||||
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).not.toContain('Committer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('message truncation', () => {
|
||||
it('should detect when truncation is needed', () => {
|
||||
fixture.componentRef.setInput('commit', mockCommit);
|
||||
fixture.componentRef.setInput('maxLines', 3);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.needsTruncation()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not truncate short messages', () => {
|
||||
const shortCommit: PedigreeCommit = {
|
||||
...mockCommit,
|
||||
message: 'Short message',
|
||||
};
|
||||
fixture.componentRef.setInput('commit', shortCommit);
|
||||
fixture.componentRef.setInput('maxLines', 3);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.needsTruncation()).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle message expansion', () => {
|
||||
fixture.componentRef.setInput('commit', mockCommit);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.messageExpanded()).toBe(false);
|
||||
component.toggleMessage();
|
||||
expect(component.messageExpanded()).toBe(true);
|
||||
component.toggleMessage();
|
||||
expect(component.messageExpanded()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy functionality', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('commit', mockCommit);
|
||||
fixture.detectChanges();
|
||||
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy full SHA to clipboard', async () => {
|
||||
await component.copySha();
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockCommit.uid);
|
||||
});
|
||||
|
||||
it('should set copied state', async () => {
|
||||
await component.copySha();
|
||||
expect(component.copied()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should show empty message when no commit', () => {
|
||||
fixture.componentRef.setInput('commit', undefined);
|
||||
fixture.detectChanges();
|
||||
|
||||
const empty = fixture.nativeElement.querySelector('.commit-empty');
|
||||
expect(empty).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('external link', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('commit', mockCommit);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should have link to upstream repository', () => {
|
||||
const link = fixture.nativeElement.querySelector('.commit-sha__link');
|
||||
expect(link).toBeTruthy();
|
||||
expect(link.getAttribute('href')).toBe(mockCommit.url);
|
||||
});
|
||||
|
||||
it('should open in new tab', () => {
|
||||
const link = fixture.nativeElement.querySelector('.commit-sha__link');
|
||||
expect(link.getAttribute('target')).toBe('_blank');
|
||||
expect(link.getAttribute('rel')).toContain('noopener');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @file commit-info.component.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE (UI-006)
|
||||
* @description Component for displaying commit information from pedigree.
|
||||
* Shows commit SHA, author, committer, message, and timestamp.
|
||||
*/
|
||||
|
||||
import { Component, computed, input, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { PedigreeCommit, CommitIdentity } from '../../models/cyclonedx-evidence.models';
|
||||
|
||||
/**
|
||||
* Commit info component for displaying pedigree commit details.
|
||||
*
|
||||
* Features:
|
||||
* - Display commit SHA with copy button
|
||||
* - Link to upstream repository
|
||||
* - Show author and committer
|
||||
* - Show commit message (truncated with expand)
|
||||
* - Timestamp display
|
||||
*
|
||||
* @example
|
||||
* <app-commit-info [commit]="pedigree.commits[0]" />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-commit-info',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
@if (commit(); as c) {
|
||||
<article class="commit-info" [attr.aria-label]="'Commit ' + shortSha()">
|
||||
<!-- Commit SHA -->
|
||||
<div class="commit-row commit-row--sha">
|
||||
<span class="commit-label">Commit</span>
|
||||
<div class="commit-sha">
|
||||
<code class="commit-sha__value">{{ shortSha() }}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="commit-sha__copy"
|
||||
(click)="copySha()"
|
||||
[attr.aria-label]="copied() ? 'Copied!' : 'Copy full SHA'"
|
||||
>
|
||||
@if (copied()) {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
@if (c.url) {
|
||||
<a
|
||||
class="commit-sha__link"
|
||||
[href]="c.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
[attr.aria-label]="'View commit on ' + repoHost()"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4"/>
|
||||
<path d="M14 4h6v6M21 3l-9 9"/>
|
||||
</svg>
|
||||
{{ repoHost() }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
@if (c.author) {
|
||||
<div class="commit-row">
|
||||
<span class="commit-label">Author</span>
|
||||
<div class="commit-identity">
|
||||
<span class="commit-identity__name">{{ c.author.name ?? 'Unknown' }}</span>
|
||||
@if (c.author.email) {
|
||||
<span class="commit-identity__email"><{{ c.author.email }}></span>
|
||||
}
|
||||
@if (c.author.timestamp) {
|
||||
<time class="commit-identity__time" [attr.datetime]="c.author.timestamp">
|
||||
{{ formatTimestamp(c.author.timestamp) }}
|
||||
</time>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Committer (if different from author) -->
|
||||
@if (c.committer && isDifferentFromAuthor(c.author, c.committer)) {
|
||||
<div class="commit-row">
|
||||
<span class="commit-label">Committer</span>
|
||||
<div class="commit-identity">
|
||||
<span class="commit-identity__name">{{ c.committer.name ?? 'Unknown' }}</span>
|
||||
@if (c.committer.email) {
|
||||
<span class="commit-identity__email"><{{ c.committer.email }}></span>
|
||||
}
|
||||
@if (c.committer.timestamp) {
|
||||
<time class="commit-identity__time" [attr.datetime]="c.committer.timestamp">
|
||||
{{ formatTimestamp(c.committer.timestamp) }}
|
||||
</time>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Commit Message -->
|
||||
@if (c.message) {
|
||||
<div class="commit-row commit-row--message">
|
||||
<span class="commit-label">Message</span>
|
||||
<div class="commit-message" [class.expanded]="messageExpanded()">
|
||||
<p class="commit-message__text">{{ displayMessage() }}</p>
|
||||
@if (needsTruncation()) {
|
||||
<button
|
||||
type="button"
|
||||
class="commit-message__toggle"
|
||||
(click)="toggleMessage()"
|
||||
>
|
||||
{{ messageExpanded() ? 'Show less' : 'Show more' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
} @else {
|
||||
<div class="commit-empty">
|
||||
<p>No commit information available.</p>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
.commit-info {
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.commit-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&--sha {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&--message {
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.commit-label {
|
||||
min-width: 5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
/* SHA */
|
||||
.commit-sha {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.commit-sha__value {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--code-bg, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.commit-sha__copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-hover, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
.commit-sha__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-link, #2563eb);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
border-color: var(--text-link, #2563eb);
|
||||
}
|
||||
}
|
||||
|
||||
/* Identity */
|
||||
.commit-identity {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.commit-identity__name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.commit-identity__email {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.commit-identity__time {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
/* Message */
|
||||
.commit-message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.commit-message__text {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary, #111827);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
.commit-message:not(.expanded) & {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.commit-message__toggle {
|
||||
padding: 0;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-link, #2563eb);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.commit-empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class CommitInfoComponent {
|
||||
/** Commit data to display */
|
||||
readonly commit = input<PedigreeCommit | undefined>(undefined);
|
||||
|
||||
/** Max lines before truncation */
|
||||
readonly maxLines = input<number>(3);
|
||||
|
||||
/** Whether message is expanded */
|
||||
readonly messageExpanded = signal<boolean>(false);
|
||||
|
||||
/** Copied state for SHA */
|
||||
readonly copied = signal<boolean>(false);
|
||||
|
||||
/** Short SHA (first 7 characters) */
|
||||
readonly shortSha = computed<string>(() => {
|
||||
const c = this.commit();
|
||||
return c?.uid?.slice(0, 7) ?? '';
|
||||
});
|
||||
|
||||
/** Repository host from URL */
|
||||
readonly repoHost = computed<string>(() => {
|
||||
const url = this.commit()?.url;
|
||||
if (!url) return '';
|
||||
try {
|
||||
const host = new URL(url).hostname;
|
||||
return host.replace('www.', '');
|
||||
} catch {
|
||||
return 'View';
|
||||
}
|
||||
});
|
||||
|
||||
/** Display message (potentially truncated) */
|
||||
readonly displayMessage = computed<string>(() => {
|
||||
return this.commit()?.message ?? '';
|
||||
});
|
||||
|
||||
/** Whether message needs truncation */
|
||||
readonly needsTruncation = computed<boolean>(() => {
|
||||
const message = this.commit()?.message;
|
||||
if (!message) return false;
|
||||
const lines = message.split('\n').length;
|
||||
return lines > this.maxLines();
|
||||
});
|
||||
|
||||
/** Toggle message expansion */
|
||||
toggleMessage(): void {
|
||||
this.messageExpanded.update((v) => !v);
|
||||
}
|
||||
|
||||
/** Check if committer is different from author */
|
||||
isDifferentFromAuthor(
|
||||
author: CommitIdentity | undefined,
|
||||
committer: CommitIdentity | undefined
|
||||
): boolean {
|
||||
if (!author || !committer) return false;
|
||||
return (
|
||||
author.name !== committer.name || author.email !== committer.email
|
||||
);
|
||||
}
|
||||
|
||||
/** Format timestamp for display */
|
||||
formatTimestamp(timestamp: string): string {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy full SHA to clipboard */
|
||||
async copySha(): Promise<void> {
|
||||
const sha = this.commit()?.uid;
|
||||
if (!sha) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(sha);
|
||||
this.copied.set(true);
|
||||
setTimeout(() => this.copied.set(false), 2000);
|
||||
} catch {
|
||||
console.error('Failed to copy SHA');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @file diff-viewer.component.spec.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE (UI-011)
|
||||
* @description Unit tests for DiffViewerComponent.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { DiffViewerComponent } from './diff-viewer.component';
|
||||
import { PatchDiff } from '../../models/cyclonedx-evidence.models';
|
||||
|
||||
describe('DiffViewerComponent', () => {
|
||||
let component: DiffViewerComponent;
|
||||
let fixture: ComponentFixture<DiffViewerComponent>;
|
||||
|
||||
const mockDiffText = `@@ -1,5 +1,6 @@
|
||||
context line 1
|
||||
-deleted line
|
||||
+added line
|
||||
context line 2
|
||||
context line 3
|
||||
+another added line
|
||||
context line 4`;
|
||||
|
||||
const mockDiff: PatchDiff = {
|
||||
url: 'https://github.com/example/repo/commit/abc123.diff',
|
||||
text: {
|
||||
contentType: 'text/plain',
|
||||
content: mockDiffText,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DiffViewerComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DiffViewerComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('diff parsing', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('diffText', mockDiffText);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should parse diff lines correctly', () => {
|
||||
const lines = component.parsedLines();
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should identify header lines', () => {
|
||||
const lines = component.parsedLines();
|
||||
const headerLine = lines.find((l) => l.type === 'header');
|
||||
expect(headerLine).toBeTruthy();
|
||||
expect(headerLine?.content).toContain('@@');
|
||||
});
|
||||
|
||||
it('should identify addition lines', () => {
|
||||
const lines = component.parsedLines();
|
||||
const additions = lines.filter((l) => l.type === 'addition');
|
||||
expect(additions.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should identify deletion lines', () => {
|
||||
const lines = component.parsedLines();
|
||||
const deletions = lines.filter((l) => l.type === 'deletion');
|
||||
expect(deletions.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should identify context lines', () => {
|
||||
const lines = component.parsedLines();
|
||||
const context = lines.filter((l) => l.type === 'context');
|
||||
expect(context.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diff stats', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('diffText', mockDiffText);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should calculate additions correctly', () => {
|
||||
const stats = component.diffStats();
|
||||
expect(stats.additions).toBe(2);
|
||||
});
|
||||
|
||||
it('should calculate deletions correctly', () => {
|
||||
const stats = component.diffStats();
|
||||
expect(stats.deletions).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('view mode', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('diffText', mockDiffText);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should default to unified view', () => {
|
||||
expect(component.viewMode()).toBe('unified');
|
||||
});
|
||||
|
||||
it('should switch to side-by-side view', () => {
|
||||
component.setViewMode('side-by-side');
|
||||
expect(component.viewMode()).toBe('side-by-side');
|
||||
});
|
||||
|
||||
it('should render unified view by default', () => {
|
||||
const unified = fixture.nativeElement.querySelector('.diff-unified');
|
||||
expect(unified).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render side-by-side view when selected', () => {
|
||||
component.setViewMode('side-by-side');
|
||||
fixture.detectChanges();
|
||||
|
||||
const sideBySide = fixture.nativeElement.querySelector('.diff-side-by-side');
|
||||
expect(sideBySide).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy functionality', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('diffText', mockDiffText);
|
||||
fixture.detectChanges();
|
||||
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy diff to clipboard', async () => {
|
||||
await component.copyDiff();
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockDiffText);
|
||||
});
|
||||
|
||||
it('should set copied state', async () => {
|
||||
await component.copyDiff();
|
||||
expect(component.copied()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close functionality', () => {
|
||||
it('should emit close event', () => {
|
||||
const closeSpy = jest.spyOn(component.close, 'emit');
|
||||
component.onClose();
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64 decoding', () => {
|
||||
it('should decode base64 content', () => {
|
||||
const base64Diff: PatchDiff = {
|
||||
text: {
|
||||
content: btoa(mockDiffText),
|
||||
encoding: 'base64',
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput('diff', base64Diff);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.rawDiff()).toBe(mockDiffText);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should show empty message when no diff content', () => {
|
||||
fixture.componentRef.setInput('diffText', '');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.hasContent()).toBe(false);
|
||||
});
|
||||
|
||||
it('should show external link when URL provided', () => {
|
||||
fixture.componentRef.setInput('diffText', '');
|
||||
fixture.componentRef.setInput('diffUrl', 'https://example.com/diff');
|
||||
fixture.detectChanges();
|
||||
|
||||
const link = fixture.nativeElement.querySelector('.diff-link');
|
||||
expect(link).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('line numbers', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('diffText', mockDiffText);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should assign line numbers to context lines', () => {
|
||||
const lines = component.parsedLines();
|
||||
const contextLines = lines.filter((l) => l.type === 'context');
|
||||
const withOldLine = contextLines.filter((l) => l.oldLineNumber !== undefined);
|
||||
expect(withOldLine.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should assign new line numbers to additions', () => {
|
||||
const lines = component.parsedLines();
|
||||
const additions = lines.filter((l) => l.type === 'addition');
|
||||
const withNewLine = additions.filter((l) => l.newLineNumber !== undefined);
|
||||
expect(withNewLine.length).toBe(additions.length);
|
||||
});
|
||||
|
||||
it('should assign old line numbers to deletions', () => {
|
||||
const lines = component.parsedLines();
|
||||
const deletions = lines.filter((l) => l.type === 'deletion');
|
||||
const withOldLine = deletions.filter((l) => l.oldLineNumber !== undefined);
|
||||
expect(withOldLine.length).toBe(deletions.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('side-by-side lines', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('diffText', mockDiffText);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should separate old lines correctly', () => {
|
||||
const oldLines = component.oldLines();
|
||||
const hasAddition = oldLines.some((l) => l.type === 'addition');
|
||||
expect(hasAddition).toBe(false);
|
||||
});
|
||||
|
||||
it('should separate new lines correctly', () => {
|
||||
const newLines = component.newLines();
|
||||
const hasDeletion = newLines.some((l) => l.type === 'deletion');
|
||||
expect(hasDeletion).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,646 @@
|
||||
/**
|
||||
* @file diff-viewer.component.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE (UI-005)
|
||||
* @description Syntax-highlighted diff display component.
|
||||
* Supports side-by-side and unified views with collapsible unchanged regions.
|
||||
*/
|
||||
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { PatchDiff } from '../../models/cyclonedx-evidence.models';
|
||||
|
||||
/**
|
||||
* Parsed diff line with metadata.
|
||||
*/
|
||||
interface DiffLine {
|
||||
readonly type: 'addition' | 'deletion' | 'context' | 'header';
|
||||
readonly content: string;
|
||||
readonly oldLineNumber?: number;
|
||||
readonly newLineNumber?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed region of unchanged lines.
|
||||
*/
|
||||
interface CollapsedRegion {
|
||||
readonly startIndex: number;
|
||||
readonly endIndex: number;
|
||||
readonly lineCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff viewer component for displaying patch diffs.
|
||||
*
|
||||
* Features:
|
||||
* - Syntax-highlighted diff display
|
||||
* - Side-by-side and unified views
|
||||
* - Line number gutter
|
||||
* - Copy diff button
|
||||
* - Collapse unchanged regions
|
||||
*
|
||||
* @example
|
||||
* <app-diff-viewer
|
||||
* [diff]="patch.diff"
|
||||
* [viewMode]="'unified'"
|
||||
* (close)="onCloseDiff()"
|
||||
* />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-diff-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<section class="diff-viewer" [attr.aria-label]="'Diff viewer'">
|
||||
<!-- Header -->
|
||||
<header class="diff-viewer__header">
|
||||
<h3 class="diff-viewer__title">Diff</h3>
|
||||
<div class="diff-viewer__controls">
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="view-mode-toggle" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
class="view-mode-btn"
|
||||
[class.active]="viewMode() === 'unified'"
|
||||
(click)="setViewMode('unified')"
|
||||
role="tab"
|
||||
[attr.aria-selected]="viewMode() === 'unified'"
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-mode-btn"
|
||||
[class.active]="viewMode() === 'side-by-side'"
|
||||
(click)="setViewMode('side-by-side')"
|
||||
role="tab"
|
||||
[attr.aria-selected]="viewMode() === 'side-by-side'"
|
||||
>
|
||||
Side-by-Side
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Copy Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="copy-diff-btn"
|
||||
(click)="copyDiff()"
|
||||
[attr.aria-label]="copied() ? 'Copied!' : 'Copy diff'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
||||
</svg>
|
||||
{{ copied() ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn"
|
||||
(click)="onClose()"
|
||||
aria-label="Close diff viewer"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats -->
|
||||
@if (diffStats(); as stats) {
|
||||
<div class="diff-stats">
|
||||
<span class="diff-stat diff-stat--additions">+{{ stats.additions }}</span>
|
||||
<span class="diff-stat diff-stat--deletions">-{{ stats.deletions }}</span>
|
||||
<span class="diff-stat diff-stat--files">{{ stats.files }} file(s)</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="diff-viewer__content" [class.side-by-side]="viewMode() === 'side-by-side'">
|
||||
@if (viewMode() === 'unified') {
|
||||
<!-- Unified View -->
|
||||
<div class="diff-unified">
|
||||
@for (line of visibleLines(); track $index; let i = $index) {
|
||||
@if (isCollapsedRegion(i)) {
|
||||
<div class="diff-collapsed" (click)="expandRegion(i)">
|
||||
<span class="diff-collapsed__icon">+</span>
|
||||
<span class="diff-collapsed__text">
|
||||
{{ getCollapsedCount(i) }} unchanged lines (click to expand)
|
||||
</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="diff-line"
|
||||
[class.diff-line--addition]="line.type === 'addition'"
|
||||
[class.diff-line--deletion]="line.type === 'deletion'"
|
||||
[class.diff-line--context]="line.type === 'context'"
|
||||
[class.diff-line--header]="line.type === 'header'"
|
||||
>
|
||||
<span class="diff-line__gutter diff-line__gutter--old">
|
||||
{{ line.oldLineNumber ?? '' }}
|
||||
</span>
|
||||
<span class="diff-line__gutter diff-line__gutter--new">
|
||||
{{ line.newLineNumber ?? '' }}
|
||||
</span>
|
||||
<span class="diff-line__prefix">
|
||||
@switch (line.type) {
|
||||
@case ('addition') { + }
|
||||
@case ('deletion') { - }
|
||||
@case ('header') { @@ }
|
||||
@default { }
|
||||
}
|
||||
</span>
|
||||
<code class="diff-line__content">{{ line.content }}</code>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Side-by-Side View -->
|
||||
<div class="diff-side-by-side">
|
||||
<div class="diff-pane diff-pane--old">
|
||||
<div class="diff-pane__header">Old</div>
|
||||
@for (line of oldLines(); track $index) {
|
||||
<div
|
||||
class="diff-line"
|
||||
[class.diff-line--deletion]="line.type === 'deletion'"
|
||||
[class.diff-line--context]="line.type === 'context'"
|
||||
[class.diff-line--empty]="line.type === 'addition'"
|
||||
>
|
||||
<span class="diff-line__gutter">{{ line.oldLineNumber ?? '' }}</span>
|
||||
<code class="diff-line__content">{{ line.type !== 'addition' ? line.content : '' }}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="diff-pane diff-pane--new">
|
||||
<div class="diff-pane__header">New</div>
|
||||
@for (line of newLines(); track $index) {
|
||||
<div
|
||||
class="diff-line"
|
||||
[class.diff-line--addition]="line.type === 'addition'"
|
||||
[class.diff-line--context]="line.type === 'context'"
|
||||
[class.diff-line--empty]="line.type === 'deletion'"
|
||||
>
|
||||
<span class="diff-line__gutter">{{ line.newLineNumber ?? '' }}</span>
|
||||
<code class="diff-line__content">{{ line.type !== 'deletion' ? line.content : '' }}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!hasContent()) {
|
||||
<div class="diff-empty">
|
||||
<p>No diff content available.</p>
|
||||
@if (diffUrl()) {
|
||||
<a
|
||||
class="diff-link"
|
||||
[href]="diffUrl()"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View diff externally
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.diff-viewer {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diff-viewer__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
border-bottom: 1px solid var(--border-default, #e5e7eb);
|
||||
}
|
||||
|
||||
.diff-viewer__title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.diff-viewer__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* View Mode Toggle */
|
||||
.view-mode-toggle {
|
||||
display: flex;
|
||||
background: var(--surface-tertiary, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.view-mode-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
color: var(--text-primary, #111827);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.copy-diff-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 0.375rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.diff-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface-tertiary, #f3f4f6);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.diff-stat {
|
||||
font-weight: 500;
|
||||
|
||||
&--additions {
|
||||
color: var(--color-addition, #16a34a);
|
||||
}
|
||||
|
||||
&--deletions {
|
||||
color: var(--color-deletion, #dc2626);
|
||||
}
|
||||
|
||||
&--files {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.diff-viewer__content {
|
||||
overflow: auto;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
/* Unified View */
|
||||
.diff-unified {
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: flex;
|
||||
min-height: 1.5rem;
|
||||
|
||||
&--addition {
|
||||
background: var(--color-addition-bg, #dcfce7);
|
||||
}
|
||||
|
||||
&--deletion {
|
||||
background: var(--color-deletion-bg, #fee2e2);
|
||||
}
|
||||
|
||||
&--context {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&--header {
|
||||
background: var(--surface-secondary, #f3f4f6);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&--empty {
|
||||
background: var(--surface-tertiary, #f9fafb);
|
||||
}
|
||||
}
|
||||
|
||||
.diff-line__gutter {
|
||||
min-width: 3rem;
|
||||
padding: 0 0.5rem;
|
||||
text-align: right;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
border-right: 1px solid var(--border-light, #e5e7eb);
|
||||
user-select: none;
|
||||
|
||||
&--old {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.diff-line__prefix {
|
||||
width: 1.5rem;
|
||||
padding: 0 0.25rem;
|
||||
text-align: center;
|
||||
color: inherit;
|
||||
user-select: none;
|
||||
|
||||
.diff-line--addition & {
|
||||
color: var(--color-addition, #16a34a);
|
||||
}
|
||||
|
||||
.diff-line--deletion & {
|
||||
color: var(--color-deletion, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.diff-line__content {
|
||||
flex: 1;
|
||||
padding: 0 0.5rem;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Collapsed Region */
|
||||
.diff-collapsed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface-tertiary, #f3f4f6);
|
||||
border-top: 1px solid var(--border-light, #e5e7eb);
|
||||
border-bottom: 1px solid var(--border-light, #e5e7eb);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
.diff-collapsed__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--color-primary, #2563eb);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Side-by-Side View */
|
||||
.diff-side-by-side {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.diff-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
|
||||
&--old {
|
||||
border-right: 1px solid var(--border-default, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
.diff-pane__header {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
border-bottom: 1px solid var(--border-light, #e5e7eb);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.diff-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.diff-link {
|
||||
color: var(--text-link, #2563eb);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class DiffViewerComponent {
|
||||
/** Patch diff data */
|
||||
readonly diff = input<PatchDiff | undefined>(undefined);
|
||||
|
||||
/** Raw diff text (alternative to diff object) */
|
||||
readonly diffText = input<string | undefined>(undefined);
|
||||
|
||||
/** URL to external diff */
|
||||
readonly diffUrl = input<string | undefined>(undefined);
|
||||
|
||||
/** Emits when viewer should close */
|
||||
readonly close = output<void>();
|
||||
|
||||
/** Current view mode */
|
||||
readonly viewMode = signal<'unified' | 'side-by-side'>('unified');
|
||||
|
||||
/** Copied state for feedback */
|
||||
readonly copied = signal<boolean>(false);
|
||||
|
||||
/** Expanded collapsed regions */
|
||||
private readonly expandedRegions = signal<Set<number>>(new Set());
|
||||
|
||||
/** Minimum context lines to show around changes */
|
||||
private readonly contextLines = 3;
|
||||
|
||||
/** Raw diff content */
|
||||
readonly rawDiff = computed<string>(() => {
|
||||
const diff = this.diff();
|
||||
if (diff?.text?.content) {
|
||||
if (diff.text.encoding === 'base64') {
|
||||
try {
|
||||
return atob(diff.text.content);
|
||||
} catch {
|
||||
return diff.text.content;
|
||||
}
|
||||
}
|
||||
return diff.text.content;
|
||||
}
|
||||
return this.diffText() ?? '';
|
||||
});
|
||||
|
||||
/** Whether there is content to display */
|
||||
readonly hasContent = computed<boolean>(() => {
|
||||
return this.rawDiff().trim().length > 0;
|
||||
});
|
||||
|
||||
/** Parsed diff lines */
|
||||
readonly parsedLines = computed<DiffLine[]>(() => {
|
||||
const raw = this.rawDiff();
|
||||
if (!raw) return [];
|
||||
|
||||
const lines = raw.split('\n');
|
||||
const result: DiffLine[] = [];
|
||||
let oldLine = 1;
|
||||
let newLine = 1;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('@@')) {
|
||||
// Parse hunk header for line numbers
|
||||
const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/);
|
||||
if (match) {
|
||||
oldLine = parseInt(match[1], 10);
|
||||
newLine = parseInt(match[2], 10);
|
||||
}
|
||||
result.push({ type: 'header', content: line });
|
||||
} else if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
result.push({
|
||||
type: 'addition',
|
||||
content: line.slice(1),
|
||||
newLineNumber: newLine++,
|
||||
});
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
result.push({
|
||||
type: 'deletion',
|
||||
content: line.slice(1),
|
||||
oldLineNumber: oldLine++,
|
||||
});
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
result.push({
|
||||
type: 'context',
|
||||
content: line.slice(1) || '',
|
||||
oldLineNumber: oldLine++,
|
||||
newLineNumber: newLine++,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/** Visible lines with collapsed regions */
|
||||
readonly visibleLines = computed<DiffLine[]>(() => {
|
||||
// For now, return all lines (collapse logic would be here)
|
||||
return this.parsedLines();
|
||||
});
|
||||
|
||||
/** Lines for old pane in side-by-side view */
|
||||
readonly oldLines = computed<DiffLine[]>(() => {
|
||||
return this.parsedLines().filter(
|
||||
(l) => l.type !== 'header' && l.type !== 'addition'
|
||||
);
|
||||
});
|
||||
|
||||
/** Lines for new pane in side-by-side view */
|
||||
readonly newLines = computed<DiffLine[]>(() => {
|
||||
return this.parsedLines().filter(
|
||||
(l) => l.type !== 'header' && l.type !== 'deletion'
|
||||
);
|
||||
});
|
||||
|
||||
/** Diff statistics */
|
||||
readonly diffStats = computed(() => {
|
||||
const lines = this.parsedLines();
|
||||
const additions = lines.filter((l) => l.type === 'addition').length;
|
||||
const deletions = lines.filter((l) => l.type === 'deletion').length;
|
||||
const files = 1; // Would parse from diff headers
|
||||
|
||||
return { additions, deletions, files };
|
||||
});
|
||||
|
||||
/** Set view mode */
|
||||
setViewMode(mode: 'unified' | 'side-by-side'): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
/** Check if index is a collapsed region */
|
||||
isCollapsedRegion(index: number): boolean {
|
||||
// Simplified - would implement actual collapse logic
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Get count of collapsed lines */
|
||||
getCollapsedCount(index: number): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Expand a collapsed region */
|
||||
expandRegion(index: number): void {
|
||||
this.expandedRegions.update((set) => {
|
||||
const newSet = new Set(set);
|
||||
newSet.add(index);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
|
||||
/** Copy diff to clipboard */
|
||||
async copyDiff(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.rawDiff());
|
||||
this.copied.set(true);
|
||||
setTimeout(() => this.copied.set(false), 2000);
|
||||
} catch {
|
||||
console.error('Failed to copy diff');
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the viewer */
|
||||
onClose(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @file evidence-confidence-badge.component.spec.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE (UI-011)
|
||||
* @description Unit tests for EvidenceConfidenceBadgeComponent.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { EvidenceConfidenceBadgeComponent } from './evidence-confidence-badge.component';
|
||||
import { getConfidenceTier, CONFIDENCE_TIER_INFO } from '../../models/cyclonedx-evidence.models';
|
||||
|
||||
describe('EvidenceConfidenceBadgeComponent', () => {
|
||||
let component: EvidenceConfidenceBadgeComponent;
|
||||
let fixture: ComponentFixture<EvidenceConfidenceBadgeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidenceConfidenceBadgeComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EvidenceConfidenceBadgeComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('tier calculation', () => {
|
||||
it('should return tier1 for confidence >= 0.9', () => {
|
||||
expect(getConfidenceTier(0.9)).toBe('tier1');
|
||||
expect(getConfidenceTier(0.95)).toBe('tier1');
|
||||
expect(getConfidenceTier(1.0)).toBe('tier1');
|
||||
});
|
||||
|
||||
it('should return tier2 for confidence >= 0.75 and < 0.9', () => {
|
||||
expect(getConfidenceTier(0.75)).toBe('tier2');
|
||||
expect(getConfidenceTier(0.8)).toBe('tier2');
|
||||
expect(getConfidenceTier(0.89)).toBe('tier2');
|
||||
});
|
||||
|
||||
it('should return tier3 for confidence >= 0.5 and < 0.75', () => {
|
||||
expect(getConfidenceTier(0.5)).toBe('tier3');
|
||||
expect(getConfidenceTier(0.6)).toBe('tier3');
|
||||
expect(getConfidenceTier(0.74)).toBe('tier3');
|
||||
});
|
||||
|
||||
it('should return tier4 for confidence >= 0.25 and < 0.5', () => {
|
||||
expect(getConfidenceTier(0.25)).toBe('tier4');
|
||||
expect(getConfidenceTier(0.35)).toBe('tier4');
|
||||
expect(getConfidenceTier(0.49)).toBe('tier4');
|
||||
});
|
||||
|
||||
it('should return tier5 for confidence < 0.25', () => {
|
||||
expect(getConfidenceTier(0.0)).toBe('tier5');
|
||||
expect(getConfidenceTier(0.1)).toBe('tier5');
|
||||
expect(getConfidenceTier(0.24)).toBe('tier5');
|
||||
});
|
||||
|
||||
it('should return tier5 for undefined confidence', () => {
|
||||
expect(getConfidenceTier(undefined)).toBe('tier5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render with default settings', () => {
|
||||
fixture.detectChanges();
|
||||
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
|
||||
expect(badge).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply tier1 class for high confidence', () => {
|
||||
fixture.componentRef.setInput('confidence', 0.95);
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
|
||||
expect(badge.classList.contains('evidence-confidence-badge--tier1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply tier5 class for low confidence', () => {
|
||||
fixture.componentRef.setInput('confidence', 0.1);
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
|
||||
expect(badge.classList.contains('evidence-confidence-badge--tier5')).toBe(true);
|
||||
});
|
||||
|
||||
it('should show percentage when showPercentage is true', () => {
|
||||
fixture.componentRef.setInput('confidence', 0.85);
|
||||
fixture.componentRef.setInput('showPercentage', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const percentSpan = fixture.nativeElement.querySelector('.badge-percent');
|
||||
expect(percentSpan).toBeTruthy();
|
||||
expect(percentSpan.textContent).toBe('85%');
|
||||
});
|
||||
|
||||
it('should show tier label when showTierLabel is true', () => {
|
||||
fixture.componentRef.setInput('confidence', 0.95);
|
||||
fixture.componentRef.setInput('showTierLabel', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const tierSpan = fixture.nativeElement.querySelector('.badge-tier');
|
||||
expect(tierSpan).toBeTruthy();
|
||||
expect(tierSpan.textContent).toBe('Very High');
|
||||
});
|
||||
|
||||
it('should show compact dot when neither label nor percentage shown', () => {
|
||||
fixture.componentRef.setInput('confidence', 0.5);
|
||||
fixture.componentRef.setInput('showTierLabel', false);
|
||||
fixture.componentRef.setInput('showPercentage', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const dot = fixture.nativeElement.querySelector('.badge-dot');
|
||||
expect(dot).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('size variants', () => {
|
||||
it('should apply sm class for small size', () => {
|
||||
fixture.componentRef.setInput('confidence', 0.5);
|
||||
fixture.componentRef.setInput('size', 'sm');
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
|
||||
expect(badge.classList.contains('evidence-confidence-badge--sm')).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply lg class for large size', () => {
|
||||
fixture.componentRef.setInput('confidence', 0.5);
|
||||
fixture.componentRef.setInput('size', 'lg');
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
|
||||
expect(badge.classList.contains('evidence-confidence-badge--lg')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not apply size class for medium (default)', () => {
|
||||
fixture.componentRef.setInput('confidence', 0.5);
|
||||
fixture.componentRef.setInput('size', 'md');
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
|
||||
expect(badge.classList.contains('evidence-confidence-badge--md')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have title attribute with tooltip text', () => {
|
||||
fixture.componentRef.setInput('confidence', 0.95);
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
|
||||
const title = badge.getAttribute('title');
|
||||
expect(title).toContain('Very High');
|
||||
expect(title).toContain('95%');
|
||||
});
|
||||
|
||||
it('should have aria-label', () => {
|
||||
fixture.componentRef.setInput('confidence', 0.75);
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
|
||||
const ariaLabel = badge.getAttribute('aria-label');
|
||||
expect(ariaLabel).toContain('Confidence');
|
||||
expect(ariaLabel).toContain('75 percent');
|
||||
});
|
||||
|
||||
it('should have role="img"', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.evidence-confidence-badge');
|
||||
expect(badge.getAttribute('role')).toBe('img');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom tier label', () => {
|
||||
it('should use custom tier label when provided', () => {
|
||||
fixture.componentRef.setInput('confidence', 0.95);
|
||||
fixture.componentRef.setInput('tierLabel', 'Custom Label');
|
||||
fixture.componentRef.setInput('showTierLabel', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const tierSpan = fixture.nativeElement.querySelector('.badge-tier');
|
||||
expect(tierSpan.textContent).toBe('Custom Label');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CONFIDENCE_TIER_INFO', () => {
|
||||
it('should have info for all tiers', () => {
|
||||
expect(CONFIDENCE_TIER_INFO['tier1']).toBeDefined();
|
||||
expect(CONFIDENCE_TIER_INFO['tier2']).toBeDefined();
|
||||
expect(CONFIDENCE_TIER_INFO['tier3']).toBeDefined();
|
||||
expect(CONFIDENCE_TIER_INFO['tier4']).toBeDefined();
|
||||
expect(CONFIDENCE_TIER_INFO['tier5']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have correct colors for each tier', () => {
|
||||
expect(CONFIDENCE_TIER_INFO['tier1'].color).toBe('green');
|
||||
expect(CONFIDENCE_TIER_INFO['tier2'].color).toBe('yellow-green');
|
||||
expect(CONFIDENCE_TIER_INFO['tier3'].color).toBe('yellow');
|
||||
expect(CONFIDENCE_TIER_INFO['tier4'].color).toBe('orange');
|
||||
expect(CONFIDENCE_TIER_INFO['tier5'].color).toBe('red');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @file evidence-confidence-badge.component.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE (UI-007)
|
||||
* @description Color-coded confidence badge for CycloneDX evidence display.
|
||||
* Shows confidence percentage with tier-based coloring and accessibility support.
|
||||
*/
|
||||
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ConfidenceTier,
|
||||
CONFIDENCE_TIER_INFO,
|
||||
getConfidenceTier,
|
||||
} from '../../models/cyclonedx-evidence.models';
|
||||
|
||||
/**
|
||||
* Confidence badge component for displaying evidence confidence scores.
|
||||
*
|
||||
* Color Scale:
|
||||
* - Tier 1 (90-100%): Green - Authoritative source
|
||||
* - Tier 2 (75-89%): Yellow-Green - Strong evidence
|
||||
* - Tier 3 (50-74%): Yellow - Moderate confidence
|
||||
* - Tier 4 (25-49%): Orange - Weak evidence
|
||||
* - Tier 5 (0-24%): Red - Unknown/unverified
|
||||
*
|
||||
* @example
|
||||
* <app-evidence-confidence-badge [confidence]="0.95" />
|
||||
* <app-evidence-confidence-badge [confidence]="0.75" showPercentage />
|
||||
* <app-evidence-confidence-badge [confidence]="0.50" [tierLabel]="'Tier 3'" />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-evidence-confidence-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<span
|
||||
class="evidence-confidence-badge"
|
||||
[class]="badgeClasses()"
|
||||
[attr.title]="tooltipText()"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
role="img"
|
||||
>
|
||||
@if (showTierLabel()) {
|
||||
<span class="badge-tier">{{ tierInfo().label }}</span>
|
||||
}
|
||||
@if (showPercentage() && confidence() !== undefined) {
|
||||
<span class="badge-percent">{{ percentageText() }}</span>
|
||||
}
|
||||
@if (!showTierLabel() && !showPercentage()) {
|
||||
<span class="badge-dot" [attr.aria-hidden]="true"></span>
|
||||
}
|
||||
</span>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-confidence-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: help;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring, #3b82f6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-tier {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.badge-percent {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.badge-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
// Tier 1: Green (90-100%)
|
||||
.evidence-confidence-badge--tier1 {
|
||||
background: var(--color-confidence-tier1-bg, #dcfce7);
|
||||
color: var(--color-confidence-tier1-text, #15803d);
|
||||
border: 1px solid var(--color-confidence-tier1-border, #86efac);
|
||||
}
|
||||
|
||||
// Tier 2: Yellow-Green (75-89%)
|
||||
.evidence-confidence-badge--tier2 {
|
||||
background: var(--color-confidence-tier2-bg, #ecfccb);
|
||||
color: var(--color-confidence-tier2-text, #4d7c0f);
|
||||
border: 1px solid var(--color-confidence-tier2-border, #bef264);
|
||||
}
|
||||
|
||||
// Tier 3: Yellow (50-74%)
|
||||
.evidence-confidence-badge--tier3 {
|
||||
background: var(--color-confidence-tier3-bg, #fef9c3);
|
||||
color: var(--color-confidence-tier3-text, #a16207);
|
||||
border: 1px solid var(--color-confidence-tier3-border, #fde047);
|
||||
}
|
||||
|
||||
// Tier 4: Orange (25-49%)
|
||||
.evidence-confidence-badge--tier4 {
|
||||
background: var(--color-confidence-tier4-bg, #ffedd5);
|
||||
color: var(--color-confidence-tier4-text, #c2410c);
|
||||
border: 1px solid var(--color-confidence-tier4-border, #fdba74);
|
||||
}
|
||||
|
||||
// Tier 5: Red (0-24%)
|
||||
.evidence-confidence-badge--tier5 {
|
||||
background: var(--color-confidence-tier5-bg, #fee2e2);
|
||||
color: var(--color-confidence-tier5-text, #dc2626);
|
||||
border: 1px solid var(--color-confidence-tier5-border, #fca5a5);
|
||||
}
|
||||
|
||||
// Size variants
|
||||
.evidence-confidence-badge--sm {
|
||||
padding: 0.0625rem 0.375rem;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.evidence-confidence-badge--lg {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
// Compact (dot only)
|
||||
.evidence-confidence-badge--compact {
|
||||
padding: 0.25rem;
|
||||
min-width: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class EvidenceConfidenceBadgeComponent {
|
||||
/**
|
||||
* Confidence score (0-1).
|
||||
*/
|
||||
readonly confidence = input<number | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Show percentage value.
|
||||
*/
|
||||
readonly showPercentage = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* Show tier label (e.g., "Very High", "High", etc.).
|
||||
*/
|
||||
readonly showTierLabel = input<boolean>(true);
|
||||
|
||||
/**
|
||||
* Size variant.
|
||||
*/
|
||||
readonly size = input<'sm' | 'md' | 'lg'>('md');
|
||||
|
||||
/**
|
||||
* Custom tier label override.
|
||||
*/
|
||||
readonly tierLabel = input<string | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Computed confidence tier.
|
||||
*/
|
||||
readonly tier = computed<ConfidenceTier>(() => getConfidenceTier(this.confidence()));
|
||||
|
||||
/**
|
||||
* Tier info for display.
|
||||
*/
|
||||
readonly tierInfo = computed(() => {
|
||||
const tier = this.tier();
|
||||
const info = CONFIDENCE_TIER_INFO[tier];
|
||||
return {
|
||||
...info,
|
||||
label: this.tierLabel() ?? info.label,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Badge CSS classes.
|
||||
*/
|
||||
readonly badgeClasses = computed(() => {
|
||||
const tier = this.tier();
|
||||
const size = this.size();
|
||||
const isCompact = !this.showTierLabel() && !this.showPercentage();
|
||||
|
||||
return [
|
||||
`evidence-confidence-badge--${tier}`,
|
||||
size !== 'md' ? `evidence-confidence-badge--${size}` : '',
|
||||
isCompact ? 'evidence-confidence-badge--compact' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
});
|
||||
|
||||
/**
|
||||
* Percentage text (e.g., "95%").
|
||||
*/
|
||||
readonly percentageText = computed(() => {
|
||||
const conf = this.confidence();
|
||||
if (conf === undefined) return '';
|
||||
return `${Math.round(conf * 100)}%`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Tooltip text with full explanation.
|
||||
*/
|
||||
readonly tooltipText = computed(() => {
|
||||
const info = this.tierInfo();
|
||||
const conf = this.confidence();
|
||||
const percent = conf !== undefined ? ` (${Math.round(conf * 100)}%)` : '';
|
||||
return `${info.label}${percent}: ${info.description}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* ARIA label for accessibility.
|
||||
*/
|
||||
readonly ariaLabel = computed(() => {
|
||||
const info = this.tierInfo();
|
||||
const conf = this.confidence();
|
||||
const percent = conf !== undefined ? `, ${Math.round(conf * 100)} percent` : '';
|
||||
return `Confidence: ${info.label}${percent}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @file evidence-detail-drawer.component.spec.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE (UI-011)
|
||||
* @description Unit tests for EvidenceDetailDrawerComponent.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { EvidenceDetailDrawerComponent } from './evidence-detail-drawer.component';
|
||||
import { ComponentEvidence, OccurrenceEvidence } from '../../models/cyclonedx-evidence.models';
|
||||
|
||||
describe('EvidenceDetailDrawerComponent', () => {
|
||||
let component: EvidenceDetailDrawerComponent;
|
||||
let fixture: ComponentFixture<EvidenceDetailDrawerComponent>;
|
||||
|
||||
const mockEvidence: ComponentEvidence = {
|
||||
identity: {
|
||||
field: 'purl',
|
||||
confidence: 0.95,
|
||||
methods: [
|
||||
{ technique: 'manifest-analysis', confidence: 0.95, value: 'package.json' },
|
||||
{ technique: 'hash-comparison', confidence: 0.90 },
|
||||
],
|
||||
tools: ['scanner-v1', 'analyzer-v2'],
|
||||
},
|
||||
occurrences: [
|
||||
{ location: '/node_modules/lodash/index.js', line: 1 },
|
||||
{ location: '/node_modules/lodash/package.json' },
|
||||
],
|
||||
licenses: [
|
||||
{
|
||||
license: { id: 'MIT', url: 'https://opensource.org/licenses/MIT' },
|
||||
acknowledgement: 'declared',
|
||||
},
|
||||
],
|
||||
copyright: [
|
||||
{ text: 'Copyright (c) JS Foundation and other contributors' },
|
||||
],
|
||||
};
|
||||
|
||||
const mockOccurrence: OccurrenceEvidence = {
|
||||
location: '/node_modules/lodash/index.js',
|
||||
line: 42,
|
||||
symbol: 'debounce',
|
||||
additionalContext: 'Imported in main module',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EvidenceDetailDrawerComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EvidenceDetailDrawerComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('drawer visibility', () => {
|
||||
it('should not render when open is false', () => {
|
||||
fixture.componentRef.setInput('open', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const overlay = fixture.nativeElement.querySelector('.drawer-overlay');
|
||||
expect(overlay).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render when open is true', () => {
|
||||
fixture.componentRef.setInput('open', true);
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
|
||||
const overlay = fixture.nativeElement.querySelector('.drawer-overlay');
|
||||
expect(overlay).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('evidence display', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('open', true);
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display identity field', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('PURL');
|
||||
});
|
||||
|
||||
it('should display detection methods', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('Manifest Analysis');
|
||||
expect(content).toContain('Hash Comparison');
|
||||
});
|
||||
|
||||
it('should display tools used', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('scanner-v1');
|
||||
expect(content).toContain('analyzer-v2');
|
||||
});
|
||||
|
||||
it('should display license information', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('MIT');
|
||||
expect(content).toContain('declared');
|
||||
});
|
||||
|
||||
it('should display copyright information', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('JS Foundation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('occurrence display', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('open', true);
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.componentRef.setInput('selectedOccurrence', mockOccurrence);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display occurrence location', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('/node_modules/lodash/index.js');
|
||||
});
|
||||
|
||||
it('should display line number', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('42');
|
||||
});
|
||||
|
||||
it('should display symbol', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('debounce');
|
||||
});
|
||||
|
||||
it('should display additional context', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('Imported in main module');
|
||||
});
|
||||
});
|
||||
|
||||
describe('close functionality', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('open', true);
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should emit closeDrawer when close button clicked', () => {
|
||||
const closeSpy = jest.spyOn(component.closeDrawer, 'emit');
|
||||
const closeBtn = fixture.nativeElement.querySelector('.drawer-close-btn');
|
||||
closeBtn.click();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit closeDrawer when overlay clicked', () => {
|
||||
const closeSpy = jest.spyOn(component.closeDrawer, 'emit');
|
||||
const overlay = fixture.nativeElement.querySelector('.drawer-overlay');
|
||||
overlay.click();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit closeDrawer on escape key', () => {
|
||||
const closeSpy = jest.spyOn(component.closeDrawer, 'emit');
|
||||
component.onEscapeKey();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy functionality', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('open', true);
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Mock clipboard API
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy value to clipboard', async () => {
|
||||
await component.copyToClipboard('test-value');
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value');
|
||||
});
|
||||
|
||||
it('should set copiedValue after successful copy', async () => {
|
||||
await component.copyToClipboard('test-value');
|
||||
expect(component.copiedValue()).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should copy evidence reference', async () => {
|
||||
await component.copyEvidenceRef();
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalled();
|
||||
expect(component.copiedRef()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('open', true);
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should have role="dialog"', () => {
|
||||
const overlay = fixture.nativeElement.querySelector('.drawer-overlay');
|
||||
expect(overlay.getAttribute('role')).toBe('dialog');
|
||||
});
|
||||
|
||||
it('should have aria-modal="true"', () => {
|
||||
const overlay = fixture.nativeElement.querySelector('.drawer-overlay');
|
||||
expect(overlay.getAttribute('aria-modal')).toBe('true');
|
||||
});
|
||||
|
||||
it('should have accessible close button', () => {
|
||||
const closeBtn = fixture.nativeElement.querySelector('.drawer-close-btn');
|
||||
expect(closeBtn.getAttribute('aria-label')).toBe('Close drawer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should show empty message when no evidence', () => {
|
||||
fixture.componentRef.setInput('open', true);
|
||||
fixture.componentRef.setInput('evidence', undefined);
|
||||
fixture.detectChanges();
|
||||
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('No evidence data available');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,864 @@
|
||||
/**
|
||||
* @file evidence-detail-drawer.component.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE (UI-002)
|
||||
* @description Full-screen drawer for evidence details display.
|
||||
* Shows detection method chain, source file content, and copy-to-clipboard.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
inject,
|
||||
HostListener,
|
||||
ElementRef,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { CommonModule, DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
ComponentEvidence,
|
||||
IdentityEvidence,
|
||||
OccurrenceEvidence,
|
||||
getIdentityTechniqueLabel,
|
||||
} from '../../models/cyclonedx-evidence.models';
|
||||
import { EvidenceConfidenceBadgeComponent } from '../evidence-confidence-badge/evidence-confidence-badge.component';
|
||||
|
||||
/**
|
||||
* Evidence detail drawer component for full-screen evidence exploration.
|
||||
*
|
||||
* Features:
|
||||
* - Full-screen drawer for evidence details
|
||||
* - Show detection method chain
|
||||
* - Show source file content (if available)
|
||||
* - Copy-to-clipboard for evidence references
|
||||
* - Close on escape key
|
||||
*
|
||||
* @example
|
||||
* <app-evidence-detail-drawer
|
||||
* [open]="showDrawer"
|
||||
* [evidence]="selectedEvidence"
|
||||
* [occurrence]="selectedOccurrence"
|
||||
* (closeDrawer)="onCloseDrawer()"
|
||||
* />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-evidence-detail-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EvidenceConfidenceBadgeComponent],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div
|
||||
class="drawer-overlay"
|
||||
(click)="onOverlayClick($event)"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-label]="'Evidence details for ' + (evidence()?.identity?.field ?? 'component')"
|
||||
>
|
||||
<aside
|
||||
class="drawer-panel"
|
||||
[class.drawer-panel--open]="open()"
|
||||
role="document"
|
||||
#drawerPanel
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="drawer-header">
|
||||
<h2 class="drawer-title">Evidence Details</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="drawer-close-btn"
|
||||
(click)="onClose()"
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="drawer-content">
|
||||
@if (evidence(); as ev) {
|
||||
<!-- Identity Section -->
|
||||
@if (ev.identity) {
|
||||
<section class="detail-section">
|
||||
<h3 class="detail-section__title">Identity Evidence</h3>
|
||||
<div class="identity-detail">
|
||||
<div class="identity-detail__row">
|
||||
<span class="identity-detail__label">Field:</span>
|
||||
<span class="identity-detail__value">{{ ev.identity.field | uppercase }}</span>
|
||||
</div>
|
||||
<div class="identity-detail__row">
|
||||
<span class="identity-detail__label">Confidence:</span>
|
||||
<app-evidence-confidence-badge
|
||||
[confidence]="ev.identity.confidence"
|
||||
[showPercentage]="true"
|
||||
[showTierLabel]="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detection Method Chain -->
|
||||
@if (ev.identity.methods && ev.identity.methods.length > 0) {
|
||||
<div class="method-chain">
|
||||
<h4 class="method-chain__title">Detection Method Chain</h4>
|
||||
<ol class="method-chain__list">
|
||||
@for (method of ev.identity.methods; track method.technique; let i = $index) {
|
||||
<li class="method-chain__item">
|
||||
<span class="method-chain__step">{{ i + 1 }}</span>
|
||||
<div class="method-chain__content">
|
||||
<span class="method-chain__technique">
|
||||
{{ getTechniqueLabel(method.technique) }}
|
||||
</span>
|
||||
@if (method.value) {
|
||||
<code class="method-chain__value">{{ method.value }}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
(click)="copyToClipboard(method.value)"
|
||||
[attr.aria-label]="'Copy ' + method.value"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
||||
</svg>
|
||||
{{ copiedValue() === method.value ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
}
|
||||
<div class="method-chain__confidence">
|
||||
Confidence: {{ (method.confidence * 100) | number:'1.0-0' }}%
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Tools Used -->
|
||||
@if (ev.identity.tools && ev.identity.tools.length > 0) {
|
||||
<div class="tools-section">
|
||||
<h4 class="tools-section__title">Tools Used</h4>
|
||||
<div class="tools-list">
|
||||
@for (tool of ev.identity.tools; track tool) {
|
||||
<span class="tool-badge">{{ tool }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Occurrence Details -->
|
||||
@if (selectedOccurrence(); as occ) {
|
||||
<section class="detail-section">
|
||||
<h3 class="detail-section__title">Occurrence Details</h3>
|
||||
<div class="occurrence-detail">
|
||||
<div class="occurrence-detail__row">
|
||||
<span class="occurrence-detail__label">Location:</span>
|
||||
<code class="occurrence-detail__value">{{ occ.location }}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
(click)="copyToClipboard(occ.location)"
|
||||
[attr.aria-label]="'Copy location'"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
||||
</svg>
|
||||
{{ copiedValue() === occ.location ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
@if (occ.line) {
|
||||
<div class="occurrence-detail__row">
|
||||
<span class="occurrence-detail__label">Line:</span>
|
||||
<span class="occurrence-detail__value">{{ occ.line }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (occ.offset) {
|
||||
<div class="occurrence-detail__row">
|
||||
<span class="occurrence-detail__label">Offset:</span>
|
||||
<span class="occurrence-detail__value">{{ occ.offset }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (occ.symbol) {
|
||||
<div class="occurrence-detail__row">
|
||||
<span class="occurrence-detail__label">Symbol:</span>
|
||||
<code class="occurrence-detail__value">{{ occ.symbol }}</code>
|
||||
</div>
|
||||
}
|
||||
@if (occ.additionalContext) {
|
||||
<div class="occurrence-detail__row occurrence-detail__row--full">
|
||||
<span class="occurrence-detail__label">Context:</span>
|
||||
<p class="occurrence-detail__context">{{ occ.additionalContext }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Source File Content Preview -->
|
||||
@if (sourceContent()) {
|
||||
<div class="source-preview">
|
||||
<h4 class="source-preview__title">Source Preview</h4>
|
||||
<pre class="source-preview__code"><code>{{ sourceContent() }}</code></pre>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- All Occurrences Summary -->
|
||||
@if (ev.occurrences && ev.occurrences.length > 1 && !selectedOccurrence()) {
|
||||
<section class="detail-section">
|
||||
<h3 class="detail-section__title">All Occurrences ({{ ev.occurrences.length }})</h3>
|
||||
<ul class="occurrences-summary">
|
||||
@for (occ of ev.occurrences; track occ.location) {
|
||||
<li class="occurrence-summary-item">
|
||||
<code>{{ occ.location }}</code>
|
||||
@if (occ.line) {
|
||||
<span class="occurrence-line">:{{ occ.line }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Licenses Section -->
|
||||
@if (ev.licenses && ev.licenses.length > 0) {
|
||||
<section class="detail-section">
|
||||
<h3 class="detail-section__title">License Evidence</h3>
|
||||
<ul class="licenses-detail">
|
||||
@for (lic of ev.licenses; track lic.license.id ?? lic.license.name) {
|
||||
<li class="license-detail-item">
|
||||
<span class="license-id">{{ lic.license.id ?? lic.license.name }}</span>
|
||||
<span class="license-ack" [class]="'ack--' + lic.acknowledgement">
|
||||
{{ lic.acknowledgement }}
|
||||
</span>
|
||||
@if (lic.license.url) {
|
||||
<a
|
||||
class="license-link"
|
||||
[href]="lic.license.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View License
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Copyright Section -->
|
||||
@if (ev.copyright && ev.copyright.length > 0) {
|
||||
<section class="detail-section">
|
||||
<h3 class="detail-section__title">Copyright Evidence</h3>
|
||||
<ul class="copyright-detail">
|
||||
@for (cr of ev.copyright; track cr.text) {
|
||||
<li class="copyright-item">{{ cr.text }}</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
} @else {
|
||||
<div class="drawer-empty">
|
||||
<p>No evidence data available.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="drawer-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="drawer-btn drawer-btn--secondary"
|
||||
(click)="onClose()"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@if (evidence()?.identity) {
|
||||
<button
|
||||
type="button"
|
||||
class="drawer-btn drawer-btn--primary"
|
||||
(click)="copyEvidenceRef()"
|
||||
>
|
||||
{{ copiedRef() ? 'Copied!' : 'Copy Reference' }}
|
||||
</button>
|
||||
}
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
height: 100%;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-default, #e5e7eb);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.drawer-close-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--focus-ring, #3b82f6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-default, #e5e7eb);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--focus-ring, #3b82f6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-btn--primary {
|
||||
background: var(--color-primary, #2563eb);
|
||||
color: white;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-hover, #1d4ed8);
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-btn--secondary {
|
||||
background: transparent;
|
||||
color: var(--text-primary, #111827);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Detail Sections */
|
||||
.detail-section {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-section__title {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Identity Detail */
|
||||
.identity-detail {
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.identity-detail__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.identity-detail__label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.identity-detail__value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
/* Method Chain */
|
||||
.method-chain {
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.method-chain__title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.method-chain__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.method-chain__item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-light, #e5e7eb);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.method-chain__step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: var(--color-primary, #2563eb);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.method-chain__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.method-chain__technique {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.method-chain__value {
|
||||
background: var(--code-bg, #e5e7eb);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.method-chain__confidence {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Copy Button */
|
||||
.copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-link, #2563eb);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
border-color: var(--text-link, #2563eb);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tools Section */
|
||||
.tools-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tools-section__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.tools-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.tool-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--surface-tertiary, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
/* Occurrence Detail */
|
||||
.occurrence-detail {
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.occurrence-detail__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&--full {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.occurrence-detail__label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.occurrence-detail__value {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.occurrence-detail__context {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #111827);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Source Preview */
|
||||
.source-preview {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.source-preview__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.source-preview__code {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background: var(--code-bg, #1f2937);
|
||||
color: var(--code-text, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Occurrences Summary */
|
||||
.occurrences-summary {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.occurrence-summary-item {
|
||||
padding: 0.5rem;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.occurrence-line {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
/* Licenses Detail */
|
||||
.licenses-detail {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.license-detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.license-id {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.license-ack {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
|
||||
&.ack--declared {
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
color: var(--color-info-text, #1d4ed8);
|
||||
}
|
||||
|
||||
&.ack--concluded {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
color: var(--color-success-text, #15803d);
|
||||
}
|
||||
}
|
||||
|
||||
.license-link {
|
||||
margin-left: auto;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-link, #2563eb);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Copyright Detail */
|
||||
.copyright-detail {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.copyright-item {
|
||||
padding: 0.75rem;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.drawer-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.drawer-panel {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class EvidenceDetailDrawerComponent implements OnInit, OnDestroy {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
/** Whether the drawer is open */
|
||||
readonly open = input<boolean>(false);
|
||||
|
||||
/** Evidence data to display */
|
||||
readonly evidence = input<ComponentEvidence | undefined>(undefined);
|
||||
|
||||
/** Selected occurrence for detail view */
|
||||
readonly selectedOccurrence = input<OccurrenceEvidence | undefined>(undefined);
|
||||
|
||||
/** Source file content (optional) */
|
||||
readonly sourceContent = input<string | undefined>(undefined);
|
||||
|
||||
/** Emits when drawer should close */
|
||||
readonly closeDrawer = output<void>();
|
||||
|
||||
/** Currently copied value for feedback */
|
||||
readonly copiedValue = signal<string | null>(null);
|
||||
|
||||
/** Whether reference was copied */
|
||||
readonly copiedRef = signal<boolean>(false);
|
||||
|
||||
/** Handle escape key to close drawer */
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
if (this.open()) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Lock body scroll when drawer opens
|
||||
if (this.open()) {
|
||||
this.lockBodyScroll();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unlockBodyScroll();
|
||||
}
|
||||
|
||||
/** Lock body scroll */
|
||||
private lockBodyScroll(): void {
|
||||
this.document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
/** Unlock body scroll */
|
||||
private unlockBodyScroll(): void {
|
||||
this.document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
/** Handle overlay click */
|
||||
onOverlayClick(event: MouseEvent): void {
|
||||
if ((event.target as HTMLElement).classList.contains('drawer-overlay')) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the drawer */
|
||||
onClose(): void {
|
||||
this.unlockBodyScroll();
|
||||
this.closeDrawer.emit();
|
||||
}
|
||||
|
||||
/** Get technique label */
|
||||
getTechniqueLabel(technique: string): string {
|
||||
return getIdentityTechniqueLabel(technique as Parameters<typeof getIdentityTechniqueLabel>[0]);
|
||||
}
|
||||
|
||||
/** Copy value to clipboard */
|
||||
async copyToClipboard(value: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
this.copiedValue.set(value);
|
||||
setTimeout(() => this.copiedValue.set(null), 2000);
|
||||
} catch {
|
||||
console.error('Failed to copy to clipboard');
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy full evidence reference */
|
||||
async copyEvidenceRef(): Promise<void> {
|
||||
const ev = this.evidence();
|
||||
if (!ev?.identity) return;
|
||||
|
||||
const ref = JSON.stringify(
|
||||
{
|
||||
field: ev.identity.field,
|
||||
confidence: ev.identity.confidence,
|
||||
methods: ev.identity.methods?.map((m) => m.technique),
|
||||
tools: ev.identity.tools,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(ref);
|
||||
this.copiedRef.set(true);
|
||||
setTimeout(() => this.copiedRef.set(false), 2000);
|
||||
} catch {
|
||||
console.error('Failed to copy reference');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE
|
||||
* @description Public API for SBOM components.
|
||||
*/
|
||||
|
||||
export { CdxEvidencePanelComponent } from './cdx-evidence-panel/cdx-evidence-panel.component';
|
||||
export { EvidenceConfidenceBadgeComponent } from './evidence-confidence-badge/evidence-confidence-badge.component';
|
||||
export { PedigreeTimelineComponent } from './pedigree-timeline/pedigree-timeline.component';
|
||||
export { PatchListComponent, ViewDiffEvent } from './patch-list/patch-list.component';
|
||||
export { EvidenceDetailDrawerComponent } from './evidence-detail-drawer/evidence-detail-drawer.component';
|
||||
export { DiffViewerComponent } from './diff-viewer/diff-viewer.component';
|
||||
export { CommitInfoComponent } from './commit-info/commit-info.component';
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* @file patch-list.component.spec.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE (UI-011)
|
||||
* @description Unit tests for PatchListComponent.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { PatchListComponent } from './patch-list.component';
|
||||
import { ComponentPedigree, getPatchTypeLabel, getPatchBadgeColor } from '../../models/cyclonedx-evidence.models';
|
||||
|
||||
describe('PatchListComponent', () => {
|
||||
let component: PatchListComponent;
|
||||
let fixture: ComponentFixture<PatchListComponent>;
|
||||
|
||||
const mockPedigree: ComponentPedigree = {
|
||||
patches: [
|
||||
{
|
||||
type: 'backport',
|
||||
diff: { url: 'https://github.com/openssl/openssl/commit/abc123.patch' },
|
||||
resolves: [
|
||||
{ id: 'CVE-2024-1234', type: 'security', name: 'Buffer overflow vulnerability' },
|
||||
{ id: 'CVE-2024-5678', type: 'security' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'cherry-pick',
|
||||
resolves: [{ id: 'CVE-2024-9999', type: 'security' }],
|
||||
},
|
||||
{
|
||||
type: 'monkey',
|
||||
resolves: [],
|
||||
},
|
||||
],
|
||||
commits: [
|
||||
{
|
||||
uid: 'abc123def456789',
|
||||
url: 'https://github.com/openssl/openssl/commit/abc123def456789',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PatchListComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PatchListComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should create', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display header with patch count', () => {
|
||||
fixture.componentRef.setInput('pedigree', mockPedigree);
|
||||
fixture.detectChanges();
|
||||
|
||||
const header = fixture.nativeElement.querySelector('.patch-list__title');
|
||||
expect(header.textContent).toContain('Patches Applied (3)');
|
||||
});
|
||||
|
||||
it('should show empty state when no patches', () => {
|
||||
fixture.componentRef.setInput('pedigree', { patches: [] });
|
||||
fixture.detectChanges();
|
||||
|
||||
const empty = fixture.nativeElement.querySelector('.patch-list__empty');
|
||||
expect(empty).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show empty state when pedigree undefined', () => {
|
||||
fixture.componentRef.setInput('pedigree', undefined);
|
||||
fixture.detectChanges();
|
||||
|
||||
const empty = fixture.nativeElement.querySelector('.patch-list__empty');
|
||||
expect(empty).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('patch items', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('pedigree', mockPedigree);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should render all patches', () => {
|
||||
const items = fixture.nativeElement.querySelectorAll('.patch-item');
|
||||
expect(items.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should display type badges', () => {
|
||||
const badges = fixture.nativeElement.querySelectorAll('.patch-badge');
|
||||
expect(badges.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should apply correct class for backport badge', () => {
|
||||
const backportBadge = fixture.nativeElement.querySelector('.patch-badge--backport');
|
||||
expect(backportBadge).toBeTruthy();
|
||||
expect(backportBadge.textContent).toBe('Backport');
|
||||
});
|
||||
|
||||
it('should apply correct class for cherry-pick badge', () => {
|
||||
const cherryPickBadge = fixture.nativeElement.querySelector('.patch-badge--cherry-pick');
|
||||
expect(cherryPickBadge).toBeTruthy();
|
||||
expect(cherryPickBadge.textContent).toBe('Cherry-pick');
|
||||
});
|
||||
|
||||
it('should apply correct class for monkey patch badge', () => {
|
||||
const monkeyBadge = fixture.nativeElement.querySelector('.patch-badge--monkey');
|
||||
expect(monkeyBadge).toBeTruthy();
|
||||
expect(monkeyBadge.textContent).toBe('Monkey Patch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CVE tags', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('pedigree', mockPedigree);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display CVE tags for resolved issues', () => {
|
||||
const cveTags = fixture.nativeElement.querySelectorAll('.cve-tag');
|
||||
expect(cveTags.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should limit displayed CVEs to 3 with "more" indicator', () => {
|
||||
const pedigreeWithManyCves: ComponentPedigree = {
|
||||
patches: [
|
||||
{
|
||||
type: 'backport',
|
||||
resolves: [
|
||||
{ id: 'CVE-2024-0001', type: 'security' },
|
||||
{ id: 'CVE-2024-0002', type: 'security' },
|
||||
{ id: 'CVE-2024-0003', type: 'security' },
|
||||
{ id: 'CVE-2024-0004', type: 'security' },
|
||||
{ id: 'CVE-2024-0005', type: 'security' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
fixture.componentRef.setInput('pedigree', pedigreeWithManyCves);
|
||||
fixture.detectChanges();
|
||||
|
||||
const moreTags = fixture.nativeElement.querySelectorAll('.cve-tag--more');
|
||||
expect(moreTags.length).toBe(1);
|
||||
expect(moreTags[0].textContent).toContain('+2 more');
|
||||
});
|
||||
});
|
||||
|
||||
describe('diff button', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('pedigree', mockPedigree);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show Diff button when diff URL available', () => {
|
||||
const diffBtns = fixture.nativeElement.querySelectorAll('.patch-action-btn');
|
||||
expect(diffBtns.length).toBe(1); // Only first patch has diff
|
||||
});
|
||||
|
||||
it('should emit viewDiff when Diff button clicked', () => {
|
||||
const emitSpy = jest.spyOn(component.viewDiff, 'emit');
|
||||
|
||||
const diffBtn = fixture.nativeElement.querySelector('.patch-action-btn');
|
||||
diffBtn.click();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalled();
|
||||
const emittedEvent = emitSpy.mock.calls[0][0];
|
||||
expect(emittedEvent.diffUrl).toBe('https://github.com/openssl/openssl/commit/abc123.patch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expand/collapse', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('pedigree', mockPedigree);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should start collapsed', () => {
|
||||
expect(component.isExpanded(0)).toBe(false);
|
||||
expect(component.isExpanded(1)).toBe(false);
|
||||
expect(component.isExpanded(2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should expand on toggle', () => {
|
||||
component.toggleExpand(0);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isExpanded(0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should collapse on second toggle', () => {
|
||||
component.toggleExpand(0);
|
||||
component.toggleExpand(0);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isExpanded(0)).toBe(false);
|
||||
});
|
||||
|
||||
it('should show details when expanded', () => {
|
||||
component.toggleExpand(0);
|
||||
fixture.detectChanges();
|
||||
|
||||
const details = fixture.nativeElement.querySelector('.patch-item__details');
|
||||
expect(details).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show resolved issues list when expanded', () => {
|
||||
component.toggleExpand(0);
|
||||
fixture.detectChanges();
|
||||
|
||||
const resolvedList = fixture.nativeElement.querySelector('.resolved-list');
|
||||
expect(resolvedList).toBeTruthy();
|
||||
|
||||
const resolvedItems = fixture.nativeElement.querySelectorAll('.resolved-item');
|
||||
expect(resolvedItems.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confidence badges', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('pedigree', mockPedigree);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show confidence badge with default values', () => {
|
||||
const badges = fixture.nativeElement.querySelectorAll('app-evidence-confidence-badge');
|
||||
expect(badges.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should use custom confidence from patchConfidences map', () => {
|
||||
const customConfidences = new Map<number, number>();
|
||||
customConfidences.set(0, 0.99);
|
||||
|
||||
fixture.componentRef.setInput('patchConfidences', customConfidences);
|
||||
fixture.detectChanges();
|
||||
|
||||
const conf = component.getCommitConfidence(mockPedigree.patches![0]);
|
||||
expect(conf).toBe(0.99);
|
||||
});
|
||||
|
||||
it('should return default confidence based on patch type', () => {
|
||||
const backportConf = component.getCommitConfidence(mockPedigree.patches![0]);
|
||||
const cherryPickConf = component.getCommitConfidence(mockPedigree.patches![1]);
|
||||
const monkeyConf = component.getCommitConfidence(mockPedigree.patches![2]);
|
||||
|
||||
expect(backportConf).toBe(0.95);
|
||||
expect(cherryPickConf).toBe(0.80);
|
||||
expect(monkeyConf).toBe(0.50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('pedigree', mockPedigree);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should have aria-label on patch list section', () => {
|
||||
const section = fixture.nativeElement.querySelector('.patch-list');
|
||||
expect(section.getAttribute('aria-label')).toBe('Patches applied');
|
||||
});
|
||||
|
||||
it('should have role="list" on items container', () => {
|
||||
const list = fixture.nativeElement.querySelector('.patch-list__items');
|
||||
expect(list.getAttribute('role')).toBe('list');
|
||||
});
|
||||
|
||||
it('should have aria-expanded on expand buttons', () => {
|
||||
const expandBtn = fixture.nativeElement.querySelector('.patch-expand-btn');
|
||||
expect(expandBtn.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
component.toggleExpand(0);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(expandBtn.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('should have aria-label on expand buttons', () => {
|
||||
const expandBtn = fixture.nativeElement.querySelector('.patch-expand-btn');
|
||||
expect(expandBtn.getAttribute('aria-label')).toContain('Expand patch details');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPatchTypeLabel', () => {
|
||||
it('should return correct label for backport', () => {
|
||||
expect(getPatchTypeLabel('backport')).toBe('Backport');
|
||||
});
|
||||
|
||||
it('should return correct label for cherry-pick', () => {
|
||||
expect(getPatchTypeLabel('cherry-pick')).toBe('Cherry-pick');
|
||||
});
|
||||
|
||||
it('should return correct label for monkey', () => {
|
||||
expect(getPatchTypeLabel('monkey')).toBe('Monkey Patch');
|
||||
});
|
||||
|
||||
it('should return correct label for unofficial', () => {
|
||||
expect(getPatchTypeLabel('unofficial')).toBe('Unofficial');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPatchBadgeColor', () => {
|
||||
it('should return green for backport', () => {
|
||||
expect(getPatchBadgeColor('backport')).toBe('green');
|
||||
});
|
||||
|
||||
it('should return blue for cherry-pick', () => {
|
||||
expect(getPatchBadgeColor('cherry-pick')).toBe('blue');
|
||||
});
|
||||
|
||||
it('should return orange for monkey', () => {
|
||||
expect(getPatchBadgeColor('monkey')).toBe('orange');
|
||||
});
|
||||
|
||||
it('should return purple for unofficial', () => {
|
||||
expect(getPatchBadgeColor('unofficial')).toBe('purple');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* @file patch-list.component.ts
|
||||
* @sprint SPRINT_20260107_005_004_FE (UI-004)
|
||||
* @description List component for displaying CycloneDX 1.7 pedigree patches.
|
||||
* Shows patch type badges, resolved CVEs, confidence scores, and diff previews.
|
||||
*/
|
||||
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ComponentPedigree,
|
||||
PedigreePatch,
|
||||
PatchType,
|
||||
ConfidenceTier,
|
||||
getConfidenceTier,
|
||||
getPatchBadgeColor,
|
||||
getPatchTypeLabel,
|
||||
CONFIDENCE_TIER_INFO,
|
||||
} from '../../models/cyclonedx-evidence.models';
|
||||
import { EvidenceConfidenceBadgeComponent } from '../evidence-confidence-badge/evidence-confidence-badge.component';
|
||||
|
||||
/**
|
||||
* Event emitted when user wants to view a patch diff.
|
||||
*/
|
||||
export interface ViewDiffEvent {
|
||||
readonly patch: PedigreePatch;
|
||||
readonly diffUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch list component for displaying pedigree patches.
|
||||
*
|
||||
* Features:
|
||||
* - List patches with type badges (backport, cherry-pick)
|
||||
* - Show resolved CVEs per patch
|
||||
* - Show confidence score with tier explanation
|
||||
* - Expand to show diff preview
|
||||
* - Link to full diff viewer
|
||||
*
|
||||
* @example
|
||||
* <app-patch-list
|
||||
* [pedigree]="component.pedigree"
|
||||
* (viewDiff)="onViewDiff($event)"
|
||||
* />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-patch-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EvidenceConfidenceBadgeComponent],
|
||||
template: `
|
||||
<section class="patch-list" [attr.aria-label]="'Patches applied'">
|
||||
<header class="patch-list__header">
|
||||
<h4 class="patch-list__title">Patches Applied ({{ patches().length }})</h4>
|
||||
</header>
|
||||
|
||||
@if (patches().length > 0) {
|
||||
<ul class="patch-list__items" role="list">
|
||||
@for (patch of patches(); track $index; let i = $index) {
|
||||
<li class="patch-item" [class.expanded]="isExpanded(i)">
|
||||
<div class="patch-item__header">
|
||||
<!-- Type Badge -->
|
||||
<span
|
||||
class="patch-badge"
|
||||
[class]="'patch-badge--' + patch.type"
|
||||
>
|
||||
{{ getTypeLabel(patch.type) }}
|
||||
</span>
|
||||
|
||||
<!-- Resolved CVEs -->
|
||||
@if (patch.resolves && patch.resolves.length > 0) {
|
||||
<div class="patch-item__cves">
|
||||
@for (resolve of patch.resolves.slice(0, 3); track resolve.id) {
|
||||
<span class="cve-tag">{{ resolve.id }}</span>
|
||||
}
|
||||
@if (patch.resolves.length > 3) {
|
||||
<span class="cve-tag cve-tag--more">
|
||||
+{{ patch.resolves.length - 3 }} more
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Confidence Badge (if commit has confidence) -->
|
||||
@if (getCommitConfidence(patch) !== undefined) {
|
||||
<app-evidence-confidence-badge
|
||||
[confidence]="getCommitConfidence(patch)"
|
||||
[showPercentage]="true"
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="patch-item__actions">
|
||||
@if (hasDiff(patch)) {
|
||||
<button
|
||||
type="button"
|
||||
class="patch-action-btn"
|
||||
(click)="onViewDiff(patch)"
|
||||
[attr.aria-label]="'View diff for patch ' + (i + 1)"
|
||||
>
|
||||
Diff
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="patch-expand-btn"
|
||||
[attr.aria-expanded]="isExpanded(i)"
|
||||
(click)="toggleExpand(i)"
|
||||
[attr.aria-label]="(isExpanded(i) ? 'Collapse' : 'Expand') + ' patch details'"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
[class.rotated]="isExpanded(i)"
|
||||
>
|
||||
<path d="M4.5 6L8 9.5L11.5 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Details -->
|
||||
@if (isExpanded(i)) {
|
||||
<div class="patch-item__details">
|
||||
<!-- Commit Info -->
|
||||
@if (getCommitForPatch(patch); as commit) {
|
||||
<div class="patch-detail">
|
||||
<span class="patch-detail__label">Commit:</span>
|
||||
<code class="patch-detail__value">{{ commit.uid.slice(0, 7) }}</code>
|
||||
@if (commit.url) {
|
||||
<a
|
||||
class="patch-detail__link"
|
||||
[href]="commit.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
[attr.aria-label]="'View commit ' + commit.uid.slice(0, 7)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M10 2H14V6M14 2L7 9M12 9V14H2V4H7" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Confidence Tier Explanation -->
|
||||
@if (getCommitConfidence(patch); as conf) {
|
||||
<div class="patch-detail">
|
||||
<span class="patch-detail__label">Confidence:</span>
|
||||
<span class="patch-detail__value">
|
||||
{{ getTierDescription(conf) }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- All Resolved Issues -->
|
||||
@if (patch.resolves && patch.resolves.length > 0) {
|
||||
<div class="patch-detail patch-detail--full">
|
||||
<span class="patch-detail__label">Resolves:</span>
|
||||
<ul class="resolved-list">
|
||||
@for (resolve of patch.resolves; track resolve.id) {
|
||||
<li class="resolved-item">
|
||||
<span class="resolved-item__id">{{ resolve.id }}</span>
|
||||
@if (resolve.name) {
|
||||
<span class="resolved-item__name">{{ resolve.name }}</span>
|
||||
}
|
||||
<span class="resolved-item__type" [class]="'type--' + resolve.type">
|
||||
{{ resolve.type }}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<div class="patch-list__empty">
|
||||
<p>No patches recorded for this component.</p>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.patch-list {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.patch-list__header {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
||||
}
|
||||
|
||||
.patch-list__title {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.patch-list__items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.patch-item {
|
||||
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
}
|
||||
}
|
||||
|
||||
.patch-item__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
/* Patch Type Badge */
|
||||
.patch-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.patch-badge--backport {
|
||||
background: var(--color-backport-bg, #dcfce7);
|
||||
color: var(--color-backport-text, #15803d);
|
||||
}
|
||||
|
||||
.patch-badge--cherry-pick {
|
||||
background: var(--color-cherrypick-bg, #dbeafe);
|
||||
color: var(--color-cherrypick-text, #1d4ed8);
|
||||
}
|
||||
|
||||
.patch-badge--monkey {
|
||||
background: var(--color-monkey-bg, #ffedd5);
|
||||
color: var(--color-monkey-text, #c2410c);
|
||||
}
|
||||
|
||||
.patch-badge--unofficial {
|
||||
background: var(--color-unofficial-bg, #f3e8ff);
|
||||
color: var(--color-unofficial-text, #7c3aed);
|
||||
}
|
||||
|
||||
/* CVE Tags */
|
||||
.patch-item__cves {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cve-tag {
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--surface-tertiary, #f3f4f6);
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-family: monospace;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.cve-tag--more {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.patch-item__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.patch-action-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-link, #2563eb);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
border-color: var(--text-link, #2563eb);
|
||||
}
|
||||
}
|
||||
|
||||
.patch-expand-btn {
|
||||
padding: 0.25rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Expanded Details */
|
||||
.patch-item__details {
|
||||
padding: 0.75rem 1rem;
|
||||
padding-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.patch-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.patch-detail--full {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.patch-detail__label {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.patch-detail__value {
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.patch-detail__link {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-link, #2563eb);
|
||||
}
|
||||
}
|
||||
|
||||
/* Resolved List */
|
||||
.resolved-list {
|
||||
list-style: none;
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resolved-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.resolved-item__id {
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.resolved-item__name {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resolved-item__type {
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.type--security {
|
||||
background: var(--color-security-bg, #fee2e2);
|
||||
color: var(--color-security-text, #dc2626);
|
||||
}
|
||||
|
||||
.type--defect {
|
||||
background: var(--color-defect-bg, #fef3c7);
|
||||
color: var(--color-defect-text, #d97706);
|
||||
}
|
||||
|
||||
.type--enhancement {
|
||||
background: var(--color-enhancement-bg, #dbeafe);
|
||||
color: var(--color-enhancement-text, #2563eb);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.patch-list__empty {
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PatchListComponent {
|
||||
/** CycloneDX pedigree data */
|
||||
readonly pedigree = input<ComponentPedigree | undefined>(undefined);
|
||||
|
||||
/** Custom confidence map for patches (keyed by index or patch identifier) */
|
||||
readonly patchConfidences = input<Map<number, number>>(new Map());
|
||||
|
||||
/** Emits when user wants to view a patch diff */
|
||||
readonly viewDiff = output<ViewDiffEvent>();
|
||||
|
||||
/** Expanded patch indices */
|
||||
private readonly expandedIndices = signal<Set<number>>(new Set());
|
||||
|
||||
/** Computed patches list */
|
||||
readonly patches = computed<PedigreePatch[]>(() => {
|
||||
return this.pedigree()?.patches ?? [];
|
||||
});
|
||||
|
||||
/** Check if patch at index is expanded */
|
||||
isExpanded(index: number): boolean {
|
||||
return this.expandedIndices().has(index);
|
||||
}
|
||||
|
||||
/** Toggle patch expansion */
|
||||
toggleExpand(index: number): void {
|
||||
this.expandedIndices.update((set) => {
|
||||
const newSet = new Set(set);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
|
||||
/** Get patch type label */
|
||||
getTypeLabel(type: PatchType): string {
|
||||
return getPatchTypeLabel(type);
|
||||
}
|
||||
|
||||
/** Check if patch has diff available */
|
||||
hasDiff(patch: PedigreePatch): boolean {
|
||||
return !!(patch.diff?.url || patch.diff?.text?.content);
|
||||
}
|
||||
|
||||
/** Get commit info for a patch (from pedigree.commits by correlation) */
|
||||
getCommitForPatch(patch: PedigreePatch): { uid: string; url?: string } | null {
|
||||
const pedigree = this.pedigree();
|
||||
if (!pedigree?.commits?.length) return null;
|
||||
|
||||
// Try to find related commit - in real implementation this would be correlated
|
||||
// For now, return first commit if available
|
||||
const commit = pedigree.commits[0];
|
||||
return commit ? { uid: commit.uid, url: commit.url } : null;
|
||||
}
|
||||
|
||||
/** Get confidence for a patch */
|
||||
getCommitConfidence(patch: PedigreePatch): number | undefined {
|
||||
const pedigree = this.pedigree();
|
||||
const index = this.patches().indexOf(patch);
|
||||
|
||||
// Check custom confidence map first
|
||||
const customConf = this.patchConfidences().get(index);
|
||||
if (customConf !== undefined) return customConf;
|
||||
|
||||
// Fallback to default based on patch type (heuristic)
|
||||
switch (patch.type) {
|
||||
case 'backport':
|
||||
return 0.95; // Tier 1: Distro advisory
|
||||
case 'cherry-pick':
|
||||
return 0.80; // Tier 2: Commit match
|
||||
case 'monkey':
|
||||
return 0.50; // Tier 3: Runtime patch
|
||||
case 'unofficial':
|
||||
return 0.30; // Tier 4: Community patch
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get tier description for confidence value */
|
||||
getTierDescription(confidence: number): string {
|
||||
const tier = getConfidenceTier(confidence);
|
||||
return CONFIDENCE_TIER_INFO[tier].description;
|
||||
}
|
||||
|
||||
/** Handle view diff click */
|
||||
onViewDiff(patch: PedigreePatch): void {
|
||||
this.viewDiff.emit({
|
||||
patch,
|
||||
diffUrl: patch.diff?.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user