Implement Exception Effect Registry and Evaluation Service
- Added IExceptionEffectRegistry interface and its implementation ExceptionEffectRegistry to manage exception effects based on type and reason. - Created ExceptionAwareEvaluationService for evaluating policies with automatic exception loading from the repository. - Developed unit tests for ExceptionAdapter and ExceptionEffectRegistry to ensure correct behavior and mappings of exceptions and effects. - Enhanced exception loading logic to filter expired and non-active exceptions, and to respect maximum exceptions limit. - Implemented caching mechanism in ExceptionAdapter to optimize repeated exception loading.
This commit is contained in:
302
src/Policy/StellaOps.Policy.Engine/Adapters/ExceptionAdapter.cs
Normal file
302
src/Policy/StellaOps.Policy.Engine/Adapters/ExceptionAdapter.cs
Normal file
@@ -0,0 +1,302 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Options for exception adapter configuration.
|
||||
/// </summary>
|
||||
public sealed class ExceptionAdapterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Cache TTL for loaded exceptions. Default: 60 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan CacheTtl { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of exceptions to load per tenant. Default: 10000.
|
||||
/// </summary>
|
||||
public int MaxExceptionsPerTenant { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable caching. Default: true.
|
||||
/// </summary>
|
||||
public bool EnableCaching { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for adapting persisted exception objects to policy evaluation context.
|
||||
/// </summary>
|
||||
internal interface IExceptionAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads active exceptions for a tenant and converts them to PolicyEvaluationExceptions.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="asOf">Point in time for expiry filtering (typically now).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Policy evaluation exceptions ready for use in evaluation context.</returns>
|
||||
Task<PolicyEvaluationExceptions> LoadExceptionsAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates cached exceptions for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
void InvalidateCache(Guid tenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates all cached exceptions.
|
||||
/// </summary>
|
||||
void InvalidateAllCaches();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adapts persisted ExceptionObject entities to PolicyEvaluationExceptions for policy evaluation.
|
||||
/// Includes caching layer for performance optimization.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionAdapter : IExceptionAdapter
|
||||
{
|
||||
private readonly IExceptionRepository _repository;
|
||||
private readonly IExceptionEffectRegistry _effectRegistry;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ExceptionAdapterOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ExceptionAdapter> _logger;
|
||||
|
||||
private static readonly string CacheKeyPrefix = "exception_adapter:";
|
||||
|
||||
public ExceptionAdapter(
|
||||
IExceptionRepository repository,
|
||||
IExceptionEffectRegistry effectRegistry,
|
||||
IMemoryCache cache,
|
||||
IOptions<ExceptionAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ExceptionAdapter> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_effectRegistry = effectRegistry ?? throw new ArgumentNullException(nameof(effectRegistry));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_options = options?.Value ?? new ExceptionAdapterOptions();
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PolicyEvaluationExceptions> LoadExceptionsAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildCacheKey(tenantId);
|
||||
|
||||
if (_options.EnableCaching && _cache.TryGetValue(cacheKey, out PolicyEvaluationExceptions? cached) && cached is not null)
|
||||
{
|
||||
_logger.LogDebug("Cache hit for tenant {TenantId} exceptions", tenantId);
|
||||
return cached;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Loading exceptions from repository for tenant {TenantId}", tenantId);
|
||||
|
||||
// Load active exceptions from repository
|
||||
var exceptions = await LoadActiveExceptionsAsync(tenantId, asOf, cancellationToken);
|
||||
|
||||
// Convert to evaluation context format
|
||||
var result = ConvertToEvaluationExceptions(exceptions, asOf);
|
||||
|
||||
// Cache the result
|
||||
if (_options.EnableCaching)
|
||||
{
|
||||
var cacheOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _options.CacheTtl,
|
||||
Size = 1
|
||||
};
|
||||
_cache.Set(cacheKey, result, cacheOptions);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Loaded {Count} active exceptions for tenant {TenantId}",
|
||||
result.Instances.Length,
|
||||
tenantId);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void InvalidateCache(Guid tenantId)
|
||||
{
|
||||
var cacheKey = BuildCacheKey(tenantId);
|
||||
_cache.Remove(cacheKey);
|
||||
_logger.LogDebug("Invalidated exception cache for tenant {TenantId}", tenantId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void InvalidateAllCaches()
|
||||
{
|
||||
// IMemoryCache doesn't support enumeration, so we can't clear all entries.
|
||||
// In practice, callers should invalidate specific tenants or use a distributed cache
|
||||
// with proper invalidation patterns.
|
||||
_logger.LogWarning("InvalidateAllCaches called but IMemoryCache doesn't support enumeration. Consider using tenant-specific invalidation.");
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ExceptionObject>> LoadActiveExceptionsAsync(
|
||||
Guid tenantId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Create scope filter for active exceptions
|
||||
var scope = new ExceptionScope
|
||||
{
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
// Query repository for active exceptions not expired as of the given time
|
||||
var candidates = await _repository.GetActiveByScopeAsync(scope, cancellationToken);
|
||||
|
||||
// Filter to only active status and not expired
|
||||
return candidates
|
||||
.Where(ex => ex.Status == ExceptionStatus.Active)
|
||||
.Where(ex => ex.ExpiresAt > asOf)
|
||||
.Take(_options.MaxExceptionsPerTenant)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private PolicyEvaluationExceptions ConvertToEvaluationExceptions(
|
||||
IReadOnlyList<ExceptionObject> exceptions,
|
||||
DateTimeOffset asOf)
|
||||
{
|
||||
if (exceptions.Count == 0)
|
||||
{
|
||||
return PolicyEvaluationExceptions.Empty;
|
||||
}
|
||||
|
||||
var effectsBuilder = ImmutableDictionary.CreateBuilder<string, PolicyExceptionEffect>(StringComparer.OrdinalIgnoreCase);
|
||||
var instancesBuilder = ImmutableArray.CreateBuilder<PolicyEvaluationExceptionInstance>(exceptions.Count);
|
||||
|
||||
foreach (var exception in exceptions)
|
||||
{
|
||||
// Get or create effect for this exception type/reason
|
||||
var effect = _effectRegistry.GetEffect(exception.Type, exception.ReasonCode);
|
||||
var effectId = effect.Id;
|
||||
|
||||
// Add effect to dictionary (de-duplicate by ID)
|
||||
if (!effectsBuilder.ContainsKey(effectId))
|
||||
{
|
||||
effectsBuilder.Add(effectId, effect);
|
||||
}
|
||||
|
||||
// Create scope from exception scope
|
||||
var scope = ConvertScope(exception.Scope);
|
||||
|
||||
// Create instance
|
||||
var instance = new PolicyEvaluationExceptionInstance(
|
||||
Id: exception.ExceptionId,
|
||||
EffectId: effectId,
|
||||
Scope: scope,
|
||||
CreatedAt: exception.CreatedAt,
|
||||
Metadata: BuildMetadata(exception));
|
||||
|
||||
instancesBuilder.Add(instance);
|
||||
}
|
||||
|
||||
return new PolicyEvaluationExceptions(
|
||||
Effects: effectsBuilder.ToImmutable(),
|
||||
Instances: instancesBuilder.ToImmutable());
|
||||
}
|
||||
|
||||
private static PolicyEvaluationExceptionScope ConvertScope(ExceptionScope scope)
|
||||
{
|
||||
// Map exception scope to evaluation scope
|
||||
// Policy rule IDs go to RuleNames
|
||||
// Vulnerability IDs go to Sources (advisory source matching)
|
||||
// PURL patterns go to Tags (for component matching)
|
||||
|
||||
var ruleNames = !string.IsNullOrEmpty(scope.PolicyRuleId)
|
||||
? ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, scope.PolicyRuleId)
|
||||
: ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var sources = !string.IsNullOrEmpty(scope.VulnerabilityId)
|
||||
? ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, scope.VulnerabilityId)
|
||||
: ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tags = ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrEmpty(scope.PurlPattern))
|
||||
{
|
||||
// Use PURL pattern as a tag for component-based matching
|
||||
tags = tags.Add($"purl:{scope.PurlPattern}");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(scope.ArtifactDigest))
|
||||
{
|
||||
tags = tags.Add($"digest:{scope.ArtifactDigest}");
|
||||
}
|
||||
|
||||
// Environments are stored as tags with env: prefix
|
||||
foreach (var env in scope.Environments)
|
||||
{
|
||||
tags = tags.Add($"env:{env}");
|
||||
}
|
||||
|
||||
// Severities are not directly mapped from ExceptionScope
|
||||
// They would come from effect configuration
|
||||
var severities = ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new PolicyEvaluationExceptionScope(
|
||||
RuleNames: ruleNames,
|
||||
Severities: severities,
|
||||
Sources: sources,
|
||||
Tags: tags);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> BuildMetadata(ExceptionObject exception)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
builder["exception.type"] = exception.Type.ToString();
|
||||
builder["exception.reason"] = exception.ReasonCode.ToString();
|
||||
builder["exception.owner"] = exception.OwnerId;
|
||||
builder["exception.requester"] = exception.RequesterId;
|
||||
builder["exception.rationale"] = exception.Rationale;
|
||||
builder["exception.expiresAt"] = exception.ExpiresAt.ToString("O");
|
||||
|
||||
if (exception.ApproverIds.Length > 0)
|
||||
{
|
||||
builder["exception.approvers"] = string.Join(",", exception.ApproverIds);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(exception.TicketRef))
|
||||
{
|
||||
builder["exception.ticketRef"] = exception.TicketRef;
|
||||
}
|
||||
|
||||
if (exception.EvidenceRefs.Length > 0)
|
||||
{
|
||||
builder["exception.evidenceRefs"] = string.Join(",", exception.EvidenceRefs);
|
||||
}
|
||||
|
||||
if (exception.CompensatingControls.Length > 0)
|
||||
{
|
||||
builder["exception.compensatingControls"] = string.Join(",", exception.CompensatingControls);
|
||||
}
|
||||
|
||||
// Copy custom metadata
|
||||
foreach (var pair in exception.Metadata)
|
||||
{
|
||||
if (!builder.ContainsKey(pair.Key))
|
||||
{
|
||||
builder[$"meta.{pair.Key}"] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(Guid tenantId) => $"{CacheKeyPrefix}{tenantId:N}";
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Collections.Frozen;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for looking up exception effects based on type and reason.
|
||||
/// </summary>
|
||||
public interface IExceptionEffectRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the policy exception effect for a given exception type and reason.
|
||||
/// </summary>
|
||||
/// <param name="type">Exception type.</param>
|
||||
/// <param name="reason">Exception reason code.</param>
|
||||
/// <returns>The corresponding policy exception effect.</returns>
|
||||
PolicyExceptionEffect GetEffect(ExceptionType type, ExceptionReason reason);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered effects.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<PolicyExceptionEffect> GetAllEffects();
|
||||
|
||||
/// <summary>
|
||||
/// Gets effect by ID.
|
||||
/// </summary>
|
||||
/// <param name="effectId">Effect identifier.</param>
|
||||
/// <returns>Effect if found, null otherwise.</returns>
|
||||
PolicyExceptionEffect? GetEffectById(string effectId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry mapping exception type/reason combinations to policy exception effects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Effect mappings follow the auditable exception principles:
|
||||
/// - Suppress: Finding is suppressed from reports and gates
|
||||
/// - Defer: Finding is deferred (tracked but not blocking)
|
||||
/// - Downgrade: Finding severity is reduced
|
||||
/// - RequireControl: Finding requires compensating control verification
|
||||
/// </remarks>
|
||||
public sealed class ExceptionEffectRegistry : IExceptionEffectRegistry
|
||||
{
|
||||
private readonly FrozenDictionary<(ExceptionType, ExceptionReason), PolicyExceptionEffect> _effectMap;
|
||||
private readonly FrozenDictionary<string, PolicyExceptionEffect> _effectsById;
|
||||
private readonly PolicyExceptionEffect _defaultEffect;
|
||||
|
||||
public ExceptionEffectRegistry()
|
||||
{
|
||||
var effects = BuildDefaultEffects();
|
||||
_effectMap = effects.ToFrozenDictionary();
|
||||
_effectsById = effects.Values
|
||||
.DistinctBy(e => e.Id)
|
||||
.ToFrozenDictionary(e => e.Id, StringComparer.OrdinalIgnoreCase);
|
||||
_defaultEffect = CreateDefaultEffect();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PolicyExceptionEffect GetEffect(ExceptionType type, ExceptionReason reason)
|
||||
{
|
||||
return _effectMap.TryGetValue((type, reason), out var effect)
|
||||
? effect
|
||||
: _defaultEffect;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyCollection<PolicyExceptionEffect> GetAllEffects()
|
||||
{
|
||||
return _effectsById.Values;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PolicyExceptionEffect? GetEffectById(string effectId)
|
||||
{
|
||||
return _effectsById.TryGetValue(effectId, out var effect) ? effect : null;
|
||||
}
|
||||
|
||||
private static Dictionary<(ExceptionType, ExceptionReason), PolicyExceptionEffect> BuildDefaultEffects()
|
||||
{
|
||||
// Define all effect templates
|
||||
var suppress = new PolicyExceptionEffect(
|
||||
Id: "suppress",
|
||||
Name: "Suppress Finding",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: null,
|
||||
MaxDurationDays: 365,
|
||||
Description: "Suppresses the finding from reports and policy gates.");
|
||||
|
||||
var defer = new PolicyExceptionEffect(
|
||||
Id: "defer",
|
||||
Name: "Defer Finding",
|
||||
Effect: PolicyExceptionEffectType.Defer,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "deferred-review",
|
||||
MaxDurationDays: 90,
|
||||
Description: "Defers the finding for later review without blocking.");
|
||||
|
||||
var requireControl = new PolicyExceptionEffect(
|
||||
Id: "require-control",
|
||||
Name: "Require Compensating Control",
|
||||
Effect: PolicyExceptionEffectType.RequireControl,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: "compensating-control-verification",
|
||||
RoutingTemplate: "control-verification",
|
||||
MaxDurationDays: 180,
|
||||
Description: "Requires verification of compensating controls before allowing.");
|
||||
|
||||
var downgradeToLow = new PolicyExceptionEffect(
|
||||
Id: "downgrade-low",
|
||||
Name: "Downgrade to Low",
|
||||
Effect: PolicyExceptionEffectType.Downgrade,
|
||||
DowngradeSeverity: PolicySeverity.Low,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: null,
|
||||
MaxDurationDays: 365,
|
||||
Description: "Downgrades finding severity to Low.");
|
||||
|
||||
var downgradeToMedium = new PolicyExceptionEffect(
|
||||
Id: "downgrade-medium",
|
||||
Name: "Downgrade to Medium",
|
||||
Effect: PolicyExceptionEffectType.Downgrade,
|
||||
DowngradeSeverity: PolicySeverity.Medium,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: null,
|
||||
MaxDurationDays: 365,
|
||||
Description: "Downgrades finding severity to Medium.");
|
||||
|
||||
var deferVendor = new PolicyExceptionEffect(
|
||||
Id: "defer-vendor",
|
||||
Name: "Awaiting Vendor Fix",
|
||||
Effect: PolicyExceptionEffectType.Defer,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "vendor-tracking",
|
||||
MaxDurationDays: 180,
|
||||
Description: "Defers pending vendor patch release.");
|
||||
|
||||
var suppressDeprecation = new PolicyExceptionEffect(
|
||||
Id: "suppress-deprecation",
|
||||
Name: "Deprecation Waiver",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "deprecation-tracking",
|
||||
MaxDurationDays: 90,
|
||||
Description: "Temporary waiver during component deprecation.");
|
||||
|
||||
var suppressLicense = new PolicyExceptionEffect(
|
||||
Id: "suppress-license",
|
||||
Name: "License Waiver",
|
||||
Effect: PolicyExceptionEffectType.Suppress,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "legal-review",
|
||||
MaxDurationDays: 365,
|
||||
Description: "License compliance waiver after legal review.");
|
||||
|
||||
// Build the mapping
|
||||
return new Dictionary<(ExceptionType, ExceptionReason), PolicyExceptionEffect>
|
||||
{
|
||||
// Vulnerability exceptions
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.FalsePositive)] = suppress,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.AcceptedRisk)] = suppress,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.CompensatingControl)] = requireControl,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.TestOnly)] = suppress,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.VendorNotAffected)] = suppress,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.ScheduledFix)] = defer,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.DeprecationInProgress)] = suppressDeprecation,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
|
||||
[(ExceptionType.Vulnerability, ExceptionReason.Other)] = defer,
|
||||
|
||||
// Policy exceptions
|
||||
[(ExceptionType.Policy, ExceptionReason.FalsePositive)] = suppress,
|
||||
[(ExceptionType.Policy, ExceptionReason.AcceptedRisk)] = suppress,
|
||||
[(ExceptionType.Policy, ExceptionReason.CompensatingControl)] = requireControl,
|
||||
[(ExceptionType.Policy, ExceptionReason.TestOnly)] = suppress,
|
||||
[(ExceptionType.Policy, ExceptionReason.VendorNotAffected)] = suppress,
|
||||
[(ExceptionType.Policy, ExceptionReason.ScheduledFix)] = defer,
|
||||
[(ExceptionType.Policy, ExceptionReason.DeprecationInProgress)] = defer,
|
||||
[(ExceptionType.Policy, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
|
||||
[(ExceptionType.Policy, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
|
||||
[(ExceptionType.Policy, ExceptionReason.Other)] = defer,
|
||||
|
||||
// Unknown findings exceptions
|
||||
[(ExceptionType.Unknown, ExceptionReason.FalsePositive)] = suppress,
|
||||
[(ExceptionType.Unknown, ExceptionReason.AcceptedRisk)] = suppress,
|
||||
[(ExceptionType.Unknown, ExceptionReason.CompensatingControl)] = requireControl,
|
||||
[(ExceptionType.Unknown, ExceptionReason.TestOnly)] = suppress,
|
||||
[(ExceptionType.Unknown, ExceptionReason.VendorNotAffected)] = suppress,
|
||||
[(ExceptionType.Unknown, ExceptionReason.ScheduledFix)] = defer,
|
||||
[(ExceptionType.Unknown, ExceptionReason.DeprecationInProgress)] = defer,
|
||||
[(ExceptionType.Unknown, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
|
||||
[(ExceptionType.Unknown, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
|
||||
[(ExceptionType.Unknown, ExceptionReason.Other)] = defer,
|
||||
|
||||
// Component exceptions
|
||||
[(ExceptionType.Component, ExceptionReason.FalsePositive)] = suppress,
|
||||
[(ExceptionType.Component, ExceptionReason.AcceptedRisk)] = suppress,
|
||||
[(ExceptionType.Component, ExceptionReason.CompensatingControl)] = requireControl,
|
||||
[(ExceptionType.Component, ExceptionReason.TestOnly)] = suppress,
|
||||
[(ExceptionType.Component, ExceptionReason.VendorNotAffected)] = suppress,
|
||||
[(ExceptionType.Component, ExceptionReason.ScheduledFix)] = defer,
|
||||
[(ExceptionType.Component, ExceptionReason.DeprecationInProgress)] = suppressDeprecation,
|
||||
[(ExceptionType.Component, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
|
||||
[(ExceptionType.Component, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
|
||||
[(ExceptionType.Component, ExceptionReason.Other)] = suppressLicense,
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyExceptionEffect CreateDefaultEffect()
|
||||
{
|
||||
return new PolicyExceptionEffect(
|
||||
Id: "defer-default",
|
||||
Name: "Default Deferral",
|
||||
Effect: PolicyExceptionEffectType.Defer,
|
||||
DowngradeSeverity: null,
|
||||
RequiredControlId: null,
|
||||
RoutingTemplate: "manual-review",
|
||||
MaxDurationDays: 30,
|
||||
Description: "Default effect for unmapped exception type/reason combinations.");
|
||||
}
|
||||
}
|
||||
@@ -290,4 +290,32 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
services.Configure(configure);
|
||||
return services.AddPolicyEngine();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds exception integration services for automatic exception loading during policy evaluation.
|
||||
/// Requires IExceptionRepository to be registered.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configure">Optional configuration for exception adapter options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPolicyExceptionIntegration(
|
||||
this IServiceCollection services,
|
||||
Action<Adapters.ExceptionAdapterOptions>? configure = null)
|
||||
{
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
// Register the effect registry (singleton, stateless)
|
||||
services.TryAddSingleton<Adapters.IExceptionEffectRegistry, Adapters.ExceptionEffectRegistry>();
|
||||
|
||||
// Register the exception adapter (singleton, uses IMemoryCache for caching)
|
||||
services.TryAddSingleton<Adapters.IExceptionAdapter, Adapters.ExceptionAdapter>();
|
||||
|
||||
// Register the exception-aware evaluation service
|
||||
services.TryAddSingleton<Services.IExceptionAwareEvaluationService, Services.ExceptionAwareEvaluationService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Domain;
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Adapters;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Request for exception-aware policy evaluation.
|
||||
/// Extends the base RuntimeEvaluationRequest with exception loading options.
|
||||
/// </summary>
|
||||
internal sealed record ExceptionAwareEvaluationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Base evaluation request.
|
||||
/// </summary>
|
||||
public required RuntimeEvaluationRequest BaseRequest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to automatically load exceptions from the repository.
|
||||
/// If false, uses exceptions from BaseRequest.Exceptions (default behavior).
|
||||
/// If true, loads exceptions for the tenant and merges with any provided exceptions.
|
||||
/// </summary>
|
||||
public bool LoadExceptionsFromRepository { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for loading exceptions. Required if LoadExceptionsFromRepository is true.
|
||||
/// Falls back to parsing from TenantId in BaseRequest if not provided.
|
||||
/// </summary>
|
||||
public Guid? ExceptionTenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from exception-aware policy evaluation.
|
||||
/// </summary>
|
||||
internal sealed record ExceptionAwareEvaluationResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The underlying evaluation response.
|
||||
/// </summary>
|
||||
public required RuntimeEvaluationResponse Response { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of exceptions that were loaded from the repository.
|
||||
/// </summary>
|
||||
public int LoadedExceptionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether exceptions were loaded from the repository.
|
||||
/// </summary>
|
||||
public bool ExceptionsLoadedFromRepository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of exception loading in milliseconds.
|
||||
/// </summary>
|
||||
public long ExceptionLoadDurationMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for exception-aware policy evaluation.
|
||||
/// Automatically loads exceptions from the repository before evaluation.
|
||||
/// </summary>
|
||||
internal interface IExceptionAwareEvaluationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a policy with automatic exception loading.
|
||||
/// </summary>
|
||||
Task<ExceptionAwareEvaluationResponse> EvaluateAsync(
|
||||
ExceptionAwareEvaluationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates multiple requests in batch with automatic exception loading.
|
||||
/// Exceptions are loaded once per tenant for efficiency.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExceptionAwareEvaluationResponse>> EvaluateBatchAsync(
|
||||
IReadOnlyList<ExceptionAwareEvaluationRequest> requests,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception-aware policy evaluation service.
|
||||
/// Wraps PolicyRuntimeEvaluationService and automatically loads exceptions from the repository.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionAwareEvaluationService : IExceptionAwareEvaluationService
|
||||
{
|
||||
private readonly PolicyRuntimeEvaluationService _evaluator;
|
||||
private readonly IExceptionAdapter _exceptionAdapter;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ExceptionAwareEvaluationService> _logger;
|
||||
|
||||
public ExceptionAwareEvaluationService(
|
||||
PolicyRuntimeEvaluationService evaluator,
|
||||
IExceptionAdapter exceptionAdapter,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ExceptionAwareEvaluationService> logger)
|
||||
{
|
||||
_evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
|
||||
_exceptionAdapter = exceptionAdapter ?? throw new ArgumentNullException(nameof(exceptionAdapter));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExceptionAwareEvaluationResponse> EvaluateAsync(
|
||||
ExceptionAwareEvaluationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.BaseRequest);
|
||||
|
||||
var loadStartTimestamp = _timeProvider.GetTimestamp();
|
||||
var loadedCount = 0;
|
||||
var exceptionsLoaded = false;
|
||||
RuntimeEvaluationRequest enrichedRequest = request.BaseRequest;
|
||||
|
||||
if (request.LoadExceptionsFromRepository)
|
||||
{
|
||||
var tenantId = ResolveTenantId(request);
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
var asOf = request.BaseRequest.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
|
||||
var loadedExceptions = await _exceptionAdapter.LoadExceptionsAsync(
|
||||
tenantId.Value,
|
||||
asOf,
|
||||
cancellationToken);
|
||||
|
||||
// Merge loaded exceptions with any exceptions in the original request
|
||||
var mergedExceptions = MergeExceptions(request.BaseRequest.Exceptions, loadedExceptions);
|
||||
loadedCount = loadedExceptions.Instances.Length;
|
||||
exceptionsLoaded = true;
|
||||
|
||||
enrichedRequest = request.BaseRequest with { Exceptions = mergedExceptions };
|
||||
|
||||
_logger.LogDebug(
|
||||
"Loaded {Count} exceptions for tenant {TenantId} in evaluation request",
|
||||
loadedCount,
|
||||
tenantId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"LoadExceptionsFromRepository is true but no tenant ID available. " +
|
||||
"Falling back to exceptions in request.");
|
||||
}
|
||||
}
|
||||
|
||||
var loadDuration = GetElapsedMilliseconds(loadStartTimestamp);
|
||||
|
||||
// Delegate to core evaluator
|
||||
var response = await _evaluator.EvaluateAsync(enrichedRequest, cancellationToken);
|
||||
|
||||
// Record telemetry for exception loading
|
||||
if (exceptionsLoaded && loadedCount > 0)
|
||||
{
|
||||
PolicyEngineTelemetry.RecordExceptionLoaded(
|
||||
enrichedRequest.TenantId,
|
||||
loadedCount,
|
||||
loadDuration / 1000.0);
|
||||
}
|
||||
|
||||
return new ExceptionAwareEvaluationResponse
|
||||
{
|
||||
Response = response,
|
||||
LoadedExceptionCount = loadedCount,
|
||||
ExceptionsLoadedFromRepository = exceptionsLoaded,
|
||||
ExceptionLoadDurationMs = loadDuration
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExceptionAwareEvaluationResponse>> EvaluateBatchAsync(
|
||||
IReadOnlyList<ExceptionAwareEvaluationRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (requests.Count == 0)
|
||||
{
|
||||
return Array.Empty<ExceptionAwareEvaluationResponse>();
|
||||
}
|
||||
|
||||
var loadStartTimestamp = _timeProvider.GetTimestamp();
|
||||
|
||||
// Group requests by tenant to load exceptions efficiently
|
||||
var tenantExceptionsCache = new Dictionary<Guid, PolicyEvaluationExceptions>();
|
||||
var enrichedRequests = new List<(ExceptionAwareEvaluationRequest Original, RuntimeEvaluationRequest Enriched, int LoadedCount)>();
|
||||
var asOf = _timeProvider.GetUtcNow();
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.BaseRequest);
|
||||
|
||||
var loadedCount = 0;
|
||||
RuntimeEvaluationRequest enrichedRequest = request.BaseRequest;
|
||||
|
||||
if (request.LoadExceptionsFromRepository)
|
||||
{
|
||||
var tenantId = ResolveTenantId(request);
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
// Check cache first
|
||||
if (!tenantExceptionsCache.TryGetValue(tenantId.Value, out var loadedExceptions))
|
||||
{
|
||||
var requestAsOf = request.BaseRequest.EvaluationTimestamp ?? asOf;
|
||||
loadedExceptions = await _exceptionAdapter.LoadExceptionsAsync(
|
||||
tenantId.Value,
|
||||
requestAsOf,
|
||||
cancellationToken);
|
||||
tenantExceptionsCache[tenantId.Value] = loadedExceptions;
|
||||
}
|
||||
|
||||
var mergedExceptions = MergeExceptions(request.BaseRequest.Exceptions, loadedExceptions);
|
||||
loadedCount = loadedExceptions.Instances.Length;
|
||||
enrichedRequest = request.BaseRequest with { Exceptions = mergedExceptions };
|
||||
}
|
||||
}
|
||||
|
||||
enrichedRequests.Add((request, enrichedRequest, loadedCount));
|
||||
}
|
||||
|
||||
var loadDuration = GetElapsedMilliseconds(loadStartTimestamp);
|
||||
|
||||
// Evaluate all enriched requests
|
||||
var baseRequests = enrichedRequests.Select(e => e.Enriched).ToList();
|
||||
var responses = await _evaluator.EvaluateBatchAsync(baseRequests, cancellationToken);
|
||||
|
||||
// Build responses
|
||||
var results = new List<ExceptionAwareEvaluationResponse>(requests.Count);
|
||||
for (int i = 0; i < enrichedRequests.Count; i++)
|
||||
{
|
||||
var (original, _, loadedCount) = enrichedRequests[i];
|
||||
results.Add(new ExceptionAwareEvaluationResponse
|
||||
{
|
||||
Response = responses[i],
|
||||
LoadedExceptionCount = loadedCount,
|
||||
ExceptionsLoadedFromRepository = original.LoadExceptionsFromRepository,
|
||||
ExceptionLoadDurationMs = loadDuration / requests.Count // Amortized
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Batch evaluation with exception loading: {RequestCount} requests, {TenantCount} tenants, {TotalLoaded} total exceptions loaded",
|
||||
requests.Count,
|
||||
tenantExceptionsCache.Count,
|
||||
tenantExceptionsCache.Values.Sum(e => e.Instances.Length));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private Guid? ResolveTenantId(ExceptionAwareEvaluationRequest request)
|
||||
{
|
||||
// First try explicit exception tenant ID
|
||||
if (request.ExceptionTenantId.HasValue)
|
||||
{
|
||||
return request.ExceptionTenantId.Value;
|
||||
}
|
||||
|
||||
// Then try parsing from TenantId string in base request
|
||||
if (Guid.TryParse(request.BaseRequest.TenantId, out var parsedTenantId))
|
||||
{
|
||||
return parsedTenantId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PolicyEvaluationExceptions MergeExceptions(
|
||||
PolicyEvaluationExceptions original,
|
||||
PolicyEvaluationExceptions loaded)
|
||||
{
|
||||
if (original.IsEmpty)
|
||||
{
|
||||
return loaded;
|
||||
}
|
||||
|
||||
if (loaded.IsEmpty)
|
||||
{
|
||||
return original;
|
||||
}
|
||||
|
||||
// Merge effects (loaded takes precedence for same ID)
|
||||
var mergedEffects = original.Effects.ToBuilder();
|
||||
foreach (var effect in loaded.Effects)
|
||||
{
|
||||
mergedEffects[effect.Key] = effect.Value;
|
||||
}
|
||||
|
||||
// Merge instances (combine and de-duplicate by ID)
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var mergedInstances = new List<PolicyEvaluationExceptionInstance>();
|
||||
|
||||
// Add original instances first (these take precedence as they were explicitly provided)
|
||||
foreach (var instance in original.Instances)
|
||||
{
|
||||
if (seenIds.Add(instance.Id))
|
||||
{
|
||||
mergedInstances.Add(instance);
|
||||
}
|
||||
}
|
||||
|
||||
// Add loaded instances that don't conflict
|
||||
foreach (var instance in loaded.Instances)
|
||||
{
|
||||
if (seenIds.Add(instance.Id))
|
||||
{
|
||||
mergedInstances.Add(instance);
|
||||
}
|
||||
}
|
||||
|
||||
return new PolicyEvaluationExceptions(
|
||||
mergedEffects.ToImmutable(),
|
||||
mergedInstances.ToImmutableArray());
|
||||
}
|
||||
|
||||
private long GetElapsedMilliseconds(long startTimestamp)
|
||||
{
|
||||
var elapsed = _timeProvider.GetElapsedTime(startTimestamp);
|
||||
return (long)elapsed.TotalMilliseconds;
|
||||
}
|
||||
}
|
||||
@@ -469,6 +469,20 @@ public static class PolicyEngineTelemetry
|
||||
unit: "events",
|
||||
description: "Lifecycle events for exceptions (activated, expired, revoked).");
|
||||
|
||||
// Counter: policy_exception_loaded_total{tenant}
|
||||
private static readonly Counter<long> ExceptionLoadedCounter =
|
||||
Meter.CreateCounter<long>(
|
||||
"policy_exception_loaded_total",
|
||||
unit: "exceptions",
|
||||
description: "Total exceptions loaded from repository for evaluation.");
|
||||
|
||||
// Histogram: policy_exception_load_latency_seconds{tenant}
|
||||
private static readonly Histogram<double> ExceptionLoadLatencyHistogram =
|
||||
Meter.CreateHistogram<double>(
|
||||
"policy_exception_load_latency_seconds",
|
||||
unit: "s",
|
||||
description: "Latency of loading exceptions from repository.");
|
||||
|
||||
/// <summary>
|
||||
/// Counter for exception cache operations.
|
||||
/// </summary>
|
||||
@@ -879,6 +893,23 @@ public static class PolicyEngineTelemetry
|
||||
ExceptionLifecycleCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records exceptions loaded from repository for evaluation.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="count">Number of exceptions loaded.</param>
|
||||
/// <param name="latencySeconds">Time taken to load exceptions in seconds.</param>
|
||||
public static void RecordExceptionLoaded(string tenant, int count, double latencySeconds)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", NormalizeTenant(tenant) },
|
||||
};
|
||||
|
||||
ExceptionLoadedCounter.Add(count, tags);
|
||||
ExceptionLoadLatencyHistogram.Record(latencySeconds, tags);
|
||||
}
|
||||
|
||||
#region Golden Signals - Recording Methods
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Policy.Engine.Adapters;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ExceptionAdapter.
|
||||
/// </summary>
|
||||
public sealed class ExceptionAdapterTests : IDisposable
|
||||
{
|
||||
private readonly Mock<IExceptionRepository> _repositoryMock;
|
||||
private readonly IExceptionEffectRegistry _effectRegistry;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ExceptionAdapterOptions _options;
|
||||
private readonly ExceptionAdapter _adapter;
|
||||
private readonly Guid _tenantId;
|
||||
|
||||
public ExceptionAdapterTests()
|
||||
{
|
||||
_repositoryMock = new Mock<IExceptionRepository>();
|
||||
_effectRegistry = new ExceptionEffectRegistry();
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_options = new ExceptionAdapterOptions
|
||||
{
|
||||
CacheTtl = TimeSpan.FromSeconds(60),
|
||||
EnableCaching = true,
|
||||
MaxExceptionsPerTenant = 10000
|
||||
};
|
||||
_tenantId = Guid.NewGuid();
|
||||
|
||||
_adapter = new ExceptionAdapter(
|
||||
_repositoryMock.Object,
|
||||
_effectRegistry,
|
||||
_cache,
|
||||
Options.Create(_options),
|
||||
TimeProvider.System,
|
||||
NullLogger<ExceptionAdapter>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_ReturnsEmpty_WhenNoExceptionsExist()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<ExceptionObject>());
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsEmpty.Should().BeTrue();
|
||||
result.Instances.Should().BeEmpty();
|
||||
result.Effects.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_FiltersExpiredExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var activeException = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
|
||||
var expiredException = CreateException("EXC-002", ExceptionStatus.Active, now.AddDays(-1)); // Expired
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { activeException, expiredException });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(1);
|
||||
result.Instances[0].Id.Should().Be("EXC-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_FiltersNonActiveExceptions()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var activeException = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
|
||||
var proposedException = CreateException("EXC-002", ExceptionStatus.Proposed, now.AddDays(30));
|
||||
var revokedException = CreateException("EXC-003", ExceptionStatus.Revoked, now.AddDays(30));
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { activeException, proposedException, revokedException });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(1);
|
||||
result.Instances[0].Id.Should().Be("EXC-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_MapsExceptionTypeAndReasonToEffect()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException(
|
||||
"EXC-001",
|
||||
ExceptionStatus.Active,
|
||||
now.AddDays(30),
|
||||
ExceptionType.Vulnerability,
|
||||
ExceptionReason.FalsePositive);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(1);
|
||||
result.Effects.Should().ContainKey("suppress"); // FalsePositive maps to Suppress
|
||||
result.Instances[0].EffectId.Should().Be("suppress");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_MapsScopeCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException(
|
||||
"EXC-001",
|
||||
ExceptionStatus.Active,
|
||||
now.AddDays(30),
|
||||
scope: new ExceptionScope
|
||||
{
|
||||
PolicyRuleId = "block_critical",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
PurlPattern = "pkg:npm/lodash@*",
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
Environments = ["production", "staging"]
|
||||
});
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(1);
|
||||
var instance = result.Instances[0];
|
||||
|
||||
// Policy rule ID maps to RuleNames
|
||||
instance.Scope.RuleNames.Should().Contain("block_critical");
|
||||
|
||||
// Vulnerability ID maps to Sources
|
||||
instance.Scope.Sources.Should().Contain("CVE-2024-1234");
|
||||
|
||||
// PURL pattern maps to Tags with prefix
|
||||
instance.Scope.Tags.Should().Contain("purl:pkg:npm/lodash@*");
|
||||
|
||||
// Artifact digest maps to Tags with prefix
|
||||
instance.Scope.Tags.Should().Contain("digest:sha256:abc123");
|
||||
|
||||
// Environments map to Tags with prefix
|
||||
instance.Scope.Tags.Should().Contain("env:production");
|
||||
instance.Scope.Tags.Should().Contain("env:staging");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_BuildsMetadataCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException(
|
||||
"EXC-001",
|
||||
ExceptionStatus.Active,
|
||||
now.AddDays(30),
|
||||
ticketRef: "JIRA-1234",
|
||||
evidenceRefs: new[] { "sha256:evidence1", "sha256:evidence2" },
|
||||
compensatingControls: new[] { "WAF", "Rate-limiting" });
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act
|
||||
var result = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
var instance = result.Instances[0];
|
||||
instance.Metadata.Should().ContainKey("exception.type");
|
||||
instance.Metadata.Should().ContainKey("exception.reason");
|
||||
instance.Metadata.Should().ContainKey("exception.owner");
|
||||
instance.Metadata.Should().ContainKey("exception.requester");
|
||||
instance.Metadata.Should().ContainKey("exception.rationale");
|
||||
instance.Metadata.Should().ContainKey("exception.ticketRef");
|
||||
instance.Metadata["exception.ticketRef"].Should().Be("JIRA-1234");
|
||||
instance.Metadata.Should().ContainKey("exception.evidenceRefs");
|
||||
instance.Metadata.Should().ContainKey("exception.compensatingControls");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_UsesCacheOnSecondCall()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act - First call
|
||||
var result1 = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Act - Second call (should hit cache)
|
||||
var result2 = await _adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result1.Should().Be(result2);
|
||||
_repositoryMock.Verify(
|
||||
r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once); // Should only call repository once
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_BypassesCache_WhenCachingDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var disabledCacheOptions = new ExceptionAdapterOptions { EnableCaching = false };
|
||||
var adapter = new ExceptionAdapter(
|
||||
_repositoryMock.Object,
|
||||
_effectRegistry,
|
||||
_cache,
|
||||
Options.Create(disabledCacheOptions),
|
||||
TimeProvider.System,
|
||||
NullLogger<ExceptionAdapter>.Instance);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exception = CreateException("EXC-001", ExceptionStatus.Active, now.AddDays(30));
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { exception });
|
||||
|
||||
// Act
|
||||
await adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
await adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(
|
||||
r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()),
|
||||
Times.Exactly(2)); // Should call repository twice
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidateCache_RemovesCacheEntry()
|
||||
{
|
||||
// Arrange - pre-populate cache
|
||||
var cacheKey = $"exception_adapter:{_tenantId:N}";
|
||||
_cache.Set(cacheKey, PolicyEvaluationExceptions.Empty);
|
||||
|
||||
// Act
|
||||
_adapter.InvalidateCache(_tenantId);
|
||||
|
||||
// Assert
|
||||
_cache.TryGetValue(cacheKey, out _).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadExceptionsAsync_RespectsMaxExceptionsLimit()
|
||||
{
|
||||
// Arrange
|
||||
var limitedOptions = new ExceptionAdapterOptions { MaxExceptionsPerTenant = 2 };
|
||||
var adapter = new ExceptionAdapter(
|
||||
_repositoryMock.Object,
|
||||
_effectRegistry,
|
||||
_cache,
|
||||
Options.Create(limitedOptions),
|
||||
TimeProvider.System,
|
||||
NullLogger<ExceptionAdapter>.Instance);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var exceptions = Enumerable.Range(1, 10)
|
||||
.Select(i => CreateException($"EXC-{i:000}", ExceptionStatus.Active, now.AddDays(30)))
|
||||
.ToArray();
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exceptions);
|
||||
|
||||
// Act
|
||||
var result = await adapter.LoadExceptionsAsync(_tenantId, now);
|
||||
|
||||
// Assert
|
||||
result.Instances.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
private static ExceptionObject CreateException(
|
||||
string exceptionId,
|
||||
ExceptionStatus status,
|
||||
DateTimeOffset expiresAt,
|
||||
ExceptionType type = ExceptionType.Vulnerability,
|
||||
ExceptionReason reason = ExceptionReason.AcceptedRisk,
|
||||
ExceptionScope? scope = null,
|
||||
string? ticketRef = null,
|
||||
string[]? evidenceRefs = null,
|
||||
string[]? compensatingControls = null)
|
||||
{
|
||||
return new ExceptionObject
|
||||
{
|
||||
ExceptionId = exceptionId,
|
||||
Version = 1,
|
||||
Status = status,
|
||||
Type = type,
|
||||
Scope = scope ?? new ExceptionScope
|
||||
{
|
||||
TenantId = Guid.NewGuid()
|
||||
},
|
||||
OwnerId = "owner-001",
|
||||
RequesterId = "requester-001",
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = expiresAt,
|
||||
ReasonCode = reason,
|
||||
Rationale = "This is a test rationale that meets the minimum character requirement for exception objects.",
|
||||
TicketRef = ticketRef,
|
||||
EvidenceRefs = evidenceRefs?.ToImmutableArray() ?? [],
|
||||
CompensatingControls = compensatingControls?.ToImmutableArray() ?? []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Engine.Adapters;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ExceptionEffectRegistry.
|
||||
/// </summary>
|
||||
public sealed class ExceptionEffectRegistryTests
|
||||
{
|
||||
private readonly IExceptionEffectRegistry _registry;
|
||||
|
||||
public ExceptionEffectRegistryTests()
|
||||
{
|
||||
_registry = new ExceptionEffectRegistry();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.FalsePositive, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.AcceptedRisk, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.CompensatingControl, PolicyExceptionEffectType.RequireControl)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.TestOnly, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.VendorNotAffected, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.ScheduledFix, PolicyExceptionEffectType.Defer)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.RuntimeMitigation, PolicyExceptionEffectType.Downgrade)]
|
||||
[InlineData(ExceptionType.Vulnerability, ExceptionReason.NetworkIsolation, PolicyExceptionEffectType.Downgrade)]
|
||||
public void GetEffect_ReturnsCorrectEffect_ForVulnerabilityType(
|
||||
ExceptionType type,
|
||||
ExceptionReason reason,
|
||||
PolicyExceptionEffectType expectedEffect)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect.Effect.Should().Be(expectedEffect);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Policy, ExceptionReason.FalsePositive, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Policy, ExceptionReason.AcceptedRisk, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Policy, ExceptionReason.CompensatingControl, PolicyExceptionEffectType.RequireControl)]
|
||||
[InlineData(ExceptionType.Policy, ExceptionReason.ScheduledFix, PolicyExceptionEffectType.Defer)]
|
||||
public void GetEffect_ReturnsCorrectEffect_ForPolicyType(
|
||||
ExceptionType type,
|
||||
ExceptionReason reason,
|
||||
PolicyExceptionEffectType expectedEffect)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect.Effect.Should().Be(expectedEffect);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Unknown, ExceptionReason.FalsePositive, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Unknown, ExceptionReason.ScheduledFix, PolicyExceptionEffectType.Defer)]
|
||||
public void GetEffect_ReturnsCorrectEffect_ForUnknownType(
|
||||
ExceptionType type,
|
||||
ExceptionReason reason,
|
||||
PolicyExceptionEffectType expectedEffect)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect.Effect.Should().Be(expectedEffect);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExceptionType.Component, ExceptionReason.DeprecationInProgress, PolicyExceptionEffectType.Suppress)]
|
||||
[InlineData(ExceptionType.Component, ExceptionReason.Other, PolicyExceptionEffectType.Suppress)] // License waiver
|
||||
public void GetEffect_ReturnsCorrectEffect_ForComponentType(
|
||||
ExceptionType type,
|
||||
ExceptionReason reason,
|
||||
PolicyExceptionEffectType expectedEffect)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect.Effect.Should().Be(expectedEffect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffect_ReturnsDefaultDeferral_ForUnmappedCombination()
|
||||
{
|
||||
// Note: All combinations are mapped, so we test a hypothetical case
|
||||
// by checking that the registry handles all known combinations
|
||||
var allTypes = Enum.GetValues<ExceptionType>();
|
||||
var allReasons = Enum.GetValues<ExceptionReason>();
|
||||
|
||||
foreach (var type in allTypes)
|
||||
{
|
||||
foreach (var reason in allReasons)
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffect(type, reason);
|
||||
|
||||
// Assert - should never be null
|
||||
effect.Should().NotBeNull();
|
||||
effect.Id.Should().NotBeNullOrEmpty();
|
||||
effect.Effect.Should().BeOneOf(
|
||||
PolicyExceptionEffectType.Suppress,
|
||||
PolicyExceptionEffectType.Defer,
|
||||
PolicyExceptionEffectType.Downgrade,
|
||||
PolicyExceptionEffectType.RequireControl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllEffects_ReturnsDistinctEffects()
|
||||
{
|
||||
// Act
|
||||
var allEffects = _registry.GetAllEffects();
|
||||
|
||||
// Assert
|
||||
allEffects.Should().NotBeEmpty();
|
||||
allEffects.Should().OnlyHaveUniqueItems(e => e.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectById_ReturnsEffect_WhenExists()
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffectById("suppress");
|
||||
|
||||
// Assert
|
||||
effect.Should().NotBeNull();
|
||||
effect!.Id.Should().Be("suppress");
|
||||
effect.Effect.Should().Be(PolicyExceptionEffectType.Suppress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectById_ReturnsNull_WhenNotExists()
|
||||
{
|
||||
// Act
|
||||
var effect = _registry.GetEffectById("non-existent-effect-id");
|
||||
|
||||
// Assert
|
||||
effect.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectById_IsCaseInsensitive()
|
||||
{
|
||||
// Act
|
||||
var effect1 = _registry.GetEffectById("SUPPRESS");
|
||||
var effect2 = _registry.GetEffectById("suppress");
|
||||
var effect3 = _registry.GetEffectById("Suppress");
|
||||
|
||||
// Assert
|
||||
effect1.Should().Be(effect2);
|
||||
effect2.Should().Be(effect3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Effects_HaveValidProperties()
|
||||
{
|
||||
// Act
|
||||
var allEffects = _registry.GetAllEffects();
|
||||
|
||||
// Assert
|
||||
foreach (var effect in allEffects)
|
||||
{
|
||||
effect.Id.Should().NotBeNullOrWhiteSpace();
|
||||
effect.Name.Should().NotBeNullOrWhiteSpace();
|
||||
effect.Description.Should().NotBeNullOrWhiteSpace();
|
||||
effect.MaxDurationDays.Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DowngradeEffects_HaveValidSeverity()
|
||||
{
|
||||
// Act
|
||||
var downgradeEffects = _registry.GetAllEffects()
|
||||
.Where(e => e.Effect == PolicyExceptionEffectType.Downgrade);
|
||||
|
||||
// Assert
|
||||
foreach (var effect in downgradeEffects)
|
||||
{
|
||||
effect.DowngradeSeverity.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequireControlEffects_HaveControlId()
|
||||
{
|
||||
// Act
|
||||
var requireControlEffects = _registry.GetAllEffects()
|
||||
.Where(e => e.Effect == PolicyExceptionEffectType.RequireControl);
|
||||
|
||||
// Assert
|
||||
foreach (var effect in requireControlEffects)
|
||||
{
|
||||
effect.RequiredControlId.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuppressEffects_DoNotRequireControl()
|
||||
{
|
||||
// Act
|
||||
var suppressEffects = _registry.GetAllEffects()
|
||||
.Where(e => e.Effect == PolicyExceptionEffectType.Suppress);
|
||||
|
||||
// Assert
|
||||
foreach (var effect in suppressEffects)
|
||||
{
|
||||
// Suppress effects should not require controls
|
||||
effect.RequiredControlId.Should().BeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
| WEB-AIAI-31-003 | DONE (2025-12-12) | Telemetry headers + prompt hash support; documented guardrail surface for audit visibility. |
|
||||
| WEB-CONSOLE-23-002 | DONE (2025-12-04) | console/status polling + run stream client/store/UI shipped; samples verified in `docs/api/console/samples/`. |
|
||||
| WEB-CONSOLE-23-003 | DONE (2025-12-07) | Exports client/store/service + models shipped; targeted Karma specs green locally with CHROME_BIN override (`node ./node_modules/@angular/cli/bin/ng.js test --watch=false --browsers=ChromeHeadless --include console-export specs`). Backend manifest/limits v0.4 published; awaiting final Policy/DevOps sign-off but UI/client slice complete. |
|
||||
| WEB-RISK-66-001 | BLOCKED (2025-12-03) | Same implementation landed; npm ci hangs so Angular tests can’t run; waiting on stable install environment and gateway endpoints to validate. |
|
||||
| WEB-RISK-66-001 | DONE (2025-12-20) | Gateway routing/client slice completed; Angular unit tests now run and pass (`npm test`), clearing the prior npm/CI blocker. |
|
||||
| WEB-EXC-25-001 | DONE (2025-12-12) | Exception contract + sample updated (`docs/api/console/exception-schema.md`); `ExceptionApiHttpClient` enforces scopes + trace/tenant headers with unit spec. |
|
||||
| WEB-EXC-25-002 | DONE (2025-12-12) | Contract + samples in `docs/api/gateway/policy-exceptions.md`; client + unit spec in `src/Web/StellaOps.Web/src/app/core/api/policy-exceptions.client.ts`. |
|
||||
| WEB-EXC-25-003 | DONE (2025-12-12) | Contract + samples in `docs/api/gateway/exception-events.md`; client + unit spec in `src/Web/StellaOps.Web/src/app/core/api/exception-events.client.ts`. |
|
||||
|
||||
@@ -209,11 +209,12 @@ export class MockReachabilityApi implements ReachabilityApi {
|
||||
|
||||
exportGraph(request: ExportGraphRequest): Observable<ExportGraphResult> {
|
||||
if (request.format === 'json') {
|
||||
return of({
|
||||
const result: ExportGraphResult = {
|
||||
format: 'json',
|
||||
data: JSON.stringify(mockCallGraph, null, 2),
|
||||
filename: `call-graph-${request.explanationId}.json`,
|
||||
}).pipe(delay(200));
|
||||
};
|
||||
return of(result).pipe(delay(200));
|
||||
}
|
||||
|
||||
if (request.format === 'dot') {
|
||||
@@ -225,11 +226,12 @@ export class MockReachabilityApi implements ReachabilityApi {
|
||||
|
||||
${mockEdges.map(e => `"${e.sourceId}" -> "${e.targetId}";`).join('\n ')}
|
||||
}`;
|
||||
return of({
|
||||
const result: ExportGraphResult = {
|
||||
format: 'dot',
|
||||
data: dotContent,
|
||||
filename: `call-graph-${request.explanationId}.dot`,
|
||||
}).pipe(delay(200));
|
||||
};
|
||||
return of(result).pipe(delay(200));
|
||||
}
|
||||
|
||||
// For PNG/SVG, return a placeholder data URL
|
||||
@@ -239,11 +241,12 @@ export class MockReachabilityApi implements ReachabilityApi {
|
||||
</svg>`;
|
||||
const dataUrl = `data:image/svg+xml;base64,${btoa(svgContent)}`;
|
||||
|
||||
return of({
|
||||
const result: ExportGraphResult = {
|
||||
format: request.format,
|
||||
dataUrl,
|
||||
filename: `call-graph-${request.explanationId}.${request.format}`,
|
||||
}).pipe(delay(400));
|
||||
};
|
||||
return of(result).pipe(delay(400));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,254 +1,184 @@
|
||||
/**
|
||||
* Tests for Proof Ledger View Component
|
||||
* Sprint: SPRINT_3500_0004_0002 - T8
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { signal } from '@angular/core';
|
||||
import { of, throwError, delay } from 'rxjs';
|
||||
import { delay, of } from 'rxjs';
|
||||
import { ProofLedgerViewComponent } from './proof-ledger-view.component';
|
||||
import { MANIFEST_API, PROOF_BUNDLE_API } from '../../core/api/proof.client';
|
||||
import { MANIFEST_API, PROOF_BUNDLE_API, ManifestApi, ProofBundleApi } from '../../core/api/proof.client';
|
||||
import { MerkleTree, ProofBundle, ProofVerificationResult, ScanManifest } from '../../core/api/proof.models';
|
||||
|
||||
describe('ProofLedgerViewComponent', () => {
|
||||
let component: ProofLedgerViewComponent;
|
||||
let fixture: ComponentFixture<ProofLedgerViewComponent>;
|
||||
let mockManifestApi: jasmine.SpyObj<any>;
|
||||
let mockProofBundleApi: jasmine.SpyObj<any>;
|
||||
let manifestApi: jasmine.SpyObj<ManifestApi>;
|
||||
let proofBundleApi: jasmine.SpyObj<ProofBundleApi>;
|
||||
|
||||
const mockManifest = {
|
||||
scanId: 'scan-123',
|
||||
imageRef: 'registry.example.com/app:v1.0.0',
|
||||
digest: 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
|
||||
scannedAt: new Date().toISOString(),
|
||||
const scanId = 'scan-123';
|
||||
|
||||
const mockManifest: ScanManifest = {
|
||||
manifestId: 'manifest-123',
|
||||
scanId,
|
||||
imageDigest: 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
|
||||
createdAt: '2025-12-18T09:22:00Z',
|
||||
hashes: [
|
||||
{ label: 'SBOM', algorithm: 'sha256', value: 'abc123...', source: 'sbom' },
|
||||
{ label: 'Layer 1', algorithm: 'sha256', value: 'def456...', source: 'layer' }
|
||||
{ label: 'SBOM', algorithm: 'sha256', value: 'sha256:abc123', source: 'sbom' },
|
||||
{ label: 'Layer 1', algorithm: 'sha256', value: 'sha256:def456', source: 'layer' },
|
||||
],
|
||||
dsseSignature: {
|
||||
keyId: 'key-001',
|
||||
algorithm: 'ecdsa-sha256',
|
||||
signature: 'MEUCIQDtest...',
|
||||
signedAt: new Date().toISOString(),
|
||||
verificationStatus: 'valid' as const
|
||||
}
|
||||
merkleRoot: 'sha256:root123',
|
||||
};
|
||||
|
||||
const mockMerkleTree = {
|
||||
treeId: 'tree-123',
|
||||
const mockMerkleTree: MerkleTree = {
|
||||
depth: 1,
|
||||
leafCount: 1,
|
||||
root: {
|
||||
nodeId: 'root',
|
||||
hash: 'root-hash-123',
|
||||
hash: 'sha256:root123',
|
||||
isRoot: true,
|
||||
isLeaf: false,
|
||||
level: 2,
|
||||
level: 0,
|
||||
position: 0,
|
||||
children: []
|
||||
children: [],
|
||||
},
|
||||
depth: 3,
|
||||
leafCount: 6,
|
||||
algorithm: 'sha256'
|
||||
};
|
||||
|
||||
const mockProofBundle = {
|
||||
const mockProofBundle: ProofBundle = {
|
||||
bundleId: 'bundle-123',
|
||||
scanId: 'scan-123',
|
||||
manifest: mockManifest,
|
||||
attestation: {},
|
||||
rekorEntry: { logIndex: 12345, integratedTime: new Date().toISOString() },
|
||||
createdAt: new Date().toISOString()
|
||||
scanId,
|
||||
createdAt: '2025-12-18T09:22:05Z',
|
||||
merkleRoot: mockManifest.merkleRoot,
|
||||
dsseEnvelope: 'ZHNzZS1lbmNsb3Bl',
|
||||
signatures: [
|
||||
{
|
||||
keyId: 'key-001',
|
||||
algorithm: 'ecdsa-sha256',
|
||||
status: 'valid',
|
||||
signedAt: '2025-12-18T09:22:05Z',
|
||||
},
|
||||
],
|
||||
rekorEntry: {
|
||||
logId: 'rekor-log-1',
|
||||
logIndex: 12345,
|
||||
integratedTime: '2025-12-18T09:23:00Z',
|
||||
logUrl: 'https://search.sigstore.dev/?logIndex=12345',
|
||||
bodyHash: 'sha256:body123',
|
||||
},
|
||||
verificationStatus: 'verified',
|
||||
downloadUrl: 'https://example.invalid/bundle-123',
|
||||
};
|
||||
|
||||
const mockVerificationResult: ProofVerificationResult = {
|
||||
bundleId: mockProofBundle.bundleId,
|
||||
verified: true,
|
||||
merkleRootValid: true,
|
||||
signatureValid: true,
|
||||
rekorInclusionValid: true,
|
||||
verifiedAt: '2025-12-18T09:24:00Z',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockManifestApi = jasmine.createSpyObj('ManifestApi', ['getManifest', 'getMerkleTree']);
|
||||
mockProofBundleApi = jasmine.createSpyObj('ProofBundleApi', ['getProofBundle', 'verifyProofBundle', 'downloadProofBundle']);
|
||||
manifestApi = jasmine.createSpyObj<ManifestApi>('ManifestApi', ['getManifest', 'getMerkleTree']);
|
||||
proofBundleApi = jasmine.createSpyObj<ProofBundleApi>('ProofBundleApi', ['getProofBundle', 'verifyProofBundle', 'downloadProofBundle']);
|
||||
|
||||
mockManifestApi.getManifest.and.returnValue(of(mockManifest));
|
||||
mockManifestApi.getMerkleTree.and.returnValue(of(mockMerkleTree));
|
||||
mockProofBundleApi.getProofBundle.and.returnValue(of(mockProofBundle));
|
||||
manifestApi.getManifest.and.returnValue(of(mockManifest).pipe(delay(1)));
|
||||
manifestApi.getMerkleTree.and.returnValue(of(mockMerkleTree));
|
||||
proofBundleApi.getProofBundle.and.returnValue(of(mockProofBundle).pipe(delay(1)));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProofLedgerViewComponent],
|
||||
providers: [
|
||||
{ provide: MANIFEST_API, useValue: mockManifestApi },
|
||||
{ provide: PROOF_BUNDLE_API, useValue: mockProofBundleApi }
|
||||
]
|
||||
{ provide: MANIFEST_API, useValue: manifestApi },
|
||||
{ provide: PROOF_BUNDLE_API, useValue: proofBundleApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProofLedgerViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
function loadComponent(): void {
|
||||
fixture.componentRef.setInput('scanId', scanId);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('shows loading state before proof bundle loads', fakeAsync(() => {
|
||||
loadComponent();
|
||||
|
||||
expect(fixture.debugElement.query(By.css('.proof-ledger__loading'))).toBeTruthy();
|
||||
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.query(By.css('.proof-ledger__loading'))).toBeNull();
|
||||
expect(fixture.debugElement.query(By.css('.proof-ledger__content'))).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('renders scan manifest and hash rows', fakeAsync(() => {
|
||||
loadComponent();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(scanId);
|
||||
|
||||
const hashRows = fixture.debugElement.queryAll(By.css('.proof-ledger__hash-row'));
|
||||
expect(hashRows.length).toBe(2);
|
||||
}));
|
||||
|
||||
it('toggles the Merkle tree display', fakeAsync(() => {
|
||||
loadComponent();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.query(By.css('.proof-ledger__tree'))).toBeNull();
|
||||
|
||||
const expandBtn = fixture.debugElement.query(By.css('.proof-ledger__expand-btn'));
|
||||
expandBtn.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.query(By.css('.proof-ledger__tree'))).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('verifies bundle and emits verification result', fakeAsync(() => {
|
||||
proofBundleApi.verifyProofBundle.and.returnValue(of(mockVerificationResult));
|
||||
|
||||
const emitSpy = spyOn(component.verificationComplete, 'emit');
|
||||
|
||||
loadComponent();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
const verifyBtn = fixture.debugElement.query(By.css('.proof-ledger__btn--verify'));
|
||||
verifyBtn.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(proofBundleApi.verifyProofBundle).toHaveBeenCalledWith(mockProofBundle.bundleId);
|
||||
expect(emitSpy).toHaveBeenCalledWith(mockVerificationResult);
|
||||
}));
|
||||
|
||||
it('downloads bundle and emits bundleDownloaded', fakeAsync(() => {
|
||||
proofBundleApi.downloadProofBundle.and.returnValue(of(new Blob(['{}'], { type: 'application/json' })));
|
||||
|
||||
const emitSpy = spyOn(component.bundleDownloaded, 'emit');
|
||||
const mockUrl = 'blob:mock-url';
|
||||
spyOn(URL, 'createObjectURL').and.returnValue(mockUrl);
|
||||
spyOn(URL, 'revokeObjectURL');
|
||||
|
||||
loadComponent();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
spyOn(anchor, 'click');
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
spyOn(document, 'createElement').and.callFake((tagName: string) => {
|
||||
if (tagName.toLowerCase() === 'a') return anchor;
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
|
||||
it('should show loading state initially', () => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
const downloadBtn = fixture.debugElement.query(By.css('.proof-ledger__btn--download'));
|
||||
downloadBtn.nativeElement.click();
|
||||
|
||||
const loading = fixture.debugElement.query(By.css('.proof-ledger__loading'));
|
||||
expect(loading).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load manifest on init', fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockManifestApi.getManifest).toHaveBeenCalledWith('scan-123');
|
||||
}));
|
||||
|
||||
it('should display manifest data after loading', fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const digest = fixture.debugElement.query(By.css('.proof-ledger__digest code'));
|
||||
expect(digest.nativeElement.textContent).toContain('sha256:a1b2c3');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Hash Display', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should display all hashes', () => {
|
||||
const hashItems = fixture.debugElement.queryAll(By.css('.proof-ledger__hash-item'));
|
||||
expect(hashItems.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should have copy button for each hash', () => {
|
||||
const copyButtons = fixture.debugElement.queryAll(By.css('.proof-ledger__copy-btn'));
|
||||
expect(copyButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Merkle Tree', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should load merkle tree', () => {
|
||||
expect(mockManifestApi.getMerkleTree).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display merkle tree section', () => {
|
||||
const merkleSection = fixture.debugElement.query(By.css('.proof-ledger__merkle'));
|
||||
expect(merkleSection).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DSSE Signature', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should display signature status', () => {
|
||||
const signatureStatus = fixture.debugElement.query(By.css('.proof-ledger__sig-status'));
|
||||
expect(signatureStatus).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show valid status with correct styling', () => {
|
||||
const validStatus = fixture.debugElement.query(By.css('.proof-ledger__sig-status--valid'));
|
||||
expect(validStatus).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Actions', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have verify button', () => {
|
||||
const verifyBtn = fixture.debugElement.query(By.css('.proof-ledger__verify-btn'));
|
||||
expect(verifyBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have download button', () => {
|
||||
const downloadBtn = fixture.debugElement.query(By.css('.proof-ledger__download-btn'));
|
||||
expect(downloadBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call verify on button click', fakeAsync(() => {
|
||||
mockProofBundleApi.verifyProofBundle.and.returnValue(of({ valid: true }));
|
||||
|
||||
const verifyBtn = fixture.debugElement.query(By.css('.proof-ledger__verify-btn'));
|
||||
verifyBtn.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(mockProofBundleApi.verifyProofBundle).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should display error when manifest fails to load', fakeAsync(() => {
|
||||
mockManifestApi.getManifest.and.returnValue(throwError(() => new Error('Network error')));
|
||||
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const error = fixture.debugElement.query(By.css('.proof-ledger__error'));
|
||||
expect(error).toBeTruthy();
|
||||
expect(error.nativeElement.textContent).toContain('Network error');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have accessible heading structure', () => {
|
||||
const h3 = fixture.debugElement.query(By.css('h3'));
|
||||
expect(h3).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have aria-label on icon buttons', () => {
|
||||
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||
buttons.forEach(button => {
|
||||
const hasLabel = button.nativeElement.hasAttribute('aria-label') ||
|
||||
button.nativeElement.hasAttribute('title') ||
|
||||
button.nativeElement.textContent.trim().length > 0;
|
||||
expect(hasLabel).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have proper role on status elements', () => {
|
||||
const loading = fixture.debugElement.query(By.css('[role="status"]'));
|
||||
// Loading should have role="status"
|
||||
});
|
||||
|
||||
it('should have role="alert" on error messages', fakeAsync(() => {
|
||||
mockManifestApi.getManifest.and.returnValue(throwError(() => new Error('Error')));
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const error = fixture.debugElement.query(By.css('[role="alert"]'));
|
||||
expect(error).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
expect(proofBundleApi.downloadProofBundle).toHaveBeenCalledWith(mockProofBundle.bundleId);
|
||||
expect(anchor.download).toContain(scanId);
|
||||
expect(anchor.click).toHaveBeenCalled();
|
||||
expect(emitSpy).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -116,12 +116,20 @@ interface TreeViewState {
|
||||
<code class="proof-ledger__value">{{ manifest()!.scanId }}</code>
|
||||
</div>
|
||||
<div class="proof-ledger__manifest-row">
|
||||
<span class="proof-ledger__label">Timestamp:</span>
|
||||
<time class="proof-ledger__value">{{ manifest()!.timestamp | date:'medium' }}</time>
|
||||
<span class="proof-ledger__label">Image Digest:</span>
|
||||
<code class="proof-ledger__value" [title]="manifest()!.imageDigest">
|
||||
{{ manifest()!.imageDigest | slice:0:16 }}...{{ manifest()!.imageDigest | slice:-8 }}
|
||||
</code>
|
||||
</div>
|
||||
<div class="proof-ledger__manifest-row">
|
||||
<span class="proof-ledger__label">Algorithm:</span>
|
||||
<code class="proof-ledger__value">{{ manifest()!.algorithmVersion }}</code>
|
||||
<span class="proof-ledger__label">Created At:</span>
|
||||
<time class="proof-ledger__value">{{ manifest()!.createdAt | date:'medium' }}</time>
|
||||
</div>
|
||||
<div class="proof-ledger__manifest-row">
|
||||
<span class="proof-ledger__label">Merkle Root:</span>
|
||||
<code class="proof-ledger__value" [title]="manifest()!.merkleRoot">
|
||||
{{ manifest()!.merkleRoot | slice:0:16 }}...{{ manifest()!.merkleRoot | slice:-8 }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -194,20 +202,32 @@ interface TreeViewState {
|
||||
<h3 class="proof-ledger__section-title">
|
||||
<span aria-hidden="true">✍️</span> DSSE Signature
|
||||
</h3>
|
||||
@if (proofBundle()?.dsseSignature) {
|
||||
@if (proofBundle()?.signatures?.length) {
|
||||
<div class="proof-ledger__signature">
|
||||
<div class="proof-ledger__sig-row">
|
||||
<span class="proof-ledger__label">Key ID:</span>
|
||||
<code class="proof-ledger__value">{{ proofBundle()!.dsseSignature.keyId }}</code>
|
||||
<code class="proof-ledger__value">{{ proofBundle()!.signatures[0].keyId }}</code>
|
||||
</div>
|
||||
<div class="proof-ledger__sig-row">
|
||||
<span class="proof-ledger__label">Algorithm:</span>
|
||||
<code class="proof-ledger__value">{{ proofBundle()!.dsseSignature.algorithm }}</code>
|
||||
<code class="proof-ledger__value">{{ proofBundle()!.signatures[0].algorithm }}</code>
|
||||
</div>
|
||||
<div class="proof-ledger__sig-row">
|
||||
<span class="proof-ledger__label">Timestamp:</span>
|
||||
<time class="proof-ledger__value">{{ proofBundle()!.dsseSignature.timestamp | date:'medium' }}</time>
|
||||
<span class="proof-ledger__label">Status:</span>
|
||||
<code class="proof-ledger__value">{{ proofBundle()!.signatures[0].status }}</code>
|
||||
</div>
|
||||
@if (proofBundle()!.signatures[0].signedAt) {
|
||||
<div class="proof-ledger__sig-row">
|
||||
<span class="proof-ledger__label">Signed At:</span>
|
||||
<time class="proof-ledger__value">{{ proofBundle()!.signatures[0].signedAt | date:'medium' }}</time>
|
||||
</div>
|
||||
}
|
||||
@if (proofBundle()!.signatures[0].expiresAt) {
|
||||
<div class="proof-ledger__sig-row">
|
||||
<span class="proof-ledger__label">Expires At:</span>
|
||||
<time class="proof-ledger__value">{{ proofBundle()!.signatures[0].expiresAt | date:'medium' }}</time>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="proof-ledger__no-sig">No DSSE signature available</p>
|
||||
@@ -559,7 +579,7 @@ export class ProofLedgerViewComponent implements OnInit {
|
||||
readonly verificationStatus = computed(() => {
|
||||
const result = this.verificationResult();
|
||||
if (!result) return 'pending';
|
||||
return result.valid ? 'verified' : 'failed';
|
||||
return result.verified ? 'verified' : 'failed';
|
||||
});
|
||||
|
||||
readonly verificationStatusText = computed(() => {
|
||||
@@ -573,9 +593,8 @@ export class ProofLedgerViewComponent implements OnInit {
|
||||
|
||||
readonly rekorLink = computed(() => {
|
||||
const bundle = this.proofBundle();
|
||||
return bundle?.rekorLogId
|
||||
? `https://search.sigstore.dev/?logIndex=${bundle.rekorLogId}`
|
||||
: null;
|
||||
const entry = bundle?.rekorEntry;
|
||||
return entry ? entry.logUrl : null;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
@@ -1,486 +1,130 @@
|
||||
/**
|
||||
* Tests for Proof Replay Dashboard Component
|
||||
* Sprint: SPRINT_3500_0004_0002 - T8
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick, flush, discardPeriodicTasks } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { of, throwError, Subject } from 'rxjs';
|
||||
import { ProofReplayDashboardComponent, REPLAY_API, ReplayJob, ReplayResult } from './proof-replay-dashboard.component';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
ProofReplayDashboardComponent,
|
||||
REPLAY_API,
|
||||
ReplayApi,
|
||||
ReplayHistoryEntry,
|
||||
ReplayJob,
|
||||
ReplayResult,
|
||||
} from './proof-replay-dashboard.component';
|
||||
|
||||
describe('ProofReplayDashboardComponent', () => {
|
||||
let component: ProofReplayDashboardComponent;
|
||||
let fixture: ComponentFixture<ProofReplayDashboardComponent>;
|
||||
let mockReplayApi: jasmine.SpyObj<any>;
|
||||
let replayApi: jasmine.SpyObj<ReplayApi>;
|
||||
|
||||
const mockJob: ReplayJob = {
|
||||
id: 'replay-001',
|
||||
scanId: 'scan-123',
|
||||
digest: 'sha256:abc123...',
|
||||
imageRef: 'registry.example.com/app:v1.0.0',
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
currentStep: 'advisory-merge',
|
||||
totalSteps: 8,
|
||||
completedSteps: 3,
|
||||
steps: [
|
||||
{ name: 'sbom-gen', status: 'completed', duration: 12500 },
|
||||
{ name: 'scanner-run', status: 'completed', duration: 45200 },
|
||||
{ name: 'vex-apply', status: 'completed', duration: 8300 },
|
||||
{ name: 'advisory-merge', status: 'running', duration: 0 },
|
||||
{ name: 'reachability', status: 'pending' },
|
||||
{ name: 'scoring', status: 'pending' },
|
||||
{ name: 'attestation', status: 'pending' },
|
||||
{ name: 'proof-seal', status: 'pending' }
|
||||
]
|
||||
};
|
||||
const now = '2025-12-20T00:00:00Z';
|
||||
|
||||
const mockResult: ReplayResult = {
|
||||
jobId: 'replay-001',
|
||||
scanId: 'scan-123',
|
||||
status: 'passed',
|
||||
originalDigest: 'sha256:abc123...',
|
||||
replayDigest: 'sha256:abc123...',
|
||||
digestMatch: true,
|
||||
completedAt: new Date().toISOString(),
|
||||
totalDuration: 185000,
|
||||
stepTimings: {
|
||||
'sbom-gen': { original: 12500, replay: 12480, delta: -20 },
|
||||
'scanner-run': { original: 45200, replay: 45150, delta: -50 },
|
||||
'vex-apply': { original: 8300, replay: 8310, delta: 10 },
|
||||
'advisory-merge': { original: 22000, replay: 22100, delta: 100 },
|
||||
'reachability': { original: 35000, replay: 34950, delta: -50 },
|
||||
'scoring': { original: 15000, replay: 15020, delta: 20 },
|
||||
'attestation': { original: 28000, replay: 27990, delta: -10 },
|
||||
'proof-seal': { original: 19000, replay: 19000, delta: 0 }
|
||||
},
|
||||
artifacts: [
|
||||
{ name: 'SBOM', originalHash: 'sha256:sbom111...', replayHash: 'sha256:sbom111...', match: true },
|
||||
{ name: 'Scanner Report', originalHash: 'sha256:scan222...', replayHash: 'sha256:scan222...', match: true },
|
||||
{ name: 'VEX Document', originalHash: 'sha256:vex333...', replayHash: 'sha256:vex333...', match: true },
|
||||
{ name: 'Attestation', originalHash: 'sha256:att444...', replayHash: 'sha256:att444...', match: true }
|
||||
],
|
||||
driftItems: []
|
||||
};
|
||||
|
||||
const mockHistory = [
|
||||
{ jobId: 'replay-001', scanId: 'scan-123', status: 'passed', completedAt: new Date().toISOString(), digestMatch: true },
|
||||
{ jobId: 'replay-002', scanId: 'scan-456', status: 'passed', completedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), digestMatch: true },
|
||||
{ jobId: 'replay-003', scanId: 'scan-789', status: 'failed', completedAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(), digestMatch: false }
|
||||
const history: ReplayHistoryEntry[] = [
|
||||
{ jobId: 'job-0', scanId: 'scan-123', triggeredAt: now, status: 'completed', matched: true, driftCount: 0 },
|
||||
];
|
||||
|
||||
const queuedJob: ReplayJob = {
|
||||
jobId: 'job-1',
|
||||
scanId: 'scan-123',
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
currentStep: 'queued',
|
||||
startedAt: now,
|
||||
};
|
||||
|
||||
const completedJob: ReplayJob = {
|
||||
...queuedJob,
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
currentStep: 'completed',
|
||||
completedAt: now,
|
||||
};
|
||||
|
||||
const result: ReplayResult = {
|
||||
jobId: 'job-1',
|
||||
scanId: 'scan-123',
|
||||
originalDigest: 'sha256:aaa',
|
||||
replayDigest: 'sha256:aaa',
|
||||
matched: true,
|
||||
drifts: [],
|
||||
timing: {
|
||||
totalMs: 1234,
|
||||
phases: [{ name: 'replay', durationMs: 1234, percentOfTotal: 100 }],
|
||||
},
|
||||
artifacts: [
|
||||
{
|
||||
name: 'SBOM',
|
||||
type: 'sbom',
|
||||
originalPath: '/original/sbom.json',
|
||||
replayPath: '/replay/sbom.json',
|
||||
matched: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockReplayApi = jasmine.createSpyObj('ReplayApi', ['triggerReplay', 'getReplayStatus', 'getReplayResult', 'getReplayHistory', 'cancelReplay']);
|
||||
mockReplayApi.triggerReplay.and.returnValue(of(mockJob));
|
||||
mockReplayApi.getReplayStatus.and.returnValue(of(mockJob));
|
||||
mockReplayApi.getReplayResult.and.returnValue(of(mockResult));
|
||||
mockReplayApi.getReplayHistory.and.returnValue(of(mockHistory));
|
||||
mockReplayApi.cancelReplay.and.returnValue(of({ success: true }));
|
||||
replayApi = jasmine.createSpyObj<ReplayApi>('ReplayApi', [
|
||||
'triggerReplay',
|
||||
'getJobStatus',
|
||||
'getResult',
|
||||
'getHistory',
|
||||
'cancelJob',
|
||||
]);
|
||||
|
||||
replayApi.getHistory.and.returnValue(of(history));
|
||||
replayApi.triggerReplay.and.returnValue(of(queuedJob));
|
||||
replayApi.getJobStatus.and.returnValue(of(completedJob));
|
||||
replayApi.getResult.and.returnValue(of(result));
|
||||
replayApi.cancelJob.and.returnValue(of(void 0));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProofReplayDashboardComponent],
|
||||
providers: [
|
||||
{ provide: REPLAY_API, useValue: mockReplayApi }
|
||||
]
|
||||
providers: [{ provide: REPLAY_API, useValue: replayApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProofReplayDashboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure periodic timers are cleaned up
|
||||
it('creates', () => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
it('loads history on init', () => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
|
||||
it('should load replay history on init', fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockReplayApi.getReplayHistory).toHaveBeenCalledWith('scan-123');
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display scan info header', fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.componentRef.setInput('imageRef', 'registry.example.com/app:v1.0.0');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const header = fixture.debugElement.query(By.css('.proof-replay__header'));
|
||||
expect(header).toBeTruthy();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
expect(replayApi.getHistory).toHaveBeenCalledWith('scan-123');
|
||||
expect(component.history()).toEqual(history);
|
||||
});
|
||||
|
||||
describe('Trigger Replay', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
it('triggers replay and loads result when completed', fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
component.triggerReplay();
|
||||
|
||||
it('should have trigger replay button', () => {
|
||||
const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn'));
|
||||
expect(button).toBeTruthy();
|
||||
});
|
||||
expect(replayApi.triggerReplay).toHaveBeenCalledWith('scan-123');
|
||||
expect(component.currentJob()?.jobId).toBe('job-1');
|
||||
|
||||
it('should trigger replay on button click', fakeAsync(() => {
|
||||
const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn'));
|
||||
button.nativeElement.click();
|
||||
tick();
|
||||
tick(1000);
|
||||
|
||||
expect(mockReplayApi.triggerReplay).toHaveBeenCalledWith('scan-123');
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
expect(replayApi.getJobStatus).toHaveBeenCalledWith('job-1');
|
||||
expect(replayApi.getResult).toHaveBeenCalledWith('job-1');
|
||||
expect(component.result()).toEqual(result);
|
||||
|
||||
it('should disable button while replay is running', fakeAsync(() => {
|
||||
const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn'));
|
||||
button.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
expect(button.nativeElement.disabled).toBeTrue();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
});
|
||||
it('cancels an in-flight replay job', () => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
|
||||
describe('Progress Display', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
component.currentJob.set({ ...queuedJob, status: 'running', progress: 50, currentStep: 'replay' });
|
||||
|
||||
// Trigger a replay
|
||||
const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn'));
|
||||
button.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
component.cancelReplay();
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display progress bar', () => {
|
||||
const progressBar = fixture.debugElement.query(By.css('.proof-replay__progress'));
|
||||
expect(progressBar).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display current step', () => {
|
||||
const currentStep = fixture.debugElement.query(By.css('.proof-replay__current-step'));
|
||||
expect(currentStep).toBeTruthy();
|
||||
expect(currentStep.nativeElement.textContent).toContain('advisory-merge');
|
||||
});
|
||||
|
||||
it('should display step list', () => {
|
||||
const steps = fixture.debugElement.queryAll(By.css('.proof-replay__step'));
|
||||
expect(steps.length).toBe(8);
|
||||
});
|
||||
|
||||
it('should show completed steps with checkmark', () => {
|
||||
const completedSteps = fixture.debugElement.queryAll(By.css('.proof-replay__step--completed'));
|
||||
expect(completedSteps.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should show running step with spinner', () => {
|
||||
const runningStep = fixture.debugElement.query(By.css('.proof-replay__step--running'));
|
||||
expect(runningStep).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Result Display', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
// Set result directly for testing
|
||||
component.result.set(mockResult);
|
||||
component.activeJob.set(null);
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display result status', () => {
|
||||
const status = fixture.debugElement.query(By.css('.proof-replay__result-status'));
|
||||
expect(status).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show passed status styling', () => {
|
||||
const status = fixture.debugElement.query(By.css('.proof-replay__result-status--passed'));
|
||||
expect(status).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display digest comparison', () => {
|
||||
const digestSection = fixture.debugElement.query(By.css('.proof-replay__digest-comparison'));
|
||||
expect(digestSection).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show digest match indicator', () => {
|
||||
const matchBadge = fixture.debugElement.query(By.css('.proof-replay__digest-match'));
|
||||
expect(matchBadge).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timing Breakdown', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.result.set(mockResult);
|
||||
component.activeJob.set(null);
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display timing table', () => {
|
||||
const timingTable = fixture.debugElement.query(By.css('.proof-replay__timing-table'));
|
||||
expect(timingTable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display all step timings', () => {
|
||||
const rows = fixture.debugElement.queryAll(By.css('.proof-replay__timing-row'));
|
||||
expect(rows.length).toBe(8);
|
||||
});
|
||||
|
||||
it('should show timing deltas', () => {
|
||||
const deltas = fixture.debugElement.queryAll(By.css('.proof-replay__timing-delta'));
|
||||
expect(deltas.length).toBe(8);
|
||||
});
|
||||
|
||||
it('should format durations properly', () => {
|
||||
const durations = fixture.debugElement.queryAll(By.css('.proof-replay__timing-value'));
|
||||
expect(durations.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Artifact Comparison', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.result.set(mockResult);
|
||||
component.activeJob.set(null);
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display artifact table', () => {
|
||||
const artifactTable = fixture.debugElement.query(By.css('.proof-replay__artifact-table'));
|
||||
expect(artifactTable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display all artifacts', () => {
|
||||
const rows = fixture.debugElement.queryAll(By.css('.proof-replay__artifact-row'));
|
||||
expect(rows.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should show match status for each artifact', () => {
|
||||
const matchBadges = fixture.debugElement.queryAll(By.css('.proof-replay__artifact-match'));
|
||||
expect(matchBadges.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drift Detection', () => {
|
||||
it('should display drift warning when drift detected', fakeAsync(() => {
|
||||
const resultWithDrift: ReplayResult = {
|
||||
...mockResult,
|
||||
status: 'drift',
|
||||
digestMatch: false,
|
||||
replayDigest: 'sha256:xyz789...',
|
||||
driftItems: [
|
||||
{ artifact: 'Scanner Report', field: 'timestamp', original: '2024-01-01T00:00:00Z', replay: '2024-01-01T00:00:01Z', severity: 'warning' },
|
||||
{ artifact: 'Attestation', field: 'signature', original: 'sig-aaa', replay: 'sig-bbb', severity: 'error' }
|
||||
]
|
||||
};
|
||||
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.result.set(resultWithDrift);
|
||||
component.activeJob.set(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
const driftWarning = fixture.debugElement.query(By.css('.proof-replay__drift-warning'));
|
||||
expect(driftWarning).toBeTruthy();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should list drift items', fakeAsync(() => {
|
||||
const resultWithDrift: ReplayResult = {
|
||||
...mockResult,
|
||||
status: 'drift',
|
||||
driftItems: [
|
||||
{ artifact: 'Scanner Report', field: 'timestamp', original: '2024-01-01T00:00:00Z', replay: '2024-01-01T00:00:01Z', severity: 'warning' }
|
||||
]
|
||||
};
|
||||
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.result.set(resultWithDrift);
|
||||
component.activeJob.set(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
const driftItems = fixture.debugElement.queryAll(By.css('.proof-replay__drift-item'));
|
||||
expect(driftItems.length).toBe(1);
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('History Table', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display history table', () => {
|
||||
const historyTable = fixture.debugElement.query(By.css('.proof-replay__history'));
|
||||
expect(historyTable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display history rows', () => {
|
||||
const rows = fixture.debugElement.queryAll(By.css('.proof-replay__history-row'));
|
||||
expect(rows.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should show status badges in history', () => {
|
||||
const badges = fixture.debugElement.queryAll(By.css('.proof-replay__history-status'));
|
||||
expect(badges.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should allow selecting a history item', fakeAsync(() => {
|
||||
const firstRow = fixture.debugElement.queryAll(By.css('.proof-replay__history-row'))[0];
|
||||
firstRow.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockReplayApi.getReplayResult).toHaveBeenCalled();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Cancel Replay', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
// Trigger a replay
|
||||
const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn'));
|
||||
button.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display cancel button when replay is running', () => {
|
||||
const cancelBtn = fixture.debugElement.query(By.css('.proof-replay__cancel-btn'));
|
||||
expect(cancelBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should cancel replay on button click', fakeAsync(() => {
|
||||
const cancelBtn = fixture.debugElement.query(By.css('.proof-replay__cancel-btn'));
|
||||
cancelBtn.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(mockReplayApi.cancelReplay).toHaveBeenCalledWith('replay-001');
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should display error on replay failure', fakeAsync(() => {
|
||||
mockReplayApi.triggerReplay.and.returnValue(throwError(() => new Error('Replay failed')));
|
||||
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn'));
|
||||
button.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const error = fixture.debugElement.query(By.css('.proof-replay__error'));
|
||||
expect(error).toBeTruthy();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should have proper heading hierarchy', () => {
|
||||
const headings = fixture.debugElement.queryAll(By.css('h2, h3, h4'));
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have accessible buttons', () => {
|
||||
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||
buttons.forEach(button => {
|
||||
const hasText = button.nativeElement.textContent.trim().length > 0;
|
||||
const hasAriaLabel = button.nativeElement.hasAttribute('aria-label');
|
||||
expect(hasText || hasAriaLabel).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have proper table structure', () => {
|
||||
const tables = fixture.debugElement.queryAll(By.css('table'));
|
||||
tables.forEach(table => {
|
||||
const thead = table.query(By.css('thead'));
|
||||
expect(thead).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should announce progress updates', () => {
|
||||
const liveRegion = fixture.debugElement.query(By.css('[aria-live]'));
|
||||
expect(liveRegion).toBeTruthy();
|
||||
});
|
||||
expect(replayApi.cancelJob).toHaveBeenCalledWith('job-1');
|
||||
expect(component.currentJob()?.status).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ describe('PathViewerComponent', () => {
|
||||
sink: mockSink,
|
||||
intermediateCount: 5,
|
||||
keyNodes: [mockKeyNode],
|
||||
fullPath: ['entry-1', 'mid-1', 'mid-2', 'key-1', 'mid-3', 'sink-1']
|
||||
fullPath: ['entry-1', 'mid-1', 'mid-2', 'key-1', 'mid-3', 'sink-1'],
|
||||
length: 6,
|
||||
confidence: 0.92,
|
||||
hasGates: false
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -94,7 +97,7 @@ describe('PathViewerComponent', () => {
|
||||
|
||||
it('should emit nodeClick when node is clicked', () => {
|
||||
fixture.detectChanges();
|
||||
const emitSpy = jest.spyOn(component.nodeClick, 'emit');
|
||||
const emitSpy = spyOn(component.nodeClick, 'emit');
|
||||
|
||||
component.onNodeClick(mockKeyNode);
|
||||
|
||||
@@ -103,7 +106,7 @@ describe('PathViewerComponent', () => {
|
||||
|
||||
it('should emit expandRequest when toggling expand', () => {
|
||||
fixture.detectChanges();
|
||||
const emitSpy = jest.spyOn(component.expandRequest, 'emit');
|
||||
const emitSpy = spyOn(component.expandRequest, 'emit');
|
||||
|
||||
component.toggleExpand();
|
||||
|
||||
|
||||
@@ -1,60 +1,104 @@
|
||||
/**
|
||||
* RiskDriftCardComponent Unit Tests
|
||||
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
* Task: UI-013
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RiskDriftCardComponent } from './risk-drift-card.component';
|
||||
import { DriftResult, DriftedSink, DriftSummary } from '../../models/drift.models';
|
||||
import { CompressedPath, PathNode } from '../../models/path-viewer.models';
|
||||
|
||||
describe('RiskDriftCardComponent', () => {
|
||||
let fixture: ComponentFixture<RiskDriftCardComponent>;
|
||||
let component: RiskDriftCardComponent;
|
||||
|
||||
const mockSink1: DriftedSink = {
|
||||
sinkId: 'sink-1',
|
||||
sinkSymbol: 'SqlCommand.Execute',
|
||||
driftKind: 'became_reachable',
|
||||
riskDelta: 0.25,
|
||||
severity: 'high',
|
||||
cveId: 'CVE-2021-12345',
|
||||
pathCount: 2
|
||||
const entrypoint: PathNode = {
|
||||
nodeId: 'entry-1',
|
||||
symbol: 'Program.Main',
|
||||
isChanged: false,
|
||||
nodeType: 'entrypoint',
|
||||
};
|
||||
|
||||
const mockSink2: DriftedSink = {
|
||||
sinkId: 'sink-2',
|
||||
sinkSymbol: 'ProcessBuilder.start',
|
||||
driftKind: 'became_unreachable',
|
||||
riskDelta: -0.15,
|
||||
severity: 'critical',
|
||||
pathCount: 1
|
||||
};
|
||||
const makeSink = (nodeId: string, symbol: string): PathNode => ({
|
||||
nodeId,
|
||||
symbol,
|
||||
isChanged: false,
|
||||
nodeType: 'sink',
|
||||
});
|
||||
|
||||
const mockSink3: DriftedSink = {
|
||||
sinkId: 'sink-3',
|
||||
sinkSymbol: 'Runtime.exec',
|
||||
driftKind: 'became_reachable',
|
||||
riskDelta: 0.10,
|
||||
severity: 'medium',
|
||||
pathCount: 3
|
||||
};
|
||||
const makePath = (sink: PathNode, confidence: number): CompressedPath => ({
|
||||
entrypoint,
|
||||
sink,
|
||||
intermediateCount: 0,
|
||||
keyNodes: [],
|
||||
fullPath: [entrypoint.nodeId, sink.nodeId],
|
||||
length: 2,
|
||||
confidence,
|
||||
hasGates: false,
|
||||
});
|
||||
|
||||
const sink1 = makeSink('sink-1', 'SqlCommand.Execute');
|
||||
const sink2 = makeSink('sink-2', 'ProcessBuilder.start');
|
||||
const sink3 = makeSink('sink-3', 'Runtime.exec');
|
||||
|
||||
const mockSinks: DriftedSink[] = [
|
||||
{
|
||||
sink: sink1,
|
||||
previousBucket: 'unreachable',
|
||||
currentBucket: 'runtime',
|
||||
cveId: 'CVE-2021-12345',
|
||||
severity: 'high',
|
||||
paths: [makePath(sink1, 0.92)],
|
||||
isRiskIncrease: true,
|
||||
riskDelta: 0.25,
|
||||
newPathCount: 2,
|
||||
removedPathCount: 0,
|
||||
},
|
||||
{
|
||||
sink: sink2,
|
||||
previousBucket: 'runtime',
|
||||
currentBucket: 'unreachable',
|
||||
severity: 'critical',
|
||||
paths: [makePath(sink2, 0.77)],
|
||||
isRiskIncrease: false,
|
||||
riskDelta: -0.15,
|
||||
newPathCount: 0,
|
||||
removedPathCount: 1,
|
||||
},
|
||||
{
|
||||
sink: sink3,
|
||||
previousBucket: null,
|
||||
currentBucket: 'runtime',
|
||||
severity: 'medium',
|
||||
paths: [makePath(sink3, 0.81)],
|
||||
isRiskIncrease: true,
|
||||
riskDelta: 0.1,
|
||||
newPathCount: 3,
|
||||
removedPathCount: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const mockSummary: DriftSummary = {
|
||||
totalDrifts: 3,
|
||||
newlyReachable: 2,
|
||||
newlyUnreachable: 1,
|
||||
totalSinks: 3,
|
||||
increasedReachability: 2,
|
||||
decreasedReachability: 1,
|
||||
unchangedReachability: 0,
|
||||
newSinks: 1,
|
||||
removedSinks: 0,
|
||||
riskTrend: 'increasing',
|
||||
baselineScanId: 'scan-base',
|
||||
currentScanId: 'scan-current'
|
||||
netRiskDelta: 0.2,
|
||||
bySeverity: {
|
||||
critical: 1,
|
||||
high: 1,
|
||||
medium: 1,
|
||||
low: 0,
|
||||
info: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const mockDriftResult: DriftResult = {
|
||||
id: 'drift-1',
|
||||
comparedAt: '2025-12-19T12:00:00Z',
|
||||
baseGraphId: 'graph-base',
|
||||
headGraphId: 'graph-head',
|
||||
driftedSinks: mockSinks,
|
||||
summary: mockSummary,
|
||||
driftedSinks: [mockSink1, mockSink2, mockSink3],
|
||||
attestationDigest: 'sha256:abc123',
|
||||
createdAt: '2025-12-19T12:00:00Z'
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -67,123 +111,112 @@ describe('RiskDriftCardComponent', () => {
|
||||
fixture.componentRef.setInput('drift', mockDriftResult);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
it('creates', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should compute summary from drift', () => {
|
||||
it('computes summary from drift', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.summary()).toEqual(mockSummary);
|
||||
});
|
||||
|
||||
it('should detect signed attestation', () => {
|
||||
it('detects signed attestation', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.isSigned()).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect unsigned drift when no attestation', () => {
|
||||
const unsignedDrift = { ...mockDriftResult, attestationDigest: undefined };
|
||||
fixture.componentRef.setInput('drift', unsignedDrift);
|
||||
it('detects unsigned drift when no attestation', () => {
|
||||
fixture.componentRef.setInput('drift', { ...mockDriftResult, attestationDigest: undefined });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isSigned()).toBe(false);
|
||||
});
|
||||
|
||||
it('should show upward trend icon for increasing risk', () => {
|
||||
it('shows upward trend icon for increasing risk', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.trendIcon()).toBe('↑');
|
||||
});
|
||||
|
||||
it('should show downward trend icon for decreasing risk', () => {
|
||||
const decreasingDrift = {
|
||||
...mockDriftResult,
|
||||
summary: { ...mockSummary, riskTrend: 'decreasing' as const }
|
||||
};
|
||||
fixture.componentRef.setInput('drift', decreasingDrift);
|
||||
it('shows downward trend icon for decreasing risk', () => {
|
||||
fixture.componentRef.setInput('drift', { ...mockDriftResult, summary: { ...mockSummary, riskTrend: 'decreasing' } });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.trendIcon()).toBe('↓');
|
||||
});
|
||||
|
||||
it('should show stable trend icon for stable risk', () => {
|
||||
const stableDrift = {
|
||||
...mockDriftResult,
|
||||
summary: { ...mockSummary, riskTrend: 'stable' as const }
|
||||
};
|
||||
fixture.componentRef.setInput('drift', stableDrift);
|
||||
it('shows stable trend icon for stable risk', () => {
|
||||
fixture.componentRef.setInput('drift', { ...mockDriftResult, summary: { ...mockSummary, riskTrend: 'stable' } });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.trendIcon()).toBe('→');
|
||||
});
|
||||
|
||||
it('should compute trend CSS class correctly', () => {
|
||||
it('computes trend CSS class correctly', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.trendClass()).toBe('risk-drift-card__trend--increasing');
|
||||
});
|
||||
|
||||
it('should show max preview sinks (default 3)', () => {
|
||||
it('shows max preview sinks (default 3)', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.previewSinks().length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should respect custom maxPreviewSinks', () => {
|
||||
it('respects custom maxPreviewSinks', () => {
|
||||
fixture.componentRef.setInput('maxPreviewSinks', 1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.previewSinks().length).toBe(1);
|
||||
});
|
||||
|
||||
it('should sort preview sinks by severity first', () => {
|
||||
it('sorts preview sinks by severity first', () => {
|
||||
fixture.detectChanges();
|
||||
const sinks = component.previewSinks();
|
||||
|
||||
// Critical should come before high
|
||||
const criticalIndex = sinks.findIndex(s => s.severity === 'critical');
|
||||
const highIndex = sinks.findIndex(s => s.severity === 'high');
|
||||
const criticalIndex = sinks.findIndex((s) => s.severity === 'critical');
|
||||
const highIndex = sinks.findIndex((s) => s.severity === 'high');
|
||||
|
||||
if (criticalIndex !== -1 && highIndex !== -1) {
|
||||
expect(criticalIndex).toBeLessThan(highIndex);
|
||||
}
|
||||
});
|
||||
|
||||
it('should compute additional sinks count', () => {
|
||||
it('computes additional sinks count', () => {
|
||||
fixture.detectChanges();
|
||||
// 3 total sinks, max 3 preview = 0 additional
|
||||
expect(component.additionalSinksCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute additional sinks when more than max', () => {
|
||||
it('computes additional sinks when more than max', () => {
|
||||
fixture.componentRef.setInput('maxPreviewSinks', 1);
|
||||
fixture.detectChanges();
|
||||
|
||||
// 3 total sinks, max 1 preview = 2 additional
|
||||
expect(component.additionalSinksCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should emit viewDetails when view details is clicked', () => {
|
||||
it('emits viewDetails when view details is clicked', () => {
|
||||
fixture.detectChanges();
|
||||
const emitSpy = jest.spyOn(component.viewDetails, 'emit');
|
||||
const emitSpy = spyOn(component.viewDetails, 'emit');
|
||||
|
||||
component.onViewDetails();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit sinkClick when a sink is clicked', () => {
|
||||
it('emits sinkClick when a sink is clicked', () => {
|
||||
fixture.detectChanges();
|
||||
const emitSpy = jest.spyOn(component.sinkClick, 'emit');
|
||||
const emitSpy = spyOn(component.sinkClick, 'emit');
|
||||
|
||||
component.onSinkClick(mockSink1);
|
||||
component.onSinkClick(mockSinks[0]);
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(mockSink1);
|
||||
expect(emitSpy).toHaveBeenCalledWith(mockSinks[0]);
|
||||
});
|
||||
|
||||
it('should be non-compact by default', () => {
|
||||
it('is non-compact by default', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.compact()).toBe(false);
|
||||
});
|
||||
|
||||
it('should show attestation by default', () => {
|
||||
it('shows attestation by default', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.showAttestation()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -135,3 +135,4 @@ export class RiskDriftCardComponent {
|
||||
return labels[bucket] ?? bucket;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@ describe('ReachabilityExplainComponent', () => {
|
||||
|
||||
it('should select node on click', fakeAsync(() => {
|
||||
const node = fixture.debugElement.query(By.css('.reachability-explain__node-group'));
|
||||
node.nativeElement.click();
|
||||
node.nativeElement.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
@@ -68,6 +68,23 @@ export interface TimeSeriesPoint {
|
||||
readonly low: number;
|
||||
}
|
||||
|
||||
const SEVERITIES = [
|
||||
{ key: 'critical', label: 'Critical', color: '#dc2626' },
|
||||
{ key: 'high', label: 'High', color: '#ea580c' },
|
||||
{ key: 'medium', label: 'Medium', color: '#d97706' },
|
||||
{ key: 'low', label: 'Low', color: '#059669' }
|
||||
] as const;
|
||||
|
||||
type SeverityKey = typeof SEVERITIES[number]['key'];
|
||||
|
||||
const CHART_SERIES = [
|
||||
{ key: 'riskScore', label: 'Risk Score', color: '#3b82f6' },
|
||||
{ key: 'critical', label: 'Critical', color: '#dc2626' },
|
||||
{ key: 'high', label: 'High', color: '#ea580c' }
|
||||
] as const;
|
||||
|
||||
type ChartSeriesKey = typeof CHART_SERIES[number]['key'];
|
||||
|
||||
// ============================================================================
|
||||
// Injection Token & API
|
||||
// ============================================================================
|
||||
@@ -877,18 +894,8 @@ export class ScoreComparisonComponent implements OnInit {
|
||||
readonly viewMode = signal<'side-by-side' | 'timeline'>('side-by-side');
|
||||
|
||||
// Static data
|
||||
readonly severities = [
|
||||
{ key: 'critical', label: 'Critical', color: '#dc2626' },
|
||||
{ key: 'high', label: 'High', color: '#ea580c' },
|
||||
{ key: 'medium', label: 'Medium', color: '#d97706' },
|
||||
{ key: 'low', label: 'Low', color: '#059669' }
|
||||
];
|
||||
|
||||
readonly chartSeries = [
|
||||
{ key: 'riskScore', label: 'Risk Score', color: '#3b82f6' },
|
||||
{ key: 'critical', label: 'Critical', color: '#dc2626' },
|
||||
{ key: 'high', label: 'High', color: '#ea580c' }
|
||||
];
|
||||
readonly severities = SEVERITIES;
|
||||
readonly chartSeries = CHART_SERIES;
|
||||
|
||||
readonly yAxisTicks = [0, 25, 50, 75, 100];
|
||||
|
||||
@@ -954,17 +961,17 @@ export class ScoreComparisonComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
getSeverityPercent(scores: ScoreMetrics, key: string): number {
|
||||
getSeverityPercent(scores: ScoreMetrics, key: SeverityKey): number {
|
||||
const total = scores.totalVulnerabilities || 1;
|
||||
const count = (scores as Record<string, number>)[key] || 0;
|
||||
const count = scores[key] || 0;
|
||||
return (count / total) * 100;
|
||||
}
|
||||
|
||||
getSeverityCount(scores: ScoreMetrics, key: string): number {
|
||||
return (scores as Record<string, number>)[key] || 0;
|
||||
getSeverityCount(scores: ScoreMetrics, key: SeverityKey): number {
|
||||
return scores[key] || 0;
|
||||
}
|
||||
|
||||
getSeverityDelta(key: string): number {
|
||||
getSeverityDelta(key: SeverityKey): number {
|
||||
const comp = this.comparison();
|
||||
if (!comp) return 0;
|
||||
const before = this.getSeverityCount(comp.before.scores, key);
|
||||
@@ -972,7 +979,7 @@ export class ScoreComparisonComponent implements OnInit {
|
||||
return after - before;
|
||||
}
|
||||
|
||||
getSeverityChangeClass(key: string): string {
|
||||
getSeverityChangeClass(key: SeverityKey): string {
|
||||
const delta = this.getSeverityDelta(key);
|
||||
if (delta < 0) return 'improved';
|
||||
if (delta > 0) return 'worsened';
|
||||
@@ -1010,10 +1017,10 @@ export class ScoreComparisonComponent implements OnInit {
|
||||
return 50 + index * spacing;
|
||||
}
|
||||
|
||||
getSeriesPoints(key: string): string {
|
||||
getSeriesPoints(key: ChartSeriesKey): string {
|
||||
return this.timeSeries()
|
||||
.map((point, i) => {
|
||||
const value = (point as Record<string, number>)[key] || 0;
|
||||
const value = point[key] || 0;
|
||||
return `${this.getXPosition(i)},${this.getYPosition(value)}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
@@ -1,368 +1,182 @@
|
||||
/**
|
||||
* Tests for Unknowns Queue Component
|
||||
* Sprint: SPRINT_3500_0004_0002 - T8
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { delay, of, throwError } from 'rxjs';
|
||||
import { UnknownsQueueComponent } from './unknowns-queue.component';
|
||||
import { UNKNOWNS_API } from '../../core/api/unknowns.client';
|
||||
import { UNKNOWNS_API, UnknownsApi } from '../../core/api/unknowns.client';
|
||||
import { UnknownEntry, UnknownsListResponse, UnknownsSummary } from '../../core/api/unknowns.models';
|
||||
|
||||
describe('UnknownsQueueComponent', () => {
|
||||
let component: UnknownsQueueComponent;
|
||||
let fixture: ComponentFixture<UnknownsQueueComponent>;
|
||||
let mockUnknownsApi: jasmine.SpyObj<any>;
|
||||
let unknownsApi: jasmine.SpyObj<UnknownsApi>;
|
||||
|
||||
const mockUnknowns = {
|
||||
items: [
|
||||
{
|
||||
unknownId: 'unk-001',
|
||||
purl: 'pkg:npm/lodash@4.17.21',
|
||||
name: 'lodash',
|
||||
version: '4.17.21',
|
||||
ecosystem: 'npm',
|
||||
band: 'HOT' as const,
|
||||
firstSeen: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
occurrenceCount: 15,
|
||||
affectedScans: 8,
|
||||
status: 'pending' as const
|
||||
},
|
||||
{
|
||||
unknownId: 'unk-002',
|
||||
purl: 'pkg:pypi/requests@2.28.0',
|
||||
name: 'requests',
|
||||
version: '2.28.0',
|
||||
ecosystem: 'pypi',
|
||||
band: 'WARM' as const,
|
||||
firstSeen: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
occurrenceCount: 5,
|
||||
affectedScans: 3,
|
||||
status: 'pending' as const
|
||||
},
|
||||
{
|
||||
unknownId: 'unk-003',
|
||||
purl: 'pkg:maven/com.example/old-lib@1.0.0',
|
||||
name: 'old-lib',
|
||||
version: '1.0.0',
|
||||
ecosystem: 'maven',
|
||||
band: 'COLD' as const,
|
||||
firstSeen: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
lastSeen: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
occurrenceCount: 2,
|
||||
affectedScans: 1,
|
||||
status: 'pending' as const
|
||||
}
|
||||
],
|
||||
totalCount: 3,
|
||||
pageSize: 20,
|
||||
pageNumber: 1
|
||||
const scanId = 'scan-123';
|
||||
|
||||
const unknowns: readonly UnknownEntry[] = [
|
||||
{
|
||||
unknownId: 'unk-001',
|
||||
package: { name: 'lodash', version: '4.17.21', ecosystem: 'npm', purl: 'pkg:npm/lodash@4.17.21' },
|
||||
band: 'HOT',
|
||||
status: 'pending',
|
||||
rank: 1,
|
||||
occurrenceCount: 15,
|
||||
firstSeenAt: '2025-12-18T00:00:00Z',
|
||||
lastSeenAt: '2025-12-19T00:00:00Z',
|
||||
ageInDays: 2,
|
||||
relatedCves: ['CVE-2024-0001'],
|
||||
recentOccurrences: [],
|
||||
},
|
||||
{
|
||||
unknownId: 'unk-002',
|
||||
package: { name: 'requests', version: '2.28.0', ecosystem: 'pypi', purl: 'pkg:pypi/requests@2.28.0' },
|
||||
band: 'WARM',
|
||||
status: 'pending',
|
||||
rank: 2,
|
||||
occurrenceCount: 5,
|
||||
firstSeenAt: '2025-12-10T00:00:00Z',
|
||||
lastSeenAt: '2025-12-19T00:00:00Z',
|
||||
ageInDays: 10,
|
||||
recentOccurrences: [],
|
||||
},
|
||||
];
|
||||
|
||||
const listResponse: UnknownsListResponse = {
|
||||
items: unknowns,
|
||||
total: unknowns.length,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
|
||||
const summary: UnknownsSummary = {
|
||||
hotCount: 1,
|
||||
warmCount: 1,
|
||||
coldCount: 0,
|
||||
totalCount: 2,
|
||||
pendingCount: 2,
|
||||
escalatedCount: 0,
|
||||
resolvedToday: 0,
|
||||
oldestUnresolvedDays: 10,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockUnknownsApi = jasmine.createSpyObj('UnknownsApi', [
|
||||
'getUnknowns',
|
||||
'escalateUnknown',
|
||||
'resolveUnknown',
|
||||
'bulkEscalate',
|
||||
'bulkResolve'
|
||||
unknownsApi = jasmine.createSpyObj<UnknownsApi>('UnknownsApi', [
|
||||
'list',
|
||||
'get',
|
||||
'getSummary',
|
||||
'escalate',
|
||||
'resolve',
|
||||
'bulkAction',
|
||||
]);
|
||||
|
||||
mockUnknownsApi.getUnknowns.and.returnValue(of(mockUnknowns));
|
||||
unknownsApi.list.and.returnValue(of(listResponse).pipe(delay(1)));
|
||||
unknownsApi.getSummary.and.returnValue(of(summary).pipe(delay(1)));
|
||||
unknownsApi.escalate.and.returnValue(of({ ...unknowns[0], status: 'escalated', recentOccurrences: [] }));
|
||||
unknownsApi.resolve.and.returnValue(of({ ...unknowns[0], status: 'resolved', recentOccurrences: [] }));
|
||||
unknownsApi.bulkAction.and.returnValue(of({ successCount: 2, failureCount: 0 }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [UnknownsQueueComponent],
|
||||
providers: [
|
||||
{ provide: UNKNOWNS_API, useValue: mockUnknownsApi }
|
||||
]
|
||||
providers: [{ provide: UNKNOWNS_API, useValue: unknownsApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UnknownsQueueComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
function render(): void {
|
||||
fixture.componentRef.setInput('scanId', scanId);
|
||||
fixture.componentRef.setInput('refreshInterval', 0);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('should show loading state initially', () => {
|
||||
fixture.detectChanges();
|
||||
it('loads unknowns and summary on init', fakeAsync(() => {
|
||||
render();
|
||||
|
||||
const loading = fixture.debugElement.query(By.css('.unknowns-queue__loading'));
|
||||
expect(loading).toBeTruthy();
|
||||
});
|
||||
expect(unknownsApi.list).toHaveBeenCalledWith({ scanId });
|
||||
expect(unknownsApi.getSummary).toHaveBeenCalled();
|
||||
expect(fixture.debugElement.query(By.css('.unknowns-queue__loading'))).toBeTruthy();
|
||||
|
||||
it('should load unknowns on init', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockUnknownsApi.getUnknowns).toHaveBeenCalled();
|
||||
expect(fixture.debugElement.query(By.css('.unknowns-queue__loading'))).toBeFalsy();
|
||||
expect(fixture.debugElement.queryAll(By.css('.unknowns-queue__item')).length).toBe(2);
|
||||
}));
|
||||
|
||||
it('filters items by band tab', fakeAsync(() => {
|
||||
render();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
const tabs = fixture.debugElement.queryAll(By.css('.unknowns-queue__tab'));
|
||||
const hotTab = tabs.find(t => (t.nativeElement.textContent as string).includes('Hot'));
|
||||
expect(hotTab).toBeTruthy();
|
||||
|
||||
hotTab!.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(hotTab!.nativeElement.getAttribute('aria-selected')).toBe('true');
|
||||
expect(fixture.debugElement.queryAll(By.css('.unknowns-queue__item')).length).toBe(1);
|
||||
}));
|
||||
|
||||
it('filters items by search query', fakeAsync(() => {
|
||||
render();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.searchQuery.set('lodash');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.queryAll(By.css('.unknowns-queue__item')).length).toBe(1);
|
||||
}));
|
||||
|
||||
it('escalates an unknown when clicking Escalate', fakeAsync(() => {
|
||||
const escalatedSpy = spyOn(component.unknownEscalated, 'emit');
|
||||
|
||||
render();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css('button[title="Escalate"]'));
|
||||
expect(buttons.length).toBe(2);
|
||||
|
||||
buttons[0].nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(unknownsApi.escalate).toHaveBeenCalledWith(jasmine.objectContaining({ unknownId: 'unk-001' }));
|
||||
expect(escalatedSpy).toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it('performs bulk resolve for selected items', fakeAsync(() => {
|
||||
render();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
unknownsApi.list.and.returnValue(of(listResponse));
|
||||
|
||||
const selectAll = fixture.debugElement.query(By.css('#select-all'));
|
||||
selectAll.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const bulkResolve = fixture.debugElement.query(By.css('.unknowns-queue__bulk-btn--resolve'));
|
||||
bulkResolve.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(unknownsApi.bulkAction).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
unknownIds: ['unk-001', 'unk-002'],
|
||||
action: 'resolve',
|
||||
resolutionAction: 'other',
|
||||
}));
|
||||
}));
|
||||
|
||||
it('should display unknowns after loading', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
it('shows an error message if list fails', fakeAsync(() => {
|
||||
unknownsApi.list.and.returnValue(throwError(() => new Error('boom')));
|
||||
|
||||
const rows = fixture.debugElement.queryAll(By.css('.unknowns-queue__row'));
|
||||
expect(rows.length).toBe(3);
|
||||
}));
|
||||
});
|
||||
render();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
describe('Band Tabs', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should display all band tabs', () => {
|
||||
const tabs = fixture.debugElement.queryAll(By.css('.unknowns-queue__tab'));
|
||||
expect(tabs.length).toBe(4); // All, HOT, WARM, COLD
|
||||
});
|
||||
|
||||
it('should filter by band when tab clicked', fakeAsync(() => {
|
||||
const hotTab = fixture.debugElement.queryAll(By.css('.unknowns-queue__tab'))[1];
|
||||
hotTab.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockUnknownsApi.getUnknowns).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
band: 'HOT'
|
||||
}));
|
||||
}));
|
||||
|
||||
it('should show active state on selected tab', fakeAsync(() => {
|
||||
const tabs = fixture.debugElement.queryAll(By.css('.unknowns-queue__tab'));
|
||||
tabs[1].nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(tabs[1].nativeElement.classList.contains('unknowns-queue__tab--active')).toBeTrue();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Search and Filter', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have search input', () => {
|
||||
const searchInput = fixture.debugElement.query(By.css('.unknowns-queue__search'));
|
||||
expect(searchInput).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should filter on search input', fakeAsync(() => {
|
||||
const searchInput = fixture.debugElement.query(By.css('.unknowns-queue__search input'));
|
||||
searchInput.nativeElement.value = 'lodash';
|
||||
searchInput.nativeElement.dispatchEvent(new Event('input'));
|
||||
tick(300); // debounce
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockUnknownsApi.getUnknowns).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
search: 'lodash'
|
||||
}));
|
||||
}));
|
||||
|
||||
it('should have ecosystem filter', () => {
|
||||
const ecosystemFilter = fixture.debugElement.query(By.css('.unknowns-queue__filter select'));
|
||||
expect(ecosystemFilter).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Selection', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have select all checkbox', () => {
|
||||
const selectAll = fixture.debugElement.query(By.css('.unknowns-queue__select-all'));
|
||||
expect(selectAll).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should select all when checkbox clicked', fakeAsync(() => {
|
||||
const selectAll = fixture.debugElement.query(By.css('.unknowns-queue__select-all input'));
|
||||
selectAll.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const checkedBoxes = fixture.debugElement.queryAll(By.css('.unknowns-queue__row-checkbox:checked'));
|
||||
expect(checkedBoxes.length).toBe(3);
|
||||
}));
|
||||
|
||||
it('should enable bulk actions when items selected', fakeAsync(() => {
|
||||
const checkbox = fixture.debugElement.query(By.css('.unknowns-queue__row-checkbox'));
|
||||
checkbox.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const bulkActions = fixture.debugElement.query(By.css('.unknowns-queue__bulk-actions'));
|
||||
expect(bulkActions).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Actions', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have escalate button for each row', () => {
|
||||
const escalateBtns = fixture.debugElement.queryAll(By.css('.unknowns-queue__escalate-btn'));
|
||||
expect(escalateBtns.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should have resolve button for each row', () => {
|
||||
const resolveBtns = fixture.debugElement.queryAll(By.css('.unknowns-queue__resolve-btn'));
|
||||
expect(resolveBtns.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should call escalate API when button clicked', fakeAsync(() => {
|
||||
mockUnknownsApi.escalateUnknown.and.returnValue(of({ success: true }));
|
||||
|
||||
const escalateBtn = fixture.debugElement.query(By.css('.unknowns-queue__escalate-btn'));
|
||||
escalateBtn.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(mockUnknownsApi.escalateUnknown).toHaveBeenCalledWith('unk-001');
|
||||
}));
|
||||
|
||||
it('should call resolve API when button clicked', fakeAsync(() => {
|
||||
mockUnknownsApi.resolveUnknown.and.returnValue(of({ success: true }));
|
||||
|
||||
const resolveBtn = fixture.debugElement.query(By.css('.unknowns-queue__resolve-btn'));
|
||||
resolveBtn.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(mockUnknownsApi.resolveUnknown).toHaveBeenCalledWith('unk-001', jasmine.any(Object));
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Bulk Actions', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
// Select all items
|
||||
const selectAll = fixture.debugElement.query(By.css('.unknowns-queue__select-all input'));
|
||||
selectAll.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should perform bulk escalate', fakeAsync(() => {
|
||||
mockUnknownsApi.bulkEscalate.and.returnValue(of({ success: true }));
|
||||
|
||||
const bulkEscalate = fixture.debugElement.query(By.css('.unknowns-queue__bulk-escalate'));
|
||||
bulkEscalate.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(mockUnknownsApi.bulkEscalate).toHaveBeenCalledWith(['unk-001', 'unk-002', 'unk-003']);
|
||||
}));
|
||||
|
||||
it('should perform bulk resolve', fakeAsync(() => {
|
||||
mockUnknownsApi.bulkResolve.and.returnValue(of({ success: true }));
|
||||
|
||||
const bulkResolve = fixture.debugElement.query(By.css('.unknowns-queue__bulk-resolve'));
|
||||
bulkResolve.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(mockUnknownsApi.bulkResolve).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Pagination', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should display pagination controls', () => {
|
||||
const pagination = fixture.debugElement.query(By.css('.unknowns-queue__pagination'));
|
||||
expect(pagination).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show total count', () => {
|
||||
const totalCount = fixture.debugElement.query(By.css('.unknowns-queue__total-count'));
|
||||
expect(totalCount.nativeElement.textContent).toContain('3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto Refresh', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have auto-refresh toggle', () => {
|
||||
const autoRefresh = fixture.debugElement.query(By.css('.unknowns-queue__auto-refresh'));
|
||||
expect(autoRefresh).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should display error when API fails', fakeAsync(() => {
|
||||
mockUnknownsApi.getUnknowns.and.returnValue(throwError(() => new Error('API Error')));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const error = fixture.debugElement.query(By.css('.unknowns-queue__error'));
|
||||
expect(error).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have proper table structure', () => {
|
||||
const table = fixture.debugElement.query(By.css('table'));
|
||||
expect(table).toBeTruthy();
|
||||
|
||||
const headers = fixture.debugElement.queryAll(By.css('th'));
|
||||
expect(headers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have role="tablist" on band tabs', () => {
|
||||
const tablist = fixture.debugElement.query(By.css('[role="tablist"]'));
|
||||
expect(tablist).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have role="tab" on each tab', () => {
|
||||
const tabs = fixture.debugElement.queryAll(By.css('[role="tab"]'));
|
||||
expect(tabs.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should have aria-selected on active tab', () => {
|
||||
const activeTab = fixture.debugElement.query(By.css('[aria-selected="true"]'));
|
||||
expect(activeTab).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have labels for checkboxes', () => {
|
||||
const checkboxes = fixture.debugElement.queryAll(By.css('input[type="checkbox"]'));
|
||||
checkboxes.forEach(checkbox => {
|
||||
const hasLabel = checkbox.nativeElement.hasAttribute('aria-label') ||
|
||||
checkbox.nativeElement.hasAttribute('aria-labelledby') ||
|
||||
checkbox.nativeElement.id;
|
||||
expect(hasLabel).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(fixture.debugElement.query(By.css('.unknowns-queue__error'))).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ interface SortConfig {
|
||||
</h2>
|
||||
<div class="unknowns-queue__stats" *ngIf="summary()">
|
||||
<span class="unknowns-queue__stat unknowns-queue__stat--total">
|
||||
{{ summary()!.total }} Total
|
||||
{{ summary()!.totalCount }} Total
|
||||
</span>
|
||||
<span class="unknowns-queue__stat unknowns-queue__stat--hot">
|
||||
🔴 {{ summary()!.hotCount }} Hot
|
||||
@@ -733,7 +733,7 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy {
|
||||
// State
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly unknowns = signal<UnknownEntry[]>([]);
|
||||
readonly unknowns = signal<readonly UnknownEntry[]>([]);
|
||||
readonly summary = signal<UnknownsSummary | null>(null);
|
||||
readonly activeTab = signal<TabId>('all');
|
||||
readonly searchQuery = signal('');
|
||||
@@ -826,9 +826,8 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const filter: UnknownsFilter = {};
|
||||
if (this.workspaceId()) filter.workspaceId = this.workspaceId();
|
||||
if (this.scanId()) filter.scanId = this.scanId();
|
||||
const scanId = this.scanId();
|
||||
const filter: UnknownsFilter = scanId ? { scanId } : {};
|
||||
|
||||
this.unknownsApi.list(filter).subscribe({
|
||||
next: (response) => {
|
||||
@@ -862,7 +861,7 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy {
|
||||
case 'hot': return s.hotCount;
|
||||
case 'warm': return s.warmCount;
|
||||
case 'cold': return s.coldCount;
|
||||
case 'all': return s.total;
|
||||
case 'all': return s.totalCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,7 +930,7 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy {
|
||||
resolveUnknown(unknown: UnknownEntry): void {
|
||||
const request: ResolveUnknownRequest = {
|
||||
unknownId: unknown.unknownId,
|
||||
resolution: 'resolved',
|
||||
action: 'other',
|
||||
notes: 'Resolved from UI'
|
||||
};
|
||||
|
||||
@@ -955,7 +954,7 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy {
|
||||
this.unknownsApi.bulkAction({
|
||||
unknownIds: ids,
|
||||
action: 'escalate',
|
||||
reason: 'Bulk escalation from UI'
|
||||
notes: 'Bulk escalation from UI'
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.loadUnknowns();
|
||||
@@ -972,7 +971,7 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy {
|
||||
this.unknownsApi.bulkAction({
|
||||
unknownIds: ids,
|
||||
action: 'resolve',
|
||||
resolution: 'resolved',
|
||||
resolutionAction: 'other',
|
||||
notes: 'Bulk resolved from UI'
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
|
||||
@@ -406,13 +406,12 @@ export class VulnerabilityExplorerComponent implements OnInit {
|
||||
async openWitnessModal(vuln: Vulnerability): Promise<void> {
|
||||
this.witnessLoading.set(true);
|
||||
try {
|
||||
// Map reachability status to confidence tier
|
||||
const tier = this.mapReachabilityToTier(vuln.reachabilityStatus, vuln.reachabilityScore);
|
||||
|
||||
// Get or create witness data
|
||||
const witness = await firstValueFrom(
|
||||
this.witnessClient.getWitnessForVulnerability(vuln.vulnId)
|
||||
);
|
||||
// Map reachability status to confidence tier
|
||||
const tier = this.mapReachabilityToTier(vuln.reachabilityStatus, vuln.reachabilityScore);
|
||||
|
||||
// Get or create witness data
|
||||
const witnesses = await firstValueFrom(this.witnessClient.getWitnessesForVuln(vuln.vulnId));
|
||||
const witness = witnesses.at(0);
|
||||
|
||||
if (witness) {
|
||||
this.witnessModalData.set(witness);
|
||||
@@ -432,14 +431,14 @@ export class VulnerabilityExplorerComponent implements OnInit {
|
||||
confidenceScore: vuln.reachabilityScore ?? 0,
|
||||
isReachable: vuln.reachabilityStatus === 'reachable',
|
||||
callPath: [],
|
||||
gates: [],
|
||||
evidence: {
|
||||
callGraphHash: undefined,
|
||||
surfaceHash: undefined,
|
||||
sbomDigest: undefined,
|
||||
},
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
gates: [],
|
||||
evidence: {
|
||||
callGraphHash: undefined,
|
||||
surfaceHash: undefined,
|
||||
analysisMethod: 'static',
|
||||
},
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
this.witnessModalData.set(placeholderWitness);
|
||||
this.showWitnessModal.set(true);
|
||||
}
|
||||
|
||||
@@ -548,9 +548,9 @@ export class ApprovalButtonComponent {
|
||||
});
|
||||
|
||||
/** Whether the confirmation form can be submitted. */
|
||||
readonly canSubmit = computed(() => {
|
||||
canSubmit(): boolean {
|
||||
return this.reason.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Methods
|
||||
|
||||
@@ -483,18 +483,20 @@ export class AttestationNodeComponent {
|
||||
|
||||
/** Format timestamp for display. */
|
||||
formatTimestamp(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
} catch {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
|
||||
return date.toLocaleString('en-US', {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('FindingListComponent', () => {
|
||||
fixture = TestBed.createComponent(FindingListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('findings', mockFindings);
|
||||
fixture.componentRef.setInput('totalCount', mockFindings.length);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
@@ -138,8 +139,7 @@ describe('FindingListComponent', () => {
|
||||
|
||||
it('should calculate critical/high count', () => {
|
||||
const criticalHighCount = component.criticalHighCount();
|
||||
// f1 has score 85 (critical), f3 has 60 (high)
|
||||
expect(criticalHighCount).toBe(2);
|
||||
expect(criticalHighCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -101,18 +101,12 @@ export interface FindingSort {
|
||||
<span class="finding-list__spinner">⏳</span>
|
||||
<span>Loading findings...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Empty State -->
|
||||
@else if (sortedFindings().length === 0) {
|
||||
} @else if (sortedFindings().length === 0) {
|
||||
<div class="finding-list__empty" role="status">
|
||||
<span class="finding-list__empty-icon">📋</span>
|
||||
<span class="finding-list__empty-text">{{ emptyMessage() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Findings List -->
|
||||
@else {
|
||||
} @else {
|
||||
<!-- Regular list (virtual scroll requires @angular/cdk, add if needed) -->
|
||||
<div class="finding-list__content">
|
||||
@for (finding of sortedFindings(); track trackByFinding($index, finding)) {
|
||||
@@ -281,7 +275,7 @@ export class FindingListComponent {
|
||||
/**
|
||||
* Current sort configuration.
|
||||
*/
|
||||
readonly sort = input<FindingSort | undefined>(undefined);
|
||||
readonly sort = input<FindingSort | undefined>({ field: 'score', direction: 'desc' });
|
||||
|
||||
/**
|
||||
* Total count for pagination display.
|
||||
@@ -399,7 +393,7 @@ export class FindingListComponent {
|
||||
criticalHighCount(): number {
|
||||
return this.sortedFindings().filter(f => {
|
||||
const score = f.score_explain?.risk_score ?? 0;
|
||||
return score >= 7.0;
|
||||
return score >= 70;
|
||||
}).length;
|
||||
}
|
||||
|
||||
@@ -438,7 +432,7 @@ export class FindingListComponent {
|
||||
getSortIndicator(field: FindingSortField): string {
|
||||
const currentSort = this.sort();
|
||||
if (currentSort?.field !== field) return '';
|
||||
return currentSort.direction === 'asc' ? '↑' : '↓';
|
||||
return currentSort.direction === 'asc' ? '▲' : '▼';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -453,9 +453,9 @@ export class FindingRowComponent {
|
||||
|
||||
readonly severityClass = computed(() => {
|
||||
const score = this.riskScore();
|
||||
if (score >= 9.0) return 'critical';
|
||||
if (score >= 7.0) return 'high';
|
||||
if (score >= 4.0) return 'medium';
|
||||
if (score >= 90) return 'critical';
|
||||
if (score >= 70) return 'high';
|
||||
if (score >= 40) return 'medium';
|
||||
if (score > 0) return 'low';
|
||||
return 'none';
|
||||
});
|
||||
@@ -473,14 +473,14 @@ export class FindingRowComponent {
|
||||
|
||||
readonly callPath = computed(() => this.finding()?.reachable_path ?? []);
|
||||
|
||||
readonly vexStatus = computed(() => this.finding()?.vex?.status);
|
||||
readonly vexStatus = computed(() => this.finding()?.vex?.status ?? 'under_investigation');
|
||||
|
||||
readonly vexJustification = computed(() => this.finding()?.vex?.justification);
|
||||
|
||||
readonly chainStatus = computed((): ChainStatusDisplay => {
|
||||
const refs = this.finding()?.attestation_refs;
|
||||
if (!refs || refs.length === 0) return 'empty';
|
||||
// Simplified - in real impl would check actual chain status
|
||||
if (refs.length < 3) return 'partial';
|
||||
return 'complete';
|
||||
});
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ export interface RekorReference {
|
||||
|
||||
<span class="rekor-link__content" *ngIf="!compact()">
|
||||
<span class="rekor-link__label">Rekor Log</span>
|
||||
<span class="rekor-link__index">#{{ logIndex() }}</span>
|
||||
<span class="rekor-link__index">#{{ effectiveLogIndex() }}</span>
|
||||
</span>
|
||||
|
||||
<span class="rekor-link__index-only" *ngIf="compact()">
|
||||
#{{ logIndex() }}
|
||||
#{{ effectiveLogIndex() }}
|
||||
</span>
|
||||
|
||||
<span class="rekor-link__external" aria-hidden="true">↗</span>
|
||||
|
||||
@@ -199,7 +199,9 @@ describe('WitnessModalComponent', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should generate JSON download', () => {
|
||||
it('should generate JSON download', async () => {
|
||||
mockWitnessClient.downloadWitnessJson.and.returnValue(of(new Blob(['{}'], { type: 'application/json' })));
|
||||
|
||||
// Mock URL.createObjectURL and document.createElement
|
||||
const mockUrl = 'blob:mock-url';
|
||||
spyOn(URL, 'createObjectURL').and.returnValue(mockUrl);
|
||||
@@ -212,7 +214,7 @@ describe('WitnessModalComponent', () => {
|
||||
};
|
||||
spyOn(document, 'createElement').and.returnValue(mockAnchor as unknown as HTMLAnchorElement);
|
||||
|
||||
component.downloadJson();
|
||||
await component.downloadJson();
|
||||
|
||||
expect(mockAnchor.download).toContain('witness-');
|
||||
expect(mockAnchor.download).toContain('.json');
|
||||
@@ -229,7 +231,7 @@ describe('WitnessModalComponent', () => {
|
||||
|
||||
it('should copy witness ID to clipboard', async () => {
|
||||
const writeTextSpy = jasmine.createSpy('writeText').and.returnValue(Promise.resolve(undefined));
|
||||
Object.assign(navigator, { clipboard: { writeText: writeTextSpy } });
|
||||
spyOnProperty(navigator, 'clipboard', 'get').and.returnValue({ writeText: writeTextSpy } as unknown as Clipboard);
|
||||
|
||||
await component.copyWitnessId();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { Component, input, output, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { ReachabilityWitness, WitnessVerificationResult } from '../../core/api/witness.models';
|
||||
import { WitnessMockClient } from '../../core/api/witness.client';
|
||||
@@ -50,7 +51,7 @@ import { PathVisualizationComponent, PathVisualizationData } from './path-visual
|
||||
</div>
|
||||
<div class="witness-modal__package">
|
||||
{{ witness()!.packageName }}
|
||||
<span *ngIf="witness()!.packageVersion">@{{ witness()!.packageVersion }}</span>
|
||||
<span *ngIf="witness()!.packageVersion">@{{ witness()!.packageVersion }}</span>
|
||||
</div>
|
||||
<div class="witness-modal__purl" *ngIf="witness()!.purl">
|
||||
{{ witness()!.purl }}
|
||||
@@ -465,7 +466,7 @@ export class WitnessModalComponent {
|
||||
|
||||
this.isVerifying.set(true);
|
||||
try {
|
||||
const result = await this.witnessClient.verifyWitness(w.witnessId).toPromise();
|
||||
const result = await firstValueFrom(this.witnessClient.verifyWitness(w.witnessId));
|
||||
this.verificationResult.set(result ?? null);
|
||||
} catch (error) {
|
||||
this.verificationResult.set({
|
||||
@@ -486,7 +487,7 @@ export class WitnessModalComponent {
|
||||
if (!w) return;
|
||||
|
||||
try {
|
||||
const blob = await this.witnessClient.downloadWitnessJson(w.witnessId).toPromise();
|
||||
const blob = await firstValueFrom(this.witnessClient.downloadWitnessJson(w.witnessId));
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
Reference in New Issue
Block a user