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

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

View File

@@ -1,35 +1,43 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Options;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Policy;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Options;
namespace StellaOps.Notifier.Worker.Processing;
internal sealed class NotifierEventProcessor
{
private readonly INotifyRuleRepository _ruleRepository;
private readonly INotifyDeliveryRepository _deliveryRepository;
private readonly INotifyLockRepository _lockRepository;
private readonly INotifyRuleEvaluator _ruleEvaluator;
private readonly INotifyDeliveryRepository _deliveryRepository;
private readonly INotifyLockRepository _lockRepository;
private readonly INotifyChannelRepository _channelRepository;
private readonly INotifyRuleEvaluator _ruleEvaluator;
private readonly IEgressPolicy _egressPolicy;
private readonly NotifierWorkerOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifierEventProcessor> _logger;
public NotifierEventProcessor(
INotifyRuleRepository ruleRepository,
INotifyDeliveryRepository deliveryRepository,
INotifyLockRepository lockRepository,
INotifyRuleEvaluator ruleEvaluator,
IOptions<NotifierWorkerOptions> options,
TimeProvider timeProvider,
ILogger<NotifierEventProcessor> logger)
public NotifierEventProcessor(
INotifyRuleRepository ruleRepository,
INotifyDeliveryRepository deliveryRepository,
INotifyLockRepository lockRepository,
INotifyChannelRepository channelRepository,
INotifyRuleEvaluator ruleEvaluator,
IEgressPolicy egressPolicy,
IOptions<NotifierWorkerOptions> options,
TimeProvider timeProvider,
ILogger<NotifierEventProcessor> logger)
{
_ruleRepository = ruleRepository ?? throw new ArgumentNullException(nameof(ruleRepository));
_deliveryRepository = deliveryRepository ?? throw new ArgumentNullException(nameof(deliveryRepository));
_lockRepository = lockRepository ?? throw new ArgumentNullException(nameof(lockRepository));
_ruleEvaluator = ruleEvaluator ?? throw new ArgumentNullException(nameof(ruleEvaluator));
_deliveryRepository = deliveryRepository ?? throw new ArgumentNullException(nameof(deliveryRepository));
_lockRepository = lockRepository ?? throw new ArgumentNullException(nameof(lockRepository));
_channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository));
_ruleEvaluator = ruleEvaluator ?? throw new ArgumentNullException(nameof(ruleEvaluator));
_egressPolicy = egressPolicy ?? throw new ArgumentNullException(nameof(egressPolicy));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -77,13 +85,74 @@ internal sealed class NotifierEventProcessor
return 0;
}
var created = 0;
foreach (var outcome in outcomes)
{
foreach (var action in outcome.Actions)
{
var ttl = ResolveIdempotencyTtl(action);
var idempotencyKey = IdempotencyKeyBuilder.Build(tenantId, outcome.Rule.RuleId, action.ActionId, notifyEvent);
var channelCache = new Dictionary<string, NotifyChannel?>(StringComparer.Ordinal);
var created = 0;
foreach (var outcome in outcomes)
{
foreach (var action in outcome.Actions)
{
if (!action.Enabled)
{
_logger.LogDebug(
"Skipping disabled action {ActionId} for tenant {TenantId}, rule {RuleId}.",
action.ActionId,
tenantId,
outcome.Rule.RuleId);
continue;
}
if (string.IsNullOrWhiteSpace(action.Channel))
{
_logger.LogWarning(
"Skipping action {ActionId} for tenant {TenantId}, rule {RuleId} because channel reference is missing.",
action.ActionId,
tenantId,
outcome.Rule.RuleId);
continue;
}
var channelId = action.Channel.Trim();
if (!channelCache.TryGetValue(channelId, out var channel))
{
channel = await _channelRepository
.GetAsync(tenantId, channelId, cancellationToken)
.ConfigureAwait(false);
channelCache[channelId] = channel;
}
if (channel is null)
{
_logger.LogWarning(
"Skipping action {ActionId} for tenant {TenantId}, rule {RuleId}: channel {ChannelId} not found.",
action.ActionId,
tenantId,
outcome.Rule.RuleId,
channelId);
continue;
}
if (!channel.Enabled)
{
_logger.LogDebug(
"Skipping action {ActionId} for tenant {TenantId}, rule {RuleId}: channel {ChannelId} is disabled.",
action.ActionId,
tenantId,
outcome.Rule.RuleId,
channel.ChannelId);
continue;
}
if (_egressPolicy.IsSealed && RequiresExternalEgress(channel))
{
if (!TryEnsureChannelAllowed(channel, action, notifyEvent, tenantId, outcome.Rule.RuleId))
{
continue;
}
}
var ttl = ResolveIdempotencyTtl(action);
var idempotencyKey = IdempotencyKeyBuilder.Build(tenantId, outcome.Rule.RuleId, action.ActionId, notifyEvent);
bool reserved;
try
@@ -144,22 +213,92 @@ internal sealed class NotifierEventProcessor
return created;
}
private TimeSpan ResolveIdempotencyTtl(NotifyRuleAction action)
{
if (action.Throttle is { Ticks: > 0 } throttle)
{
return throttle;
}
private TimeSpan ResolveIdempotencyTtl(NotifyRuleAction action)
{
if (action.Throttle is { Ticks: > 0 } throttle)
{
return throttle;
}
if (_options.DefaultIdempotencyTtl > TimeSpan.Zero)
{
return _options.DefaultIdempotencyTtl;
}
return TimeSpan.FromMinutes(5);
}
private static IEnumerable<KeyValuePair<string, string>> BuildDeliveryMetadata(NotifyRuleAction action)
return TimeSpan.FromMinutes(5);
}
private bool TryEnsureChannelAllowed(
NotifyChannel channel,
NotifyRuleAction action,
NotifyEvent notifyEvent,
string tenantId,
string ruleId)
{
var endpoint = ResolveChannelEndpoint(channel);
if (endpoint is null)
{
_logger.LogWarning(
"Sealed mode blocked action {ActionId} for tenant {TenantId}, rule {RuleId}: channel {ChannelId} ({ChannelType}) does not expose a valid endpoint. Event {EventId}. Configure enclave-safe channels (SMTP relay, syslog, file sink) or provide an allowlisted endpoint before unsealing.",
action.ActionId,
tenantId,
ruleId,
channel.ChannelId,
channel.Type,
notifyEvent.EventId);
return false;
}
try
{
var request = new EgressRequest(
component: "Notifier",
destination: endpoint,
intent: "notify.channel.dispatch",
operation: $"{ruleId}:{action.ActionId}");
_egressPolicy.EnsureAllowed(request);
return true;
}
catch (AirGapEgressBlockedException ex)
{
_logger.LogWarning(
ex,
"Sealed mode blocked action {ActionId} for tenant {TenantId}, rule {RuleId}: channel {ChannelId} ({ChannelType}) attempted to reach {Destination}. Reason: {Reason}. Remediation: {Remediation}. Suggested fallback: use enclave-safe channels (SMTP relay, syslog, file sink).",
action.ActionId,
tenantId,
ruleId,
channel.ChannelId,
channel.Type,
ex.Request.Destination,
ex.Reason,
ex.Remediation);
return false;
}
}
private static bool RequiresExternalEgress(NotifyChannel channel)
{
return channel.Type switch
{
NotifyChannelType.Email => false,
NotifyChannelType.Custom when string.IsNullOrWhiteSpace(channel.Config?.Endpoint) => false,
_ => true,
};
}
private static Uri? ResolveChannelEndpoint(NotifyChannel channel)
{
var endpoint = channel.Config?.Endpoint;
if (string.IsNullOrWhiteSpace(endpoint))
{
return null;
}
return Uri.TryCreate(endpoint.Trim(), UriKind.Absolute, out var uri) ? uri : null;
}
private static IEnumerable<KeyValuePair<string, string>> BuildDeliveryMetadata(NotifyRuleAction action)
{
var metadata = new List<KeyValuePair<string, string>>
{