sprints work

This commit is contained in:
master
2026-01-10 20:32:13 +02:00
parent 0d5eda86fc
commit 17d0631b8e
189 changed files with 40667 additions and 497 deletions

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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; }
}

View File

@@ -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);

View File

@@ -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>

View 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;
}

View 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; }
}

View 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);
}

View 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;
}

View 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();
}

View File

@@ -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; }
}
}

View File

@@ -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;
}

View 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;
}

View 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; }
}

View File

@@ -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() };
}

View File

@@ -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; }
}

View File

@@ -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();
}

View File

@@ -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 };
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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)]

View 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; }
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View 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; }
}

View 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);
}

View 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;
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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();
}

View File

@@ -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>

View File

@@ -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}");
}

View File

@@ -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;
}

View File

@@ -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"));
}
}

View File

@@ -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
};
}
}

View File

@@ -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
}
};
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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; } = [];
}

View File

@@ -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}";
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -296,5 +296,8 @@ public enum OutcomeStatus
NegativeOutcome,
/// <summary>Outcome is still pending.</summary>
Pending
Pending,
/// <summary>Decision failed to execute.</summary>
Failure
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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);
}

View 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; } = [];
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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";
}

View File

@@ -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; }
}

View File

@@ -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"
};
}
}

View File

@@ -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();

View File

@@ -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 });

View 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

View File

@@ -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';

View File

@@ -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');

View 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');
});
});

View 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();
});
});
});

View File

@@ -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],

View File

@@ -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: '**',

View 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);
}
}

View 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;
}

View 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);
}
}

View 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;
}

View File

@@ -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;
}
}

View File

@@ -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]);
}
}

View 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';

View File

@@ -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]);
}
}

View File

@@ -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';
}
}
}

View File

@@ -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';

View File

@@ -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');
});
});
});

View File

@@ -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';
}
}

View 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';

View File

@@ -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' };
}
}

View File

@@ -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);
});
});
});

View File

@@ -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));
}
}

View File

@@ -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');
});
});
});

View File

@@ -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);
}
}

View File

@@ -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');
});
});
});

View File

@@ -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">&lt;{{ c.author.email }}&gt;</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">&lt;{{ c.committer.email }}&gt;</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');
}
}
}

View File

@@ -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);
});
});
});

View File

@@ -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 { &nbsp; }
}
</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();
}
}

View File

@@ -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');
});
});

View File

@@ -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}`;
});
}

View File

@@ -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');
});
});
});

View File

@@ -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');
}
}
}

View File

@@ -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';

View File

@@ -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');
});
});

View File

@@ -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