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,12 +1,14 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notifier.Worker.Options;
using StellaOps.Notifier.Worker.Processing;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
using System;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notifier.Tests.Support;
using StellaOps.Notifier.Worker.Options;
using StellaOps.Notifier.Worker.Processing;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using StellaOps.AirGap.Policy;
using Xunit;
namespace StellaOps.Notifier.Tests;
@@ -15,23 +17,27 @@ public sealed class EventProcessorTests
[Fact]
public async Task ProcessAsync_MatchesRule_StoresSingleDeliveryWithIdempotency()
{
var ruleRepository = new InMemoryRuleRepository();
var deliveryRepository = new InMemoryDeliveryRepository();
var lockRepository = new InMemoryLockRepository();
var evaluator = new DefaultNotifyRuleEvaluator();
var options = Options.Create(new NotifierWorkerOptions
{
DefaultIdempotencyTtl = TimeSpan.FromMinutes(5)
});
var processor = new NotifierEventProcessor(
ruleRepository,
deliveryRepository,
lockRepository,
evaluator,
options,
TimeProvider.System,
NullLogger<NotifierEventProcessor>.Instance);
var ruleRepository = new InMemoryRuleRepository();
var deliveryRepository = new InMemoryDeliveryRepository();
var lockRepository = new InMemoryLockRepository();
var channelRepository = new InMemoryChannelRepository();
var evaluator = new DefaultNotifyRuleEvaluator();
var egressPolicy = new TestEgressPolicy { IsSealed = false };
var options = Options.Create(new NotifierWorkerOptions
{
DefaultIdempotencyTtl = TimeSpan.FromMinutes(5)
});
var processor = new NotifierEventProcessor(
ruleRepository,
deliveryRepository,
lockRepository,
channelRepository,
evaluator,
egressPolicy,
options,
TimeProvider.System,
NullLogger<NotifierEventProcessor>.Instance);
var rule = NotifyRule.Create(
ruleId: "rule-1",
@@ -40,12 +46,24 @@ public sealed class EventProcessorTests
match: NotifyRuleMatch.Create(eventKinds: new[] { "policy.violation" }),
actions: new[]
{
NotifyRuleAction.Create(
actionId: "act-slack",
channel: "chn-slack")
});
ruleRepository.Seed("tenant-a", rule);
NotifyRuleAction.Create(
actionId: "act-slack",
channel: "chn-slack")
});
ruleRepository.Seed("tenant-a", rule);
channelRepository.Seed(
"tenant-a",
NotifyChannel.Create(
channelId: "chn-slack",
tenantId: "tenant-a",
name: "Slack #alerts",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/slack/alerts",
target: "#alerts",
endpoint: "https://hooks.slack.com/services/T000/B000/XYZ"),
enabled: true));
var payload = new JsonObject
{
@@ -77,7 +95,205 @@ public sealed class EventProcessorTests
Assert.Equal("chn-slack", record.Metadata["channel"]);
Assert.Equal(notifyEvent.EventId, record.EventId);
// TODO: deliveriesSecond should be 0 once idempotency locks are enforced end-to-end.
// Assert.Equal(0, deliveriesSecond);
}
}
// TODO: deliveriesSecond should be 0 once idempotency locks are enforced end-to-end.
// Assert.Equal(0, deliveriesSecond);
}
[Fact]
public async Task ProcessAsync_SealedModeSkipsBlockedChannel()
{
var ruleRepository = new InMemoryRuleRepository();
var deliveryRepository = new InMemoryDeliveryRepository();
var lockRepository = new InMemoryLockRepository();
var channelRepository = new InMemoryChannelRepository();
var evaluator = new DefaultNotifyRuleEvaluator();
var egressPolicy = new TestEgressPolicy
{
IsSealed = true,
EvaluateCallback = request =>
{
if (request.Destination.Host.Contains("hooks.slack.com", StringComparison.OrdinalIgnoreCase))
{
return EgressDecision.Blocked(
reason: "Destination is not allowlisted while sealed.",
remediation: "Add the Slack webhook host to the sealed-mode allow list or switch to an enclave-safe channel.");
}
return EgressDecision.Allowed;
}
};
var options = Options.Create(new NotifierWorkerOptions
{
DefaultIdempotencyTtl = TimeSpan.FromMinutes(5)
});
var processor = new NotifierEventProcessor(
ruleRepository,
deliveryRepository,
lockRepository,
channelRepository,
evaluator,
egressPolicy,
options,
TimeProvider.System,
NullLogger<NotifierEventProcessor>.Instance);
var rule = NotifyRule.Create(
ruleId: "rule-2",
tenantId: "tenant-a",
name: "Sealed mode routing",
match: NotifyRuleMatch.Create(eventKinds: new[] { "policy.violation" }),
actions: new[]
{
NotifyRuleAction.Create(
actionId: "act-webhook",
channel: "chn-webhook"),
NotifyRuleAction.Create(
actionId: "act-email",
channel: "chn-email")
});
ruleRepository.Seed("tenant-a", rule);
channelRepository.Seed(
"tenant-a",
NotifyChannel.Create(
channelId: "chn-webhook",
tenantId: "tenant-a",
name: "Slack #alerts",
type: NotifyChannelType.Webhook,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/webhook/alerts",
endpoint: "https://hooks.slack.com/services/T000/B000/XYZ"),
enabled: true),
NotifyChannel.Create(
channelId: "chn-email",
tenantId: "tenant-a",
name: "Email SOC",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/email/soc",
target: "soc@example.com"),
enabled: true));
var notifyEvent = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: "policy.violation",
tenant: "tenant-a",
ts: DateTimeOffset.UtcNow,
payload: new JsonObject(),
actor: "policy-engine",
version: "1");
var deliveries = await processor.ProcessAsync(notifyEvent, "worker-1", TestContext.Current.CancellationToken);
Assert.Equal(1, deliveries);
var record = Assert.Single(deliveryRepository.Records("tenant-a"));
Assert.Equal("chn-email", record.Metadata["channel"]);
}
[Fact]
public async Task ProcessAsync_SealedModeAllowsAllowlistedChannel()
{
var ruleRepository = new InMemoryRuleRepository();
var deliveryRepository = new InMemoryDeliveryRepository();
var lockRepository = new InMemoryLockRepository();
var channelRepository = new InMemoryChannelRepository();
var evaluator = new DefaultNotifyRuleEvaluator();
var egressPolicy = new TestEgressPolicy
{
IsSealed = true,
EvaluateCallback = _ => EgressDecision.Allowed
};
var options = Options.Create(new NotifierWorkerOptions
{
DefaultIdempotencyTtl = TimeSpan.FromMinutes(5)
});
var processor = new NotifierEventProcessor(
ruleRepository,
deliveryRepository,
lockRepository,
channelRepository,
evaluator,
egressPolicy,
options,
TimeProvider.System,
NullLogger<NotifierEventProcessor>.Instance);
var rule = NotifyRule.Create(
ruleId: "rule-3",
tenantId: "tenant-a",
name: "Allowlisted egress",
match: NotifyRuleMatch.Create(eventKinds: new[] { "policy.violation" }),
actions: new[]
{
NotifyRuleAction.Create(
actionId: "act-webhook",
channel: "chn-webhook")
});
ruleRepository.Seed("tenant-a", rule);
channelRepository.Seed(
"tenant-a",
NotifyChannel.Create(
channelId: "chn-webhook",
tenantId: "tenant-a",
name: "Slack #alerts",
type: NotifyChannelType.Webhook,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/webhook/alerts",
endpoint: "https://hooks.slack.com/services/T000/B000/XYZ"),
enabled: true));
var notifyEvent = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: "policy.violation",
tenant: "tenant-a",
ts: DateTimeOffset.UtcNow,
payload: new JsonObject(),
actor: "policy-engine",
version: "1");
var deliveries = await processor.ProcessAsync(notifyEvent, "worker-1", TestContext.Current.CancellationToken);
Assert.Equal(1, deliveries);
var record = Assert.Single(deliveryRepository.Records("tenant-a"));
Assert.Equal("chn-webhook", record.Metadata["channel"]);
}
private sealed class TestEgressPolicy : IEgressPolicy
{
public bool IsSealed { get; set; }
public EgressPolicyMode Mode => IsSealed ? EgressPolicyMode.Sealed : EgressPolicyMode.Unsealed;
public Func<EgressRequest, EgressDecision>? EvaluateCallback { get; set; }
public EgressDecision Evaluate(EgressRequest request)
=> EvaluateCallback?.Invoke(request) ?? EgressDecision.Allowed;
public ValueTask<EgressDecision> EvaluateAsync(EgressRequest request, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(Evaluate(request));
public void EnsureAllowed(EgressRequest request)
{
var decision = Evaluate(request);
if (!decision.IsAllowed)
{
throw new AirGapEgressBlockedException(
request,
decision.Reason ?? "Request blocked by test policy.",
decision.Remediation ?? "Review sealed-mode configuration.",
documentationUrl: null,
supportContact: null);
}
}
public ValueTask EnsureAllowedAsync(EgressRequest request, CancellationToken cancellationToken = default)
{
EnsureAllowed(request);
return ValueTask.CompletedTask;
}
}
}

View File

@@ -56,9 +56,9 @@ internal sealed class InMemoryRuleRepository : INotifyRuleRepository
}
}
internal sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
{
private readonly ConcurrentDictionary<string, List<NotifyDelivery>> _deliveries = new(StringComparer.Ordinal);
internal sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
{
private readonly ConcurrentDictionary<string, List<NotifyDelivery>> _deliveries = new(StringComparer.Ordinal);
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
{
@@ -128,11 +128,63 @@ internal sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
return Array.Empty<NotifyDelivery>();
}
}
internal sealed class InMemoryLockRepository : INotifyLockRepository
{
private readonly object _sync = new();
}
internal sealed class InMemoryChannelRepository : INotifyChannelRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyChannel>> _channels = new(StringComparer.Ordinal);
public Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channel);
var map = _channels.GetOrAdd(channel.TenantId, _ => new ConcurrentDictionary<string, NotifyChannel>(StringComparer.Ordinal));
map[channel.ChannelId] = channel;
return Task.CompletedTask;
}
public Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
if (_channels.TryGetValue(tenantId, out var map) && map.TryGetValue(channelId, out var channel))
{
return Task.FromResult<NotifyChannel?>(channel);
}
return Task.FromResult<NotifyChannel?>(null);
}
public Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
if (_channels.TryGetValue(tenantId, out var map))
{
return Task.FromResult<IReadOnlyList<NotifyChannel>>(map.Values.ToArray());
}
return Task.FromResult<IReadOnlyList<NotifyChannel>>(Array.Empty<NotifyChannel>());
}
public Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
if (_channels.TryGetValue(tenantId, out var map))
{
map.TryRemove(channelId, out _);
}
return Task.CompletedTask;
}
public void Seed(string tenantId, params NotifyChannel[] channels)
{
var map = _channels.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyChannel>(StringComparer.Ordinal));
foreach (var channel in channels)
{
map[channel.ChannelId] = channel;
}
}
}
internal sealed class InMemoryLockRepository : INotifyLockRepository
{
private readonly object _sync = new();
private readonly Dictionary<(string TenantId, string Resource), (string Owner, DateTimeOffset Expiry)> _locks = new();
public int SuccessfulReservations { get; private set; }