more features checks. setup improvements

This commit is contained in:
master
2026-02-13 02:04:55 +02:00
parent 9911b7d73c
commit 9ca2de05df
675 changed files with 37550 additions and 1826 deletions

View File

@@ -0,0 +1,236 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Concelier.BackportProof.Models;
using StellaOps.Concelier.BackportProof.Repositories;
using StellaOps.Concelier.BackportProof.Services;
using Xunit;
namespace StellaOps.Concelier.BackportProof.Tests.Services;
public sealed class FixIndexServiceTests
{
private static readonly DateTimeOffset FixedTimestamp =
DateTimeOffset.Parse("2026-01-15T00:00:00Z");
private readonly Mock<IFixRuleRepository> _repository = new();
private FixIndexService CreateService() =>
new(_repository.Object, NullLogger<FixIndexService>.Instance);
[Fact]
public async Task GetActiveSnapshotId_Initially_ReturnsNull()
{
var sut = CreateService();
var id = await sut.GetActiveSnapshotIdAsync();
Assert.Null(id);
}
[Fact]
public async Task CreateSnapshot_ReturnsSnapshotWithLabel()
{
var sut = CreateService();
var snapshot = await sut.CreateSnapshotAsync("debian-2026-01-15");
Assert.Equal("debian-2026-01-15", snapshot.SourceLabel);
Assert.NotNull(snapshot.SnapshotId);
Assert.NotNull(snapshot.IndexDigest);
}
[Fact]
public async Task ActivateSnapshot_SetsActiveSnapshot()
{
var sut = CreateService();
var snapshot = await sut.CreateSnapshotAsync("test-label");
await sut.ActivateSnapshotAsync(snapshot.SnapshotId);
var activeId = await sut.GetActiveSnapshotIdAsync();
Assert.Equal(snapshot.SnapshotId, activeId);
}
[Fact]
public async Task ActivateSnapshot_InvalidId_Throws()
{
var sut = CreateService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.ActivateSnapshotAsync("nonexistent-snapshot-id").AsTask());
}
[Fact]
public async Task LookupAsync_NoActiveSnapshot_ReturnsEmpty()
{
var sut = CreateService();
var context = new ProductContext("debian", "bookworm", null, null);
var package = new PackageKey(PackageEcosystem.Deb, "curl", "curl");
var rules = await sut.LookupAsync(context, package, "CVE-2024-0001");
Assert.Empty(rules);
}
[Fact]
public async Task LookupAsync_WithActiveEmptySnapshot_ReturnsEmpty()
{
var sut = CreateService();
var snapshot = await sut.CreateSnapshotAsync("empty");
await sut.ActivateSnapshotAsync(snapshot.SnapshotId);
var context = new ProductContext("debian", "bookworm", null, null);
var package = new PackageKey(PackageEcosystem.Deb, "curl", "curl");
var rules = await sut.LookupAsync(context, package, "CVE-2024-0001");
Assert.Empty(rules);
}
[Fact]
public async Task LookupByPackageAsync_NoActiveSnapshot_ReturnsEmpty()
{
var sut = CreateService();
var context = new ProductContext("debian", "bookworm", null, null);
var package = new PackageKey(PackageEcosystem.Deb, "curl", "curl");
var rules = await sut.LookupByPackageAsync(context, package);
Assert.Empty(rules);
}
[Fact]
public async Task ListSnapshots_NoSnapshots_ReturnsEmpty()
{
var sut = CreateService();
var snapshots = await sut.ListSnapshotsAsync();
Assert.Empty(snapshots);
}
[Fact]
public async Task ListSnapshots_AfterCreate_ReturnsSnapshot()
{
var sut = CreateService();
var snapshot = await sut.CreateSnapshotAsync("test");
var listed = await sut.ListSnapshotsAsync();
Assert.Single(listed);
Assert.Equal(snapshot.SnapshotId, listed[0].SnapshotId);
Assert.False(listed[0].IsActive);
}
[Fact]
public async Task ListSnapshots_AfterActivate_MarksActive()
{
var sut = CreateService();
var snapshot = await sut.CreateSnapshotAsync("test");
await sut.ActivateSnapshotAsync(snapshot.SnapshotId);
var listed = await sut.ListSnapshotsAsync();
Assert.Single(listed);
Assert.True(listed[0].IsActive);
}
[Fact]
public async Task PruneOldSnapshots_KeepsRequestedCount()
{
var sut = CreateService();
await sut.CreateSnapshotAsync("snap-1");
await sut.CreateSnapshotAsync("snap-2");
await sut.CreateSnapshotAsync("snap-3");
await sut.PruneOldSnapshotsAsync(keepCount: 1);
var listed = await sut.ListSnapshotsAsync();
Assert.Single(listed);
}
[Fact]
public async Task PruneOldSnapshots_FewerThanKeepCount_DoesNothing()
{
var sut = CreateService();
await sut.CreateSnapshotAsync("snap-1");
await sut.PruneOldSnapshotsAsync(keepCount: 5);
var listed = await sut.ListSnapshotsAsync();
Assert.Single(listed);
}
[Fact]
public async Task GetStats_NoActiveSnapshot_ReturnsZeros()
{
var sut = CreateService();
var stats = await sut.GetStatsAsync();
Assert.Equal(0, stats.TotalRules);
Assert.Equal(0, stats.UniqueCves);
Assert.Equal(0, stats.UniquePackages);
Assert.Equal(0, stats.UniqueDistros);
Assert.Empty(stats.RulesByDistro);
Assert.Empty(stats.RulesByPriority);
Assert.Empty(stats.RulesByType);
}
[Fact]
public async Task GetStats_WithSnapshotId_ReturnsStatsForThatSnapshot()
{
var sut = CreateService();
var snapshot = await sut.CreateSnapshotAsync("stats-test");
var stats = await sut.GetStatsAsync(snapshot.SnapshotId);
Assert.Equal(0, stats.TotalRules);
}
[Fact]
public async Task GetStats_InvalidSnapshotId_ReturnsZeros()
{
var sut = CreateService();
var stats = await sut.GetStatsAsync("nonexistent");
Assert.Equal(0, stats.TotalRules);
}
[Fact]
public async Task CreateSnapshot_MultipleSnapshots_HaveUniqueIds()
{
var sut = CreateService();
var snap1 = await sut.CreateSnapshotAsync("snap-1");
var snap2 = await sut.CreateSnapshotAsync("snap-2");
Assert.NotEqual(snap1.SnapshotId, snap2.SnapshotId);
}
[Fact]
public async Task ActivateSnapshot_SwitchBetweenSnapshots()
{
var sut = CreateService();
var snap1 = await sut.CreateSnapshotAsync("first");
var snap2 = await sut.CreateSnapshotAsync("second");
await sut.ActivateSnapshotAsync(snap1.SnapshotId);
Assert.Equal(snap1.SnapshotId, await sut.GetActiveSnapshotIdAsync());
await sut.ActivateSnapshotAsync(snap2.SnapshotId);
Assert.Equal(snap2.SnapshotId, await sut.GetActiveSnapshotIdAsync());
}
[Fact]
public async Task CreateSnapshot_EmptyRules_DigestIsDeterministic()
{
var sut = CreateService();
var snap1 = await sut.CreateSnapshotAsync("test-1");
var snap2 = await sut.CreateSnapshotAsync("test-2");
// Both empty snapshots should have the same digest (SHA256 of empty sorted IDs)
Assert.Equal(snap1.IndexDigest, snap2.IndexDigest);
}
}

View File

@@ -0,0 +1,114 @@
using StellaOps.Concelier.Connector.Epss.Configuration;
using Xunit;
namespace StellaOps.Concelier.Connector.Epss.Tests.Configuration;
public sealed class EpssOptionsValidationTests
{
[Fact]
public void Validate_DefaultOptions_DoesNotThrow()
{
var options = new EpssOptions();
options.Validate();
Assert.NotNull(options.BaseUri);
Assert.NotNull(options.UserAgent);
}
[Fact]
public void Validate_NullBaseUri_Throws()
{
var options = new EpssOptions { BaseUri = null! };
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("BaseUri", ex.Message);
}
[Fact]
public void Validate_NegativeCatchUpDays_Throws()
{
var options = new EpssOptions { CatchUpDays = -1 };
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("CatchUpDays", ex.Message);
}
[Fact]
public void Validate_ZeroHttpTimeout_Throws()
{
var options = new EpssOptions { HttpTimeout = TimeSpan.Zero };
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("HttpTimeout", ex.Message);
}
[Fact]
public void Validate_NegativeMaxRetries_Throws()
{
var options = new EpssOptions { MaxRetries = -1 };
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("MaxRetries", ex.Message);
}
[Fact]
public void Validate_EmptyUserAgent_Throws()
{
var options = new EpssOptions { UserAgent = "" };
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("UserAgent", ex.Message);
}
[Fact]
public void Validate_AirgapMode_WithoutBundlePath_Throws()
{
var options = new EpssOptions { AirgapMode = true, BundlePath = null };
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("BundlePath", ex.Message);
}
[Fact]
public void Validate_AirgapMode_WithBundlePath_DoesNotThrow()
{
var options = new EpssOptions { AirgapMode = true, BundlePath = "/data/epss-bundle.tar.gz" };
options.Validate();
Assert.True(options.AirgapMode);
}
[Fact]
public void Validate_ZeroCatchUpDays_DoesNotThrow()
{
var options = new EpssOptions { CatchUpDays = 0 };
options.Validate();
Assert.Equal(0, options.CatchUpDays);
}
[Fact]
public void Validate_ZeroMaxRetries_DoesNotThrow()
{
var options = new EpssOptions { MaxRetries = 0 };
options.Validate();
Assert.Equal(0, options.MaxRetries);
}
[Fact]
public void SectionName_IsExpected()
{
Assert.Equal("Concelier:Epss", EpssOptions.SectionName);
}
[Fact]
public void HttpClientName_IsExpected()
{
Assert.Equal("source.epss", EpssOptions.HttpClientName);
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Epss.Configuration;
using StellaOps.Concelier.Connector.Epss.Internal;
using StellaOps.Concelier.Storage;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Concelier.Connector.Epss.Tests;
public sealed class EpssConnectorPluginTests
{
[Fact]
public void Name_ReturnsEpss()
{
var plugin = new EpssConnectorPlugin();
Assert.Equal("epss", plugin.Name);
}
[Fact]
public void IsAvailable_WithoutRegisteredConnector_ReturnsFalse()
{
var services = new ServiceCollection().BuildServiceProvider();
var plugin = new EpssConnectorPlugin();
Assert.False(plugin.IsAvailable(services));
}
[Fact]
public void Create_WithNullServices_ThrowsArgumentNull()
{
var plugin = new EpssConnectorPlugin();
Assert.Throws<ArgumentNullException>(() => plugin.Create(null!));
}
}

View File

@@ -0,0 +1,108 @@
using StellaOps.Concelier.Connector.Epss.Internal;
using Xunit;
namespace StellaOps.Concelier.Connector.Epss.Tests.Internal;
public sealed class EpssCursorRoundTripTests
{
[Fact]
public void Empty_RoundTrips_ToEmpty()
{
var cursor = EpssCursor.Empty;
var doc = cursor.ToDocumentObject();
var restored = EpssCursor.FromDocument(doc);
Assert.Null(restored.ModelVersion);
Assert.Null(restored.LastProcessedDate);
Assert.Null(restored.ETag);
Assert.Null(restored.ContentHash);
Assert.Null(restored.LastRowCount);
Assert.Equal(DateTimeOffset.MinValue, restored.UpdatedAt);
Assert.Empty(restored.PendingDocuments);
Assert.Empty(restored.PendingMappings);
}
[Fact]
public void FullCursor_RoundTrips_AllFields()
{
var id1 = Guid.NewGuid();
var id2 = Guid.NewGuid();
var updatedAt = new DateTimeOffset(2026, 2, 12, 10, 0, 0, TimeSpan.Zero);
var cursor = EpssCursor.Empty
.WithSnapshotMetadata("v2026.02.12", new DateOnly(2026, 2, 12), "\"etag-123\"", "sha256:abc", 50000, updatedAt)
.WithPendingDocuments(new[] { id1 })
.WithPendingMappings(new[] { id2 });
var doc = cursor.ToDocumentObject();
var restored = EpssCursor.FromDocument(doc);
Assert.Equal("v2026.02.12", restored.ModelVersion);
Assert.Equal(new DateOnly(2026, 2, 12), restored.LastProcessedDate);
Assert.Equal("\"etag-123\"", restored.ETag);
Assert.Equal("sha256:abc", restored.ContentHash);
Assert.Equal(50000, restored.LastRowCount);
Assert.Equal(updatedAt.UtcDateTime, restored.UpdatedAt.UtcDateTime);
Assert.Contains(id1, restored.PendingDocuments);
Assert.Contains(id2, restored.PendingMappings);
}
[Fact]
public void FromDocument_NullDocument_ReturnsEmpty()
{
var restored = EpssCursor.FromDocument(null);
Assert.Equal(DateTimeOffset.MinValue, restored.UpdatedAt);
Assert.Empty(restored.PendingDocuments);
Assert.Empty(restored.PendingMappings);
}
[Fact]
public void WithPendingDocuments_DeduplicatesIds()
{
var id = Guid.NewGuid();
var cursor = EpssCursor.Empty.WithPendingDocuments(new[] { id, id, id });
Assert.Single(cursor.PendingDocuments);
Assert.Contains(id, cursor.PendingDocuments);
}
[Fact]
public void WithPendingMappings_DeduplicatesIds()
{
var id = Guid.NewGuid();
var cursor = EpssCursor.Empty.WithPendingMappings(new[] { id, id });
Assert.Single(cursor.PendingMappings);
}
[Fact]
public void WithSnapshotMetadata_WhitespaceStrings_NormalizedToNull()
{
var cursor = EpssCursor.Empty
.WithSnapshotMetadata(" ", null, " ", " ", 0, DateTimeOffset.MinValue);
Assert.Null(cursor.ModelVersion);
Assert.Null(cursor.ETag);
Assert.Null(cursor.ContentHash);
Assert.Null(cursor.LastRowCount); // 0 normalizes to null
}
[Fact]
public void ToDocumentObject_SortsPendingCollections_ForDeterminism()
{
var id1 = Guid.Parse("00000000-0000-0000-0000-000000000002");
var id2 = Guid.Parse("00000000-0000-0000-0000-000000000001");
var cursor = EpssCursor.Empty
.WithPendingDocuments(new[] { id1, id2 });
var doc = cursor.ToDocumentObject();
var restored = EpssCursor.FromDocument(doc);
// Both IDs should be present regardless of order
Assert.Equal(2, restored.PendingDocuments.Count);
Assert.Contains(id1, restored.PendingDocuments);
Assert.Contains(id2, restored.PendingDocuments);
}
}

View File

@@ -0,0 +1,257 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Core.Orchestration;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Orchestration;
public sealed class ConnectorRegistrationServiceTests
{
private static readonly DateTimeOffset FixedNow = new(2026, 1, 15, 0, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _time = new(FixedNow);
private readonly InMemoryOrchestratorRegistryStore _store;
private readonly ConnectorRegistrationService _sut;
public ConnectorRegistrationServiceTests()
{
_store = new InMemoryOrchestratorRegistryStore(_time);
_sut = new ConnectorRegistrationService(
_store,
_time,
NullLogger<ConnectorRegistrationService>.Instance);
}
[Fact]
public async Task RegisterAsync_CreatesRecord_WithMetadataFields()
{
var metadata = new ConnectorMetadata
{
ConnectorId = "nvd",
Source = "nvd",
DisplayName = "NVD",
DefaultCron = "0 */4 * * *",
DefaultRpm = 30,
EgressAllowlist = ["services.nvd.nist.gov"]
};
var record = await _sut.RegisterAsync("tenant-a", metadata, CancellationToken.None);
Assert.Equal("tenant-a", record.Tenant);
Assert.Equal("nvd", record.ConnectorId);
Assert.Equal("nvd", record.Source);
Assert.Equal("0 */4 * * *", record.Schedule.Cron);
Assert.Equal(30, record.RatePolicy.Rpm);
Assert.Contains("services.nvd.nist.gov", record.EgressGuard.Allowlist);
}
[Fact]
public async Task RegisterAsync_DefaultsAuthRef_WhenNull()
{
var metadata = new ConnectorMetadata
{
ConnectorId = "test-conn",
Source = "test",
AuthRef = null
};
var record = await _sut.RegisterAsync("t1", metadata, CancellationToken.None);
Assert.Equal("secret:concelier/test-conn/api-key", record.AuthRef);
}
[Fact]
public async Task RegisterAsync_UsesProvidedAuthRef()
{
var metadata = new ConnectorMetadata
{
ConnectorId = "custom",
Source = "custom",
AuthRef = "vault:custom-key"
};
var record = await _sut.RegisterAsync("t1", metadata, CancellationToken.None);
Assert.Equal("vault:custom-key", record.AuthRef);
}
[Fact]
public async Task RegisterAsync_NullTenant_Throws()
{
var metadata = WellKnownConnectors.Nvd;
await Assert.ThrowsAnyAsync<ArgumentException>(
() => _sut.RegisterAsync(null!, metadata, CancellationToken.None));
}
[Fact]
public async Task RegisterAsync_NullMetadata_Throws()
{
await Assert.ThrowsAsync<ArgumentNullException>(
() => _sut.RegisterAsync("t1", null!, CancellationToken.None));
}
[Fact]
public async Task RegisterBatchAsync_RegistersMultiple()
{
var metadataList = new[] { WellKnownConnectors.Nvd, WellKnownConnectors.Ghsa };
var records = await _sut.RegisterBatchAsync("t1", metadataList, CancellationToken.None);
Assert.Equal(2, records.Count);
Assert.Contains(records, r => r.ConnectorId == "nvd");
Assert.Contains(records, r => r.ConnectorId == "ghsa");
}
[Fact]
public async Task RegisterBatchAsync_EmptyList_ReturnsEmpty()
{
var records = await _sut.RegisterBatchAsync(
"t1", Array.Empty<ConnectorMetadata>(), CancellationToken.None);
Assert.Empty(records);
}
[Fact]
public async Task GetRegistrationAsync_ReturnsRegistered()
{
await _sut.RegisterAsync("t1", WellKnownConnectors.Nvd, CancellationToken.None);
var record = await _sut.GetRegistrationAsync("t1", "nvd", CancellationToken.None);
Assert.NotNull(record);
Assert.Equal("nvd", record.ConnectorId);
}
[Fact]
public async Task GetRegistrationAsync_NotFound_ReturnsNull()
{
var record = await _sut.GetRegistrationAsync("t1", "nonexistent", CancellationToken.None);
Assert.Null(record);
}
[Fact]
public async Task ListRegistrationsAsync_ReturnsTenantRecords()
{
await _sut.RegisterBatchAsync("t1", WellKnownConnectors.All, CancellationToken.None);
await _sut.RegisterAsync("t2", WellKnownConnectors.Nvd, CancellationToken.None);
var t1Records = await _sut.ListRegistrationsAsync("t1", CancellationToken.None);
var t2Records = await _sut.ListRegistrationsAsync("t2", CancellationToken.None);
Assert.Equal(6, t1Records.Count); // All 6 well-known connectors
Assert.Single(t2Records);
}
[Fact]
public async Task RegisterAsync_SetsLockKey_WithTenantAndConnector()
{
var metadata = new ConnectorMetadata
{
ConnectorId = "test-conn",
Source = "test"
};
var record = await _sut.RegisterAsync("my-tenant", metadata, CancellationToken.None);
Assert.Equal("concelier:my-tenant:test-conn", record.LockKey);
}
[Fact]
public async Task RegisterAsync_EgressAllowlist_SetsAirgapMode()
{
var withEgress = new ConnectorMetadata
{
ConnectorId = "with-egress",
Source = "test",
EgressAllowlist = ["example.com"]
};
var withoutEgress = new ConnectorMetadata
{
ConnectorId = "without-egress",
Source = "test",
EgressAllowlist = []
};
var recordWith = await _sut.RegisterAsync("t1", withEgress, CancellationToken.None);
var recordWithout = await _sut.RegisterAsync("t1", withoutEgress, CancellationToken.None);
Assert.True(recordWith.EgressGuard.AirgapMode);
Assert.False(recordWithout.EgressGuard.AirgapMode);
}
}
public sealed class WellKnownConnectorsTests
{
[Fact]
public void All_ContainsSixConnectors()
{
Assert.Equal(6, WellKnownConnectors.All.Count);
}
[Theory]
[InlineData("nvd", "nvd", "NVD")]
[InlineData("ghsa", "ghsa", "GHSA")]
[InlineData("osv", "osv", "OSV")]
[InlineData("kev", "kev", "KEV")]
[InlineData("epss", "epss", "EPSS")]
[InlineData("icscisa", "icscisa", "ICS-CISA")]
public void WellKnownConnector_HasExpectedIdAndName(
string connectorId, string source, string displayName)
{
var connector = WellKnownConnectors.All.Single(c => c.ConnectorId == connectorId);
Assert.Equal(source, connector.Source);
Assert.Equal(displayName, connector.DisplayName);
Assert.NotNull(connector.DefaultCron);
Assert.True(connector.DefaultRpm > 0);
}
[Fact]
public void AllConnectors_HaveEgressAllowlists()
{
foreach (var connector in WellKnownConnectors.All)
{
Assert.NotEmpty(connector.EgressAllowlist);
}
}
[Fact]
public void AllConnectors_HaveObservationsCapability()
{
foreach (var connector in WellKnownConnectors.All)
{
Assert.Contains("observations", connector.Capabilities);
}
}
[Fact]
public void AllConnectors_HaveUniqueIds()
{
var ids = WellKnownConnectors.All.Select(c => c.ConnectorId).ToList();
Assert.Equal(ids.Distinct().Count(), ids.Count);
}
}
public sealed class DefaultConnectorMetadataProviderTests
{
[Fact]
public void GetMetadata_ReturnsLowercaseIdAndSource()
{
var provider = new DefaultConnectorMetadataProvider("MySource");
var metadata = provider.GetMetadata();
Assert.Equal("mysource", metadata.ConnectorId);
Assert.Equal("mysource", metadata.Source);
Assert.Equal("MYSOURCE", metadata.DisplayName);
}
[Fact]
public void Constructor_NullOrWhiteSpace_Throws()
{
Assert.ThrowsAny<ArgumentException>(() => new DefaultConnectorMetadataProvider(null!));
Assert.ThrowsAny<ArgumentException>(() => new DefaultConnectorMetadataProvider(""));
Assert.ThrowsAny<ArgumentException>(() => new DefaultConnectorMetadataProvider(" "));
}
}

View File

@@ -0,0 +1,235 @@
using System;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Concelier.Core.Risk;
using StellaOps.Concelier.Core.Risk.PolicyStudio;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Risk;
/// <summary>
/// Behavioral tests for PolicyStudioSignalPicker.MapFromSignal.
/// Verifies CVSS version selection, KEV override, fix availability, provenance, and options control.
/// </summary>
public sealed class PolicyStudioSignalPickerTests
{
private static readonly DateTimeOffset FixedNow = new(2026, 2, 13, 0, 0, 0, TimeSpan.Zero);
private static readonly VendorRiskProvenance DefaultProv = new("nvd", "nvd-api", "sha256:abc", FixedNow, null, null);
private readonly PolicyStudioSignalPicker _picker;
public PolicyStudioSignalPickerTests()
{
// We only test MapFromSignal which doesn't use IVendorRiskSignalProvider
// so we can pass a dummy provider
_picker = new PolicyStudioSignalPicker(
new DummyProvider(),
NullLogger<PolicyStudioSignalPicker>.Instance,
TimeProvider.System);
}
[Fact]
public void MapFromSignal_WithCvss_SelectsHighestVersionByDefault()
{
var signal = CreateSignal(
cvss: [
new VendorCvssScore("cvss_v2", 9.0, null, null, DefaultProv),
new VendorCvssScore("cvss_v31", 7.5, null, null, DefaultProv),
new VendorCvssScore("cvss_v40", 8.0, null, null, DefaultProv)
]);
var result = _picker.MapFromSignal(signal);
// v4.0 has highest priority (4), not highest score
Assert.Equal(8.0, result.Cvss);
Assert.Equal("cvss_v40", result.CvssVersion);
}
[Fact]
public void MapFromSignal_WithPreferredCvssVersion_SelectsPreferred()
{
var signal = CreateSignal(
cvss: [
new VendorCvssScore("cvss_v2", 9.0, null, null, DefaultProv),
new VendorCvssScore("cvss_v31", 7.5, "CVSS:3.1/AV:N", null, DefaultProv),
new VendorCvssScore("cvss_v40", 8.0, null, null, DefaultProv)
]);
var options = new PolicyStudioSignalOptions { PreferredCvssVersion = "cvss_v31" };
var result = _picker.MapFromSignal(signal, options);
Assert.Equal(7.5, result.Cvss);
Assert.Equal("cvss_v31", result.CvssVersion);
Assert.Equal("CVSS:3.1/AV:N", result.CvssVector);
}
[Fact]
public void MapFromSignal_WithNoCvss_ReturnsNullCvssFields()
{
var signal = CreateSignal(cvss: []);
var result = _picker.MapFromSignal(signal);
Assert.Null(result.Cvss);
Assert.Null(result.CvssVersion);
Assert.Null(result.CvssVector);
}
[Fact]
public void MapFromSignal_CvssExcluded_ReturnsNullCvssFields()
{
var signal = CreateSignal(
cvss: [new VendorCvssScore("cvss_v31", 7.5, null, null, DefaultProv)]);
var options = new PolicyStudioSignalOptions { IncludeCvss = false };
var result = _picker.MapFromSignal(signal, options);
Assert.Null(result.Cvss);
Assert.Null(result.CvssVersion);
}
[Fact]
public void MapFromSignal_KevStatusPresent_OverridesSeverityToCritical()
{
var kevStatus = new VendorKevStatus(true, FixedNow, FixedNow.AddDays(30), null, null, DefaultProv);
var signal = CreateSignal(
cvss: [new VendorCvssScore("cvss_v31", 5.0, null, "medium", DefaultProv)],
kev: kevStatus);
var result = _picker.MapFromSignal(signal);
Assert.True(result.Kev);
Assert.Equal("critical", result.Severity); // KEV overrides CVSS severity
Assert.Equal(FixedNow, result.KevDateAdded);
}
[Fact]
public void MapFromSignal_KevExcluded_ReturnsNullKevFields()
{
var kevStatus = new VendorKevStatus(true, FixedNow, null, null, null, DefaultProv);
var signal = CreateSignal(kev: kevStatus);
var options = new PolicyStudioSignalOptions { IncludeKev = false };
var result = _picker.MapFromSignal(signal, options);
Assert.Null(result.Kev);
Assert.Null(result.KevDateAdded);
}
[Fact]
public void MapFromSignal_WithFixAvailability_SetsFixFields()
{
var fix = new VendorFixAvailability(
FixStatus.Available, "1.2.3", null, null, "my-pkg", "npm", DefaultProv);
var signal = CreateSignal(fixes: [fix]);
var result = _picker.MapFromSignal(signal);
Assert.True(result.FixAvailable);
Assert.NotNull(result.FixedVersions);
Assert.Single(result.FixedVersions!.Value);
Assert.Equal("1.2.3", result.FixedVersions!.Value[0]);
}
[Fact]
public void MapFromSignal_FixExcluded_ReturnsNullFixFields()
{
var fix = new VendorFixAvailability(
FixStatus.Available, "1.2.3", null, null, null, null, DefaultProv);
var signal = CreateSignal(fixes: [fix]);
var options = new PolicyStudioSignalOptions { IncludeFixAvailability = false };
var result = _picker.MapFromSignal(signal, options);
Assert.Null(result.FixAvailable);
Assert.Null(result.FixedVersions);
}
[Fact]
public void MapFromSignal_WithProvenance_BuildsProvenanceMetadata()
{
var signal = CreateSignal(
cvss: [new VendorCvssScore("cvss_v31", 7.5, null, null, DefaultProv)]);
var result = _picker.MapFromSignal(signal);
Assert.NotNull(result.Provenance);
Assert.Contains("obs-1", result.Provenance!.ObservationIds);
Assert.Contains("nvd-api", result.Provenance.Sources);
Assert.Contains("sha256:abc", result.Provenance.ObservationHashes);
Assert.NotNull(result.Provenance.CvssProvenance);
Assert.Equal("nvd", result.Provenance.CvssProvenance!.Vendor);
}
[Fact]
public void MapFromSignal_ProvenanceExcluded_ReturnsNullProvenance()
{
var signal = CreateSignal(
cvss: [new VendorCvssScore("cvss_v31", 7.5, null, null, DefaultProv)]);
var options = new PolicyStudioSignalOptions { IncludeProvenance = false };
var result = _picker.MapFromSignal(signal, options);
Assert.Null(result.Provenance);
}
[Fact]
public void MapFromSignal_SetsTenantAndAdvisoryId()
{
var signal = CreateSignal();
var result = _picker.MapFromSignal(signal);
Assert.Equal("tenant-a", result.TenantId);
Assert.Equal("CVE-2025-0001", result.AdvisoryId);
}
[Fact]
public void MapFromSignal_SetsExtractedAt()
{
var signal = CreateSignal();
var result = _picker.MapFromSignal(signal);
Assert.Equal(FixedNow, result.ExtractedAt);
}
[Fact]
public void MapFromSignal_NullSignal_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() => _picker.MapFromSignal(null!));
}
[Fact]
public void MapFromSignal_SeverityFromCvssWhenNoKev()
{
var signal = CreateSignal(
cvss: [new VendorCvssScore("cvss_v31", 7.5, null, null, DefaultProv)]);
var result = _picker.MapFromSignal(signal);
Assert.Equal("high", result.Severity); // 7.5 -> high for v3.1
}
private static VendorRiskSignal CreateSignal(
VendorCvssScore[]? cvss = null,
VendorKevStatus? kev = null,
VendorFixAvailability[]? fixes = null)
{
return new VendorRiskSignal(
TenantId: "tenant-a",
AdvisoryId: "CVE-2025-0001",
ObservationId: "obs-1",
Provenance: DefaultProv,
CvssScores: cvss is not null ? [.. cvss] : ImmutableArray<VendorCvssScore>.Empty,
KevStatus: kev,
FixAvailability: fixes is not null ? [.. fixes] : ImmutableArray<VendorFixAvailability>.Empty,
ExtractedAt: FixedNow);
}
private sealed class DummyProvider : IVendorRiskSignalProvider
{
public Task<VendorRiskSignal?> GetByObservationAsync(string tenantId, string observationId, System.Threading.CancellationToken ct) => Task.FromResult<VendorRiskSignal?>(null);
public Task<System.Collections.Generic.IReadOnlyList<VendorRiskSignal>> GetByAdvisoryAsync(string tenantId, string advisoryId, System.Threading.CancellationToken ct) => Task.FromResult<System.Collections.Generic.IReadOnlyList<VendorRiskSignal>>(Array.Empty<VendorRiskSignal>());
public Task<System.Collections.Generic.IReadOnlyList<VendorRiskSignal>> GetByLinksetAsync(string tenantId, string linksetId, System.Threading.CancellationToken ct) => Task.FromResult<System.Collections.Generic.IReadOnlyList<VendorRiskSignal>>(Array.Empty<VendorRiskSignal>());
public Task<VendorRiskSignal?> GetSignalAsync(string tenantId, string advisoryId, System.Threading.CancellationToken ct) => Task.FromResult<VendorRiskSignal?>(null);
public Task<System.Collections.Generic.IReadOnlyList<VendorRiskSignal>> GetSignalsBatchAsync(string tenantId, System.Collections.Generic.IEnumerable<string> advisoryIds, System.Threading.CancellationToken ct) => Task.FromResult<System.Collections.Generic.IReadOnlyList<VendorRiskSignal>>(Array.Empty<VendorRiskSignal>());
}
}

View File

@@ -0,0 +1,338 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Concelier.Core.Risk;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Risk;
/// <summary>
/// Behavioral tests for VendorRiskSignalExtractor.
/// Verifies CVSS extraction, KEV detection, fix availability parsing, and provenance anchoring.
/// </summary>
public sealed class VendorRiskSignalExtractorTests
{
private static readonly DateTimeOffset FixedNow = new(2026, 2, 13, 0, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset FetchedAt = new(2026, 2, 12, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void Extract_WithCvssSeverities_ProducesCvssScores()
{
var severities = new List<SeverityInput>
{
new("cvss_v31", 7.5, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", "high"),
new("cvss_v2", 5.0, null, "medium")
};
var signal = VendorRiskSignalExtractor.Extract(
tenantId: "tenant-a",
advisoryId: "CVE-2025-0001",
observationId: "obs-1",
vendor: "nvd",
source: "nvd-api",
observationHash: "sha256:abc123",
fetchedAt: FetchedAt,
ingestJobId: "job-1",
upstreamId: "nvd-CVE-2025-0001",
severities: severities,
rawContent: null,
now: FixedNow);
Assert.Equal(2, signal.CvssScores.Length);
Assert.Equal("cvss_v31", signal.CvssScores[0].System);
Assert.Equal(7.5, signal.CvssScores[0].Score);
Assert.Equal("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", signal.CvssScores[0].Vector);
Assert.Equal("high", signal.CvssScores[0].Severity);
Assert.Equal("cvss_v2", signal.CvssScores[1].System);
Assert.Equal(5.0, signal.CvssScores[1].Score);
}
[Fact]
public void Extract_WithNullSeverities_ReturnsEmptyCvss()
{
var signal = VendorRiskSignalExtractor.Extract(
tenantId: "tenant-a",
advisoryId: "CVE-2025-0001",
observationId: "obs-1",
vendor: "nvd",
source: "nvd-api",
observationHash: "sha256:abc123",
fetchedAt: FetchedAt,
ingestJobId: null,
upstreamId: null,
severities: null,
rawContent: null,
now: FixedNow);
Assert.True(signal.CvssScores.IsEmpty);
}
[Fact]
public void Extract_SkipsSeveritiesWithBlankSystem()
{
var severities = new List<SeverityInput>
{
new("cvss_v31", 7.5, null, "high"),
new("", 5.0, null, "medium"),
new(" ", 3.0, null, "low")
};
var signal = VendorRiskSignalExtractor.Extract(
tenantId: "tenant-a",
advisoryId: "CVE-2025-0001",
observationId: "obs-1",
vendor: "nvd",
source: "nvd-api",
observationHash: "sha256:abc",
fetchedAt: FetchedAt,
ingestJobId: null,
upstreamId: null,
severities: severities,
rawContent: null,
now: FixedNow);
Assert.Single(signal.CvssScores);
Assert.Equal("cvss_v31", signal.CvssScores[0].System);
}
[Fact]
public void Extract_SetsProvenanceCorrectly()
{
var signal = VendorRiskSignalExtractor.Extract(
tenantId: "tenant-a",
advisoryId: "CVE-2025-0001",
observationId: "obs-1",
vendor: "nvd",
source: "nvd-api",
observationHash: "sha256:abc123",
fetchedAt: FetchedAt,
ingestJobId: "job-1",
upstreamId: "nvd-CVE-2025-0001",
severities: null,
rawContent: null,
now: FixedNow);
Assert.Equal("nvd", signal.Provenance.Vendor);
Assert.Equal("nvd-api", signal.Provenance.Source);
Assert.Equal("sha256:abc123", signal.Provenance.ObservationHash);
Assert.Equal(FetchedAt, signal.Provenance.FetchedAt);
Assert.Equal("job-1", signal.Provenance.IngestJobId);
Assert.Equal("nvd-CVE-2025-0001", signal.Provenance.UpstreamId);
}
[Fact]
public void Extract_SetsTopLevelFieldsCorrectly()
{
var signal = VendorRiskSignalExtractor.Extract(
tenantId: "tenant-a",
advisoryId: "CVE-2025-0001",
observationId: "obs-1",
vendor: "nvd",
source: "nvd-api",
observationHash: "sha256:abc",
fetchedAt: FetchedAt,
ingestJobId: null,
upstreamId: null,
severities: null,
rawContent: null,
now: FixedNow);
Assert.Equal("tenant-a", signal.TenantId);
Assert.Equal("CVE-2025-0001", signal.AdvisoryId);
Assert.Equal("obs-1", signal.ObservationId);
Assert.Equal(FixedNow, signal.ExtractedAt);
}
[Fact]
public void Extract_WithOsvFixedVersion_ExtractsFixAvailability()
{
var rawJson = """
{
"affected": [
{
"package": { "name": "lodash", "ecosystem": "npm" },
"ranges": [
{
"type": "SEMVER",
"events": [
{ "introduced": "0" },
{ "fixed": "4.17.21" }
]
}
]
}
]
}
""";
var rawContent = JsonDocument.Parse(rawJson).RootElement;
var signal = VendorRiskSignalExtractor.Extract(
tenantId: "tenant-a",
advisoryId: "CVE-2025-0001",
observationId: "obs-1",
vendor: "osv",
source: "osv-api",
observationHash: "sha256:abc",
fetchedAt: FetchedAt,
ingestJobId: null,
upstreamId: null,
severities: null,
rawContent: rawContent,
now: FixedNow);
Assert.Single(signal.FixAvailability);
Assert.Equal(FixStatus.Available, signal.FixAvailability[0].Status);
Assert.Equal("4.17.21", signal.FixAvailability[0].FixedVersion);
Assert.Equal("lodash", signal.FixAvailability[0].Package);
Assert.Equal("npm", signal.FixAvailability[0].Ecosystem);
Assert.True(signal.HasFixAvailable);
}
[Fact]
public void Extract_WithNullRawContent_ReturnsNoFixAndNoKev()
{
var signal = VendorRiskSignalExtractor.Extract(
tenantId: "tenant-a",
advisoryId: "CVE-2025-0001",
observationId: "obs-1",
vendor: "nvd",
source: "nvd-api",
observationHash: "sha256:abc",
fetchedAt: FetchedAt,
ingestJobId: null,
upstreamId: null,
severities: null,
rawContent: null,
now: FixedNow);
Assert.Null(signal.KevStatus);
Assert.True(signal.FixAvailability.IsEmpty);
Assert.False(signal.HasFixAvailable);
Assert.False(signal.IsKnownExploited);
}
[Fact]
public void Extract_WithCisaKevData_ExtractsKevStatus()
{
var rawJson = """
{
"cisa_exploit_add": "2026-01-15",
"cisa_action_due": "2026-02-15",
"cisa_ransomware": "Known",
"cisa_vulnerability_name": "Test Vuln"
}
""";
var rawContent = JsonDocument.Parse(rawJson).RootElement;
var signal = VendorRiskSignalExtractor.Extract(
tenantId: "tenant-a",
advisoryId: "CVE-2025-0001",
observationId: "obs-1",
vendor: "nvd",
source: "nvd-api",
observationHash: "sha256:abc",
fetchedAt: FetchedAt,
ingestJobId: null,
upstreamId: null,
severities: null,
rawContent: rawContent,
now: FixedNow);
Assert.NotNull(signal.KevStatus);
Assert.True(signal.KevStatus!.InKev);
Assert.True(signal.IsKnownExploited);
Assert.Equal("Known", signal.KevStatus.KnownRansomwareCampaignUse);
Assert.Equal("Test Vuln", signal.KevStatus.Notes);
}
[Fact]
public void VendorCvssScore_NormalizedSystem_NormalizesVariants()
{
var prov = new VendorRiskProvenance("v", "s", "h", FixedNow, null, null);
Assert.Equal("cvss_v31", new VendorCvssScore("cvss_v31", 7.5, null, null, prov).NormalizedSystem);
Assert.Equal("cvss_v31", new VendorCvssScore("CVSS_V31", 7.5, null, null, prov).NormalizedSystem);
Assert.Equal("cvss_v31", new VendorCvssScore("cvssv31", 7.5, null, null, prov).NormalizedSystem);
Assert.Equal("cvss_v31", new VendorCvssScore("cvss31", 7.5, null, null, prov).NormalizedSystem);
Assert.Equal("cvss_v2", new VendorCvssScore("cvss_v2", 5.0, null, null, prov).NormalizedSystem);
Assert.Equal("cvss_v2", new VendorCvssScore("cvssv2", 5.0, null, null, prov).NormalizedSystem);
Assert.Equal("cvss_v2", new VendorCvssScore("cvss2", 5.0, null, null, prov).NormalizedSystem);
Assert.Equal("cvss_v40", new VendorCvssScore("cvss_v40", 9.0, null, null, prov).NormalizedSystem);
Assert.Equal("cvss_v40", new VendorCvssScore("cvss_v4", 9.0, null, null, prov).NormalizedSystem);
Assert.Equal("cvss_v40", new VendorCvssScore("cvss4", 9.0, null, null, prov).NormalizedSystem);
Assert.Equal("cvss_v30", new VendorCvssScore("cvss_v30", 6.0, null, null, prov).NormalizedSystem);
Assert.Equal("cvss_v30", new VendorCvssScore("cvss_v3", 6.0, null, null, prov).NormalizedSystem);
}
[Fact]
public void VendorCvssScore_EffectiveSeverity_DerivesFromScoreWhenNoVendorSeverity()
{
var prov = new VendorRiskProvenance("v", "s", "h", FixedNow, null, null);
Assert.Equal("critical", new VendorCvssScore("cvss_v31", 9.5, null, null, prov).EffectiveSeverity);
Assert.Equal("high", new VendorCvssScore("cvss_v31", 7.5, null, null, prov).EffectiveSeverity);
Assert.Equal("medium", new VendorCvssScore("cvss_v31", 5.0, null, null, prov).EffectiveSeverity);
Assert.Equal("low", new VendorCvssScore("cvss_v31", 2.0, null, null, prov).EffectiveSeverity);
Assert.Equal("none", new VendorCvssScore("cvss_v31", 0.0, null, null, prov).EffectiveSeverity);
}
[Fact]
public void VendorCvssScore_EffectiveSeverity_UsesVendorSeverityWhenProvided()
{
var prov = new VendorRiskProvenance("v", "s", "h", FixedNow, null, null);
var score = new VendorCvssScore("cvss_v31", 9.5, null, "vendor-critical", prov);
Assert.Equal("vendor-critical", score.EffectiveSeverity);
}
[Fact]
public void VendorCvssScore_CvssV2_UsesDifferentThresholds()
{
var prov = new VendorRiskProvenance("v", "s", "h", FixedNow, null, null);
// CVSS v2 has no "critical" tier and uses 7.0 for "high"
Assert.Equal("high", new VendorCvssScore("cvss_v2", 9.5, null, null, prov).EffectiveSeverity);
Assert.Equal("high", new VendorCvssScore("cvss_v2", 7.0, null, null, prov).EffectiveSeverity);
Assert.Equal("medium", new VendorCvssScore("cvss_v2", 5.0, null, null, prov).EffectiveSeverity);
Assert.Equal("low", new VendorCvssScore("cvss_v2", 2.0, null, null, prov).EffectiveSeverity);
}
[Fact]
public void VendorRiskSignal_HighestCvssScore_ReturnsMaxByScore()
{
var prov = new VendorRiskProvenance("v", "s", "h", FixedNow, null, null);
var signal = new VendorRiskSignal(
TenantId: "t",
AdvisoryId: "a",
ObservationId: "o",
Provenance: prov,
CvssScores: ImmutableArray.Create(
new VendorCvssScore("cvss_v2", 5.0, null, null, prov),
new VendorCvssScore("cvss_v31", 9.0, null, null, prov),
new VendorCvssScore("cvss_v40", 7.0, null, null, prov)),
KevStatus: null,
FixAvailability: ImmutableArray<VendorFixAvailability>.Empty,
ExtractedAt: FixedNow);
Assert.NotNull(signal.HighestCvssScore);
Assert.Equal(9.0, signal.HighestCvssScore!.Score);
Assert.Equal("cvss_v31", signal.HighestCvssScore.System);
}
[Fact]
public void VendorRiskSignal_Empty_HasNoData()
{
var prov = new VendorRiskProvenance("v", "s", "h", FixedNow, null, null);
var signal = VendorRiskSignal.Empty("t", "a", "o", prov, FixedNow);
Assert.True(signal.CvssScores.IsEmpty);
Assert.Null(signal.KevStatus);
Assert.True(signal.FixAvailability.IsEmpty);
Assert.Null(signal.HighestCvssScore);
Assert.False(signal.HasFixAvailable);
Assert.False(signal.IsKnownExploited);
}
}

View File

@@ -0,0 +1,151 @@
using System.Collections.Immutable;
using StellaOps.Concelier.Core.Tenancy;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Tenancy;
/// <summary>
/// Behavioral tests for LinkNotMergeTenantCapabilitiesProvider.
/// Verifies LNM mode enforcement, scope validation, and capabilities endpoint contract.
/// </summary>
public sealed class LinkNotMergeTenantCapabilitiesProviderTests
{
private readonly LinkNotMergeTenantCapabilitiesProvider _provider;
public LinkNotMergeTenantCapabilitiesProviderTests()
{
_provider = new LinkNotMergeTenantCapabilitiesProvider(TimeProvider.System);
}
[Fact]
public void GetCapabilities_ReturnsLinkNotMergeMode()
{
var scope = CreateValidScope("test-tenant");
var result = _provider.GetCapabilities(scope);
Assert.Equal(TenantCapabilitiesMode.LinkNotMerge, result.Mode);
}
[Fact]
public void GetCapabilities_MergeAlwaysFalse()
{
// Even if the scope capabilities allow merge, LNM provider overrides to false
var scope = CreateValidScope("test-tenant", mergeAllowed: true);
var result = _provider.GetCapabilities(scope);
Assert.False(result.MergeAllowed);
}
[Fact]
public void GetCapabilities_EchoesCorrectTenantId()
{
var scope = CreateValidScope("my-tenant");
var result = _provider.GetCapabilities(scope);
Assert.Equal("my-tenant", result.TenantId);
}
[Fact]
public void GetCapabilities_EchoesCorrectTenantUrn()
{
var scope = CreateValidScope("my-tenant");
var result = _provider.GetCapabilities(scope);
Assert.Equal("urn:tenant:my-tenant", result.TenantUrn);
}
[Fact]
public void GetCapabilities_EchoesScopes()
{
var scope = CreateValidScope("my-tenant");
var result = _provider.GetCapabilities(scope);
Assert.Contains("concelier.read", result.Scopes);
Assert.Contains("concelier.linkset.write", result.Scopes);
}
[Fact]
public void GetCapabilities_SetsOfflineAllowedFromCapabilities()
{
var scope = CreateValidScope("my-tenant", offlineAllowed: true);
var result = _provider.GetCapabilities(scope);
Assert.True(result.OfflineAllowed);
}
[Fact]
public void GetCapabilities_SetsGeneratedAtTimestamp()
{
var scope = CreateValidScope("my-tenant");
var before = DateTimeOffset.UtcNow;
var result = _provider.GetCapabilities(scope);
var after = DateTimeOffset.UtcNow;
Assert.InRange(result.GeneratedAt, before, after);
}
[Fact]
public void GetCapabilities_NullScope_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() => _provider.GetCapabilities(null!));
}
[Fact]
public void GetCapabilities_ExpiredToken_ThrowsTenantScopeException()
{
var scope = new TenantScope(
TenantId: "my-tenant",
Issuer: "https://test.stellaops.local",
Scopes: [.. new[] { "concelier.read" }],
Capabilities: TenantCapabilities.Default,
Attribution: null,
IssuedAt: DateTimeOffset.UtcNow.AddHours(-2),
ExpiresAt: DateTimeOffset.UtcNow.AddHours(-1)); // Expired
var ex = Assert.Throws<TenantScopeException>(() => _provider.GetCapabilities(scope));
Assert.Equal("auth/token-expired", ex.ErrorCode);
}
[Fact]
public void ValidateScope_WithRequiredScope_DoesNotThrow()
{
var scope = CreateValidScope("my-tenant");
_provider.ValidateScope(scope, "concelier.read");
}
[Fact]
public void ValidateScope_MissingRequiredScope_ThrowsTenantScopeException()
{
var scope = CreateValidScope("my-tenant");
var ex = Assert.Throws<TenantScopeException>(
() => _provider.ValidateScope(scope, "concelier.tenant.admin"));
Assert.Equal("auth/insufficient-scope", ex.ErrorCode);
}
[Fact]
public void ValidateScope_NoRequiredScopes_DoesNotThrow()
{
var scope = CreateValidScope("my-tenant");
_provider.ValidateScope(scope);
}
[Fact]
public void ValidateScope_CaseInsensitiveScopeMatch()
{
var scope = CreateValidScope("my-tenant");
_provider.ValidateScope(scope, "CONCELIER.READ");
}
private static TenantScope CreateValidScope(
string tenantId,
bool mergeAllowed = false,
bool offlineAllowed = true) =>
new(
TenantId: tenantId,
Issuer: "https://test.stellaops.local",
Scopes: [.. new[] { "concelier.read", "concelier.linkset.write" }],
Capabilities: new TenantCapabilities(MergeAllowed: mergeAllowed, OfflineAllowed: offlineAllowed),
Attribution: null,
IssuedAt: DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresAt: DateTimeOffset.UtcNow.AddHours(1));
}

View File

@@ -0,0 +1,126 @@
using StellaOps.Concelier.Core.Tenancy;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Tenancy;
/// <summary>
/// Behavioral tests for TenantScopeNormalizer.
/// Verifies normalization to URN format, extraction, equality checks, and cross-tenant validation.
/// </summary>
public sealed class TenantScopeNormalizerTests
{
[Theory]
[InlineData("tenant-a", "urn:tenant:tenant-a")]
[InlineData("TENANT-A", "urn:tenant:tenant-a")]
[InlineData(" tenant-a ", "urn:tenant:tenant-a")]
[InlineData("urn:tenant:tenant-a", "urn:tenant:tenant-a")]
[InlineData("urn:tenant:TENANT-A", "urn:tenant:tenant-a")]
public void NormalizeToUrn_ProducesCanonicalUrn(string input, string expected)
{
var result = TenantScopeNormalizer.NormalizeToUrn(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void NormalizeToUrn_ThrowsOnEmptyInput(string? input)
{
Assert.Throws<ArgumentException>(() => TenantScopeNormalizer.NormalizeToUrn(input!));
}
[Theory]
[InlineData("urn:tenant:tenant-a", "tenant-a")]
[InlineData("urn:tenant:TENANT-A", "tenant-a")]
[InlineData("tenant-a", "tenant-a")]
[InlineData("TENANT-A", "tenant-a")]
[InlineData(" urn:tenant:tenant-b ", "tenant-b")]
public void ExtractFromUrn_ReturnsRawTenantId(string input, string expected)
{
var result = TenantScopeNormalizer.ExtractFromUrn(input);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ExtractFromUrn_ThrowsOnEmptyInput(string? input)
{
Assert.Throws<ArgumentException>(() => TenantScopeNormalizer.ExtractFromUrn(input!));
}
[Fact]
public void NormalizeForStorage_MatchesExtractFromUrn()
{
var urn = "urn:tenant:MY-TENANT";
var fromExtract = TenantScopeNormalizer.ExtractFromUrn(urn);
var fromStorage = TenantScopeNormalizer.NormalizeForStorage(urn);
Assert.Equal(fromExtract, fromStorage);
}
[Theory]
[InlineData("tenant-a", "tenant-a", true)]
[InlineData("tenant-a", "TENANT-A", true)]
[InlineData("tenant-a", "urn:tenant:tenant-a", true)]
[InlineData("urn:tenant:tenant-a", "urn:tenant:TENANT-A", true)]
[InlineData("tenant-a", "tenant-b", false)]
[InlineData("tenant-a", null, false)]
[InlineData(null, "tenant-a", false)]
[InlineData(null, null, false)]
[InlineData("", "tenant-a", false)]
public void AreEqual_ComparesNormalizedTenants(string? a, string? b, bool expected)
{
var result = TenantScopeNormalizer.AreEqual(a, b);
Assert.Equal(expected, result);
}
[Fact]
public void ValidateTenantMatch_MatchingTenants_DoesNotThrow()
{
var scope = CreateScope("tenant-a");
TenantScopeNormalizer.ValidateTenantMatch("tenant-a", scope);
// No exception means match is valid
}
[Fact]
public void ValidateTenantMatch_MismatchedTenants_ThrowsTenantScopeException()
{
var scope = CreateScope("tenant-a");
var ex = Assert.Throws<TenantScopeException>(
() => TenantScopeNormalizer.ValidateTenantMatch("tenant-b", scope));
Assert.Equal("auth/tenant-mismatch", ex.ErrorCode);
}
[Fact]
public void ValidateTenantMatch_CaseInsensitiveMatch_DoesNotThrow()
{
var scope = CreateScope("tenant-a");
TenantScopeNormalizer.ValidateTenantMatch("TENANT-A", scope);
}
[Fact]
public void ValidateTenantMatch_UrnFormatMatch_DoesNotThrow()
{
var scope = CreateScope("tenant-a");
TenantScopeNormalizer.ValidateTenantMatch("urn:tenant:tenant-a", scope);
}
[Fact]
public void ValidateTenantMatch_NullScope_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(
() => TenantScopeNormalizer.ValidateTenantMatch("tenant-a", null!));
}
private static TenantScope CreateScope(string tenantId) =>
new(
TenantId: tenantId,
Issuer: "https://test.stellaops.local",
Scopes: [.. new[] { "concelier.read", "concelier.linkset.write" }],
Capabilities: TenantCapabilities.Default,
Attribution: null,
IssuedAt: DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresAt: DateTimeOffset.UtcNow.AddHours(1));
}

View File

@@ -0,0 +1,166 @@
using System.Collections.Immutable;
using StellaOps.Concelier.Core.Tenancy;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Tenancy;
/// <summary>
/// Behavioral tests for TenantScope record.
/// Verifies validation, scope checks, URN generation, and access control properties.
/// </summary>
public sealed class TenantScopeTests
{
[Fact]
public void Validate_ValidScope_DoesNotThrow()
{
var scope = CreateScope("my-tenant", scopes: ["concelier.read"]);
scope.Validate(TimeProvider.System);
}
[Fact]
public void Validate_MissingTenantId_ThrowsTenantScopeException()
{
var scope = CreateScope("", scopes: ["concelier.read"]);
var ex = Assert.Throws<TenantScopeException>(() => scope.Validate(TimeProvider.System));
Assert.Equal("auth/tenant-scope-missing", ex.ErrorCode);
}
[Fact]
public void Validate_MissingIssuer_ThrowsTenantScopeException()
{
var scope = new TenantScope(
TenantId: "my-tenant",
Issuer: "",
Scopes: [.. new[] { "concelier.read" }],
Capabilities: TenantCapabilities.Default,
Attribution: null,
IssuedAt: DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresAt: DateTimeOffset.UtcNow.AddHours(1));
var ex = Assert.Throws<TenantScopeException>(() => scope.Validate(TimeProvider.System));
Assert.Equal("auth/tenant-scope-missing", ex.ErrorCode);
}
[Fact]
public void Validate_EmptyScopes_ThrowsTenantScopeException()
{
var scope = CreateScope("my-tenant", scopes: []);
var ex = Assert.Throws<TenantScopeException>(() => scope.Validate(TimeProvider.System));
Assert.Equal("auth/tenant-scope-missing", ex.ErrorCode);
}
[Fact]
public void Validate_NoConcielierScope_ThrowsTenantScopeException()
{
var scope = CreateScope("my-tenant", scopes: ["other.read"]);
var ex = Assert.Throws<TenantScopeException>(() => scope.Validate(TimeProvider.System));
Assert.Equal("auth/tenant-scope-missing", ex.ErrorCode);
}
[Fact]
public void Validate_ExpiredToken_ThrowsTenantScopeException()
{
var scope = new TenantScope(
TenantId: "my-tenant",
Issuer: "https://test.stellaops.local",
Scopes: [.. new[] { "concelier.read" }],
Capabilities: TenantCapabilities.Default,
Attribution: null,
IssuedAt: DateTimeOffset.UtcNow.AddHours(-2),
ExpiresAt: DateTimeOffset.UtcNow.AddHours(-1));
var ex = Assert.Throws<TenantScopeException>(() => scope.Validate(TimeProvider.System));
Assert.Equal("auth/token-expired", ex.ErrorCode);
}
[Theory]
[InlineData("concelier.read", true)]
[InlineData("concelier.linkset.read", true)]
[InlineData("other.read", false)]
public void CanRead_ReflectsReadScopes(string scopeValue, bool expectedCanRead)
{
var scope = CreateScope("my-tenant", scopes: [scopeValue, "concelier.dummy"]);
Assert.Equal(expectedCanRead, scope.CanRead);
}
[Theory]
[InlineData("concelier.linkset.write", true)]
[InlineData("concelier.read", false)]
public void CanWrite_ReflectsWriteScope(string scopeValue, bool expectedCanWrite)
{
var scope = CreateScope("my-tenant", scopes: [scopeValue, "concelier.dummy"]);
Assert.Equal(expectedCanWrite, scope.CanWrite);
}
[Theory]
[InlineData("concelier.tenant.admin", true)]
[InlineData("concelier.read", false)]
public void CanAdminTenant_ReflectsAdminScope(string scopeValue, bool expectedCanAdmin)
{
var scope = CreateScope("my-tenant", scopes: [scopeValue, "concelier.dummy"]);
Assert.Equal(expectedCanAdmin, scope.CanAdminTenant);
}
[Fact]
public void TenantUrn_RawId_ReturnsUrnFormat()
{
var scope = CreateScope("my-tenant", scopes: ["concelier.read"]);
Assert.Equal("urn:tenant:my-tenant", scope.TenantUrn);
}
[Fact]
public void TenantUrn_AlreadyUrn_ReturnsAsIs()
{
var scope = CreateScope("urn:tenant:my-tenant", scopes: ["concelier.read"]);
Assert.Equal("urn:tenant:my-tenant", scope.TenantUrn);
}
[Fact]
public void HasRequiredScope_ConcielierScope_ReturnsTrue()
{
var scope = CreateScope("my-tenant", scopes: ["concelier.read"]);
Assert.True(scope.HasRequiredScope());
}
[Fact]
public void HasRequiredScope_NoConcielierScope_ReturnsFalse()
{
// Need to bypass validation to test this
var scope = new TenantScope(
TenantId: "my-tenant",
Issuer: "https://test.stellaops.local",
Scopes: [.. new[] { "other.read" }],
Capabilities: TenantCapabilities.Default,
Attribution: null,
IssuedAt: DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresAt: DateTimeOffset.UtcNow.AddHours(1));
Assert.False(scope.HasRequiredScope());
}
[Fact]
public void TenantCapabilities_Default_MergeDisabledOfflineEnabled()
{
var caps = TenantCapabilities.Default;
Assert.False(caps.MergeAllowed);
Assert.True(caps.OfflineAllowed);
}
[Fact]
public void TenantScopeException_StoresErrorCode()
{
var ex = new TenantScopeException("auth/test-code", "test message");
Assert.Equal("auth/test-code", ex.ErrorCode);
Assert.Equal("test message", ex.Message);
}
private static TenantScope CreateScope(string tenantId, string[] scopes) =>
new(
TenantId: tenantId,
Issuer: "https://test.stellaops.local",
Scopes: scopes.Length > 0 ? [.. scopes] : ImmutableArray<string>.Empty,
Capabilities: TenantCapabilities.Default,
Attribution: null,
IssuedAt: DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresAt: DateTimeOffset.UtcNow.AddHours(1));
}

View File

@@ -0,0 +1,282 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Advisories;
namespace StellaOps.Concelier.Merge.Tests.Identity;
public sealed class MergeHashShadowWriteServiceTests
{
private readonly InMemoryAdvisoryStore _advisoryStore = new();
private readonly Mock<IMergeHashCalculator> _calculator = new();
private readonly MergeHashShadowWriteService _service;
public MergeHashShadowWriteServiceTests()
{
_service = new MergeHashShadowWriteService(
_advisoryStore,
_calculator.Object,
NullLogger<MergeHashShadowWriteService>.Instance);
}
private static Advisory CreateAdvisory(string key, string? mergeHash = null)
=> new(
advisoryKey: key,
title: $"Advisory {key}",
summary: null,
language: null,
published: null,
modified: null,
severity: null,
exploitKnown: false,
aliases: null,
references: null,
affectedPackages: null,
cvssMetrics: null,
provenance: null,
mergeHash: mergeHash);
// --- BackfillAllAsync ---
[Fact]
public async Task BackfillAllAsync_NoAdvisories_ReturnsZeroCounts()
{
var result = await _service.BackfillAllAsync(CancellationToken.None);
Assert.Equal(0, result.Processed);
Assert.Equal(0, result.Updated);
Assert.Equal(0, result.Skipped);
Assert.Equal(0, result.Failed);
}
[Fact]
public async Task BackfillAllAsync_AdvisoryWithoutHash_ComputesAndPersists()
{
var advisory = CreateAdvisory("CVE-2024-0001");
await _advisoryStore.UpsertAsync(advisory, CancellationToken.None);
_calculator.Setup(c => c.ComputeMergeHash(It.IsAny<Advisory>()))
.Returns("sha256:abc123");
var result = await _service.BackfillAllAsync(CancellationToken.None);
Assert.Equal(1, result.Processed);
Assert.Equal(1, result.Updated);
Assert.Equal(0, result.Skipped);
Assert.Equal(0, result.Failed);
var stored = await _advisoryStore.FindAsync("CVE-2024-0001", CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal("sha256:abc123", stored.MergeHash);
}
[Fact]
public async Task BackfillAllAsync_AdvisoryAlreadyHasHash_SkipsIt()
{
var advisory = CreateAdvisory("CVE-2024-0002", mergeHash: "sha256:existing");
await _advisoryStore.UpsertAsync(advisory, CancellationToken.None);
var result = await _service.BackfillAllAsync(CancellationToken.None);
Assert.Equal(1, result.Processed);
Assert.Equal(0, result.Updated);
Assert.Equal(1, result.Skipped);
Assert.Equal(0, result.Failed);
_calculator.Verify(c => c.ComputeMergeHash(It.IsAny<Advisory>()), Times.Never);
}
[Fact]
public async Task BackfillAllAsync_MixedAdvisories_UpdatesOnlyMissing()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0001"), CancellationToken.None);
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0002", "sha256:existing"), CancellationToken.None);
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0003"), CancellationToken.None);
_calculator.Setup(c => c.ComputeMergeHash(It.IsAny<Advisory>()))
.Returns("sha256:computed");
var result = await _service.BackfillAllAsync(CancellationToken.None);
Assert.Equal(3, result.Processed);
Assert.Equal(2, result.Updated);
Assert.Equal(1, result.Skipped);
Assert.Equal(0, result.Failed);
}
[Fact]
public async Task BackfillAllAsync_CalculatorThrows_CountsAsFailedAndContinues()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0001"), CancellationToken.None);
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0002"), CancellationToken.None);
var callCount = 0;
_calculator.Setup(c => c.ComputeMergeHash(It.IsAny<Advisory>()))
.Returns(() =>
{
callCount++;
if (callCount == 1) throw new InvalidOperationException("bad input");
return "sha256:ok";
});
var result = await _service.BackfillAllAsync(CancellationToken.None);
Assert.Equal(2, result.Processed);
Assert.Equal(1, result.Updated);
Assert.Equal(0, result.Skipped);
Assert.Equal(1, result.Failed);
}
[Fact]
public async Task BackfillAllAsync_Cancellation_ThrowsOperationCanceled()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0001"), CancellationToken.None);
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(
() => _service.BackfillAllAsync(cts.Token));
}
// --- BackfillOneAsync ---
[Fact]
public async Task BackfillOneAsync_AdvisoryNotFound_ReturnsFalse()
{
var result = await _service.BackfillOneAsync("CVE-2024-MISSING", false, CancellationToken.None);
Assert.False(result);
}
[Fact]
public async Task BackfillOneAsync_AdvisoryWithoutHash_ComputesAndPersists()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0001"), CancellationToken.None);
_calculator.Setup(c => c.ComputeMergeHash(It.IsAny<Advisory>()))
.Returns("sha256:newHash");
var result = await _service.BackfillOneAsync("CVE-2024-0001", false, CancellationToken.None);
Assert.True(result);
var stored = await _advisoryStore.FindAsync("CVE-2024-0001", CancellationToken.None);
Assert.Equal("sha256:newHash", stored!.MergeHash);
}
[Fact]
public async Task BackfillOneAsync_AdvisoryAlreadyHasHash_NoForce_ReturnsFalse()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0001", "sha256:old"), CancellationToken.None);
var result = await _service.BackfillOneAsync("CVE-2024-0001", false, CancellationToken.None);
Assert.False(result);
_calculator.Verify(c => c.ComputeMergeHash(It.IsAny<Advisory>()), Times.Never);
}
[Fact]
public async Task BackfillOneAsync_AdvisoryAlreadyHasHash_ForceTrue_Recomputes()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0001", "sha256:old"), CancellationToken.None);
_calculator.Setup(c => c.ComputeMergeHash(It.IsAny<Advisory>()))
.Returns("sha256:recomputed");
var result = await _service.BackfillOneAsync("CVE-2024-0001", true, CancellationToken.None);
Assert.True(result);
var stored = await _advisoryStore.FindAsync("CVE-2024-0001", CancellationToken.None);
Assert.Equal("sha256:recomputed", stored!.MergeHash);
}
[Fact]
public async Task BackfillOneAsync_CalculatorThrows_PropagatesException()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0001"), CancellationToken.None);
_calculator.Setup(c => c.ComputeMergeHash(It.IsAny<Advisory>()))
.Throws(new InvalidOperationException("bad"));
await Assert.ThrowsAsync<InvalidOperationException>(
() => _service.BackfillOneAsync("CVE-2024-0001", false, CancellationToken.None));
}
[Fact]
public async Task BackfillOneAsync_NullOrWhitespaceKey_ThrowsArgumentException()
{
await Assert.ThrowsAsync<ArgumentException>(
() => _service.BackfillOneAsync("", false, CancellationToken.None));
await Assert.ThrowsAsync<ArgumentException>(
() => _service.BackfillOneAsync(" ", false, CancellationToken.None));
}
// --- Constructor validation ---
[Fact]
public void Constructor_NullAdvisoryStore_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() =>
new MergeHashShadowWriteService(null!, _calculator.Object, NullLogger<MergeHashShadowWriteService>.Instance));
}
[Fact]
public void Constructor_NullCalculator_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() =>
new MergeHashShadowWriteService(_advisoryStore, null!, NullLogger<MergeHashShadowWriteService>.Instance));
}
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() =>
new MergeHashShadowWriteService(_advisoryStore, _calculator.Object, null!));
}
// --- ShadowWriteResult ---
[Fact]
public void ShadowWriteResult_RecordProperties_AreCorrect()
{
var result = new ShadowWriteResult(100, 50, 45, 5);
Assert.Equal(100, result.Processed);
Assert.Equal(50, result.Updated);
Assert.Equal(45, result.Skipped);
Assert.Equal(5, result.Failed);
}
// --- Enrichment preserves advisory fields ---
[Fact]
public async Task BackfillOneAsync_PreservesAllAdvisoryFields()
{
var original = new Advisory(
advisoryKey: "CVE-2024-9999",
title: "Test Advisory",
summary: "A summary",
language: "en",
published: new DateTimeOffset(2024, 6, 1, 0, 0, 0, TimeSpan.Zero),
modified: new DateTimeOffset(2024, 7, 1, 0, 0, 0, TimeSpan.Zero),
severity: "high",
exploitKnown: true,
aliases: new[] { "CVE-2024-9999" },
references: null,
affectedPackages: null,
cvssMetrics: null,
provenance: null,
description: "A description",
cwes: null,
canonicalMetricId: "metric-001");
await _advisoryStore.UpsertAsync(original, CancellationToken.None);
_calculator.Setup(c => c.ComputeMergeHash(It.IsAny<Advisory>()))
.Returns("sha256:enriched");
await _service.BackfillOneAsync("CVE-2024-9999", false, CancellationToken.None);
var stored = await _advisoryStore.FindAsync("CVE-2024-9999", CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal("CVE-2024-9999", stored.AdvisoryKey);
Assert.Equal("Test Advisory", stored.Title);
Assert.Equal("A summary", stored.Summary);
Assert.Equal("en", stored.Language);
Assert.True(stored.ExploitKnown);
Assert.Equal("A description", stored.Description);
Assert.Equal("sha256:enriched", stored.MergeHash);
}
}

View File

@@ -0,0 +1,144 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Merge.Jobs;
namespace StellaOps.Concelier.Merge.Tests.Jobs;
public sealed class MergeHashBackfillJobTests
{
private readonly MergeHashBackfillJob _job;
public MergeHashBackfillJobTests()
{
var advisoryStore = new StellaOps.Concelier.Storage.Advisories.InMemoryAdvisoryStore();
var calculator = new Mock<IMergeHashCalculator>();
calculator.Setup(c => c.ComputeMergeHash(It.IsAny<StellaOps.Concelier.Models.Advisory>()))
.Returns("sha256:test");
var shadowWriteService = new MergeHashShadowWriteService(
advisoryStore,
calculator.Object,
NullLogger<MergeHashShadowWriteService>.Instance);
_job = new MergeHashBackfillJob(
shadowWriteService,
NullLogger<MergeHashBackfillJob>.Instance);
}
private static JobExecutionContext CreateContext(Dictionary<string, object?>? parameters = null)
{
var services = new ServiceCollection().BuildServiceProvider();
return new JobExecutionContext(
Guid.NewGuid(),
"merge-hash-backfill",
"manual",
parameters ?? new Dictionary<string, object?>(),
services,
TimeProvider.System,
NullLogger.Instance);
}
[Fact]
public async Task ExecuteAsync_NoSeed_CallsBackfillAll()
{
var context = CreateContext();
// Should not throw - runs BackfillAllAsync on empty store
await _job.ExecuteAsync(context, CancellationToken.None);
}
[Fact]
public async Task ExecuteAsync_WithSeed_CallsBackfillOne()
{
var context = CreateContext(new Dictionary<string, object?>
{
["seed"] = "CVE-2024-0001"
});
// Advisory not found, but should not throw (BackfillOneAsync returns false)
await _job.ExecuteAsync(context, CancellationToken.None);
}
[Fact]
public async Task ExecuteAsync_WithSeedAndForce_ParsesForceParameter()
{
var context = CreateContext(new Dictionary<string, object?>
{
["seed"] = "CVE-2024-0001",
["force"] = "true"
});
await _job.ExecuteAsync(context, CancellationToken.None);
}
[Fact]
public async Task ExecuteAsync_EmptySeed_FallsBackToAll()
{
var context = CreateContext(new Dictionary<string, object?>
{
["seed"] = ""
});
// Empty seed should fall through to BackfillAllAsync
await _job.ExecuteAsync(context, CancellationToken.None);
}
[Fact]
public async Task ExecuteAsync_WhitespaceSeed_FallsBackToAll()
{
var context = CreateContext(new Dictionary<string, object?>
{
["seed"] = " "
});
await _job.ExecuteAsync(context, CancellationToken.None);
}
[Fact]
public async Task ExecuteAsync_ForceNotTrue_DefaultsToFalse()
{
var context = CreateContext(new Dictionary<string, object?>
{
["seed"] = "CVE-2024-0001",
["force"] = "false"
});
await _job.ExecuteAsync(context, CancellationToken.None);
}
[Fact]
public async Task ExecuteAsync_ForceNotString_DefaultsToFalse()
{
var context = CreateContext(new Dictionary<string, object?>
{
["seed"] = "CVE-2024-0001",
["force"] = 42 // not a string
});
await _job.ExecuteAsync(context, CancellationToken.None);
}
// --- Constructor validation ---
[Fact]
public void Constructor_NullShadowWriteService_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() =>
new MergeHashBackfillJob(null!, NullLogger<MergeHashBackfillJob>.Instance));
}
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNull()
{
var store = new StellaOps.Concelier.Storage.Advisories.InMemoryAdvisoryStore();
var calc = new Mock<IMergeHashCalculator>();
var svc = new MergeHashShadowWriteService(store, calc.Object, NullLogger<MergeHashShadowWriteService>.Instance);
Assert.Throws<ArgumentNullException>(() =>
new MergeHashBackfillJob(svc, null!));
}
}

View File

@@ -0,0 +1,245 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Concelier.Merge.Identity;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Advisories;
namespace StellaOps.Concelier.Merge.Tests.Services;
public sealed class MergeHashBackfillServiceTests
{
private readonly InMemoryAdvisoryStore _advisoryStore = new();
private readonly Mock<IMergeHashCalculator> _calculator = new();
private readonly MergeHashBackfillService _service;
public MergeHashBackfillServiceTests()
{
_service = new MergeHashBackfillService(
_advisoryStore,
_calculator.Object,
NullLogger<MergeHashBackfillService>.Instance);
}
private static Advisory CreateAdvisory(string key, string? mergeHash = null)
=> new(
advisoryKey: key,
title: $"Advisory {key}",
summary: null,
language: null,
published: null,
modified: null,
severity: null,
exploitKnown: false,
aliases: null,
references: null,
affectedPackages: null,
cvssMetrics: null,
provenance: null,
mergeHash: mergeHash);
// --- BackfillAsync ---
[Fact]
public async Task BackfillAsync_NoAdvisories_ReturnsZeroCounts()
{
var result = await _service.BackfillAsync();
Assert.Equal(0, result.TotalProcessed);
Assert.Equal(0, result.Updated);
Assert.Equal(0, result.Skipped);
Assert.Equal(0, result.Errors);
Assert.False(result.DryRun);
}
[Fact]
public async Task BackfillAsync_AdvisoryWithoutHash_ComputesAndPersists()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0001"), CancellationToken.None);
_calculator.Setup(c => c.ComputeMergeHash(It.IsAny<Advisory>()))
.Returns("sha256:abc123");
var result = await _service.BackfillAsync();
Assert.Equal(1, result.TotalProcessed);
Assert.Equal(1, result.Updated);
Assert.Equal(0, result.Skipped);
Assert.Equal(0, result.Errors);
var stored = await _advisoryStore.FindAsync("CVE-2024-0001", CancellationToken.None);
Assert.NotNull(stored);
Assert.Equal("sha256:abc123", stored.MergeHash);
}
[Fact]
public async Task BackfillAsync_AdvisoryAlreadyHasHash_SkipsIt()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0002", "sha256:existing"), CancellationToken.None);
var result = await _service.BackfillAsync();
Assert.Equal(1, result.TotalProcessed);
Assert.Equal(0, result.Updated);
Assert.Equal(1, result.Skipped);
Assert.Equal(0, result.Errors);
_calculator.Verify(c => c.ComputeMergeHash(It.IsAny<Advisory>()), Times.Never);
}
[Fact]
public async Task BackfillAsync_DryRun_ComputesButDoesNotPersist()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0001"), CancellationToken.None);
_calculator.Setup(c => c.ComputeMergeHash(It.IsAny<Advisory>()))
.Returns("sha256:computed");
var result = await _service.BackfillAsync(dryRun: true);
Assert.Equal(1, result.TotalProcessed);
Assert.Equal(1, result.Updated);
Assert.True(result.DryRun);
// Advisory should NOT have been updated in store
var stored = await _advisoryStore.FindAsync("CVE-2024-0001", CancellationToken.None);
Assert.Null(stored!.MergeHash);
}
[Fact]
public async Task BackfillAsync_CalculatorThrows_CountsAsErrorAndContinues()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0001"), CancellationToken.None);
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0002"), CancellationToken.None);
var callCount = 0;
_calculator.Setup(c => c.ComputeMergeHash(It.IsAny<Advisory>()))
.Returns(() =>
{
callCount++;
if (callCount == 1) throw new InvalidOperationException("bad");
return "sha256:ok";
});
var result = await _service.BackfillAsync();
Assert.Equal(2, result.TotalProcessed);
Assert.Equal(1, result.Updated);
Assert.Equal(0, result.Skipped);
Assert.Equal(1, result.Errors);
}
[Fact]
public async Task BackfillAsync_MixedAdvisories_CorrectCounts()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0001"), CancellationToken.None);
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0002", "sha256:has"), CancellationToken.None);
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0003"), CancellationToken.None);
_calculator.Setup(c => c.ComputeMergeHash(It.IsAny<Advisory>()))
.Returns("sha256:filled");
var result = await _service.BackfillAsync();
Assert.Equal(3, result.TotalProcessed);
Assert.Equal(2, result.Updated);
Assert.Equal(1, result.Skipped);
Assert.Equal(0, result.Errors);
}
[Fact]
public async Task BackfillAsync_Cancellation_ThrowsOperationCanceled()
{
await _advisoryStore.UpsertAsync(CreateAdvisory("CVE-2024-0001"), CancellationToken.None);
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(
() => _service.BackfillAsync(cancellationToken: cts.Token));
}
[Fact]
public async Task BackfillAsync_RecordsDuration()
{
var result = await _service.BackfillAsync();
Assert.True(result.Duration >= TimeSpan.Zero);
}
// --- ComputeMergeHash (preview) ---
[Fact]
public void ComputeMergeHash_DelegatesToCalculator()
{
var advisory = CreateAdvisory("CVE-2024-0001");
_calculator.Setup(c => c.ComputeMergeHash(advisory))
.Returns("sha256:preview");
var hash = _service.ComputeMergeHash(advisory);
Assert.Equal("sha256:preview", hash);
_calculator.Verify(c => c.ComputeMergeHash(advisory), Times.Once);
}
[Fact]
public void ComputeMergeHash_NullAdvisory_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() => _service.ComputeMergeHash(null!));
}
// --- MergeHashBackfillResult ---
[Fact]
public void BackfillResult_SuccessRate_AllUpdatedOrSkipped()
{
var result = new MergeHashBackfillResult(100, 60, 40, 0, false, TimeSpan.FromSeconds(5));
Assert.Equal(100.0, result.SuccessRate);
}
[Fact]
public void BackfillResult_SuccessRate_WithErrors()
{
var result = new MergeHashBackfillResult(100, 50, 40, 10, false, TimeSpan.FromSeconds(5));
Assert.Equal(90.0, result.SuccessRate);
}
[Fact]
public void BackfillResult_SuccessRate_ZeroProcessed_Returns100()
{
var result = new MergeHashBackfillResult(0, 0, 0, 0, false, TimeSpan.Zero);
Assert.Equal(100.0, result.SuccessRate);
}
[Fact]
public void BackfillResult_AvgTimePerAdvisoryMs_CorrectCalculation()
{
var result = new MergeHashBackfillResult(10, 5, 5, 0, false, TimeSpan.FromMilliseconds(1000));
Assert.Equal(100.0, result.AvgTimePerAdvisoryMs);
}
[Fact]
public void BackfillResult_AvgTimePerAdvisoryMs_ZeroProcessed_ReturnsZero()
{
var result = new MergeHashBackfillResult(0, 0, 0, 0, false, TimeSpan.FromMilliseconds(100));
Assert.Equal(0.0, result.AvgTimePerAdvisoryMs);
}
// --- Constructor validation ---
[Fact]
public void Constructor_NullAdvisoryStore_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() =>
new MergeHashBackfillService(null!, _calculator.Object, NullLogger<MergeHashBackfillService>.Instance));
}
[Fact]
public void Constructor_NullCalculator_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() =>
new MergeHashBackfillService(_advisoryStore, null!, NullLogger<MergeHashBackfillService>.Instance));
}
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNull()
{
Assert.Throws<ArgumentNullException>(() =>
new MergeHashBackfillService(_advisoryStore, _calculator.Object, null!));
}
}

View File

@@ -0,0 +1,212 @@
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Concelier.SbomIntegration.Models;
using StellaOps.Concelier.SbomIntegration.Parsing;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Concelier.SbomIntegration.Tests.Parsing;
/// <summary>
/// Edge-case tests for ParsedSbomParser: error paths, null guards,
/// unsupported formats, invalid JSON, and seekable stream behavior.
/// </summary>
public sealed class ParsedSbomParserEdgeCaseTests
{
private readonly ParsedSbomParser _parser =
new(NullLogger<ParsedSbomParser>.Instance);
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNullException()
{
var act = () => new ParsedSbomParser(null!);
act.Should().Throw<ArgumentNullException>()
.And.ParamName.Should().Be("logger");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ParseAsync_NullContent_ThrowsArgumentNullException()
{
var act = () => _parser.ParseAsync(null!, SbomFormat.CycloneDX);
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ParseAsync_UnsupportedFormat_ThrowsArgumentException()
{
using var stream = new MemoryStream("{}"u8.ToArray());
var act = () => _parser.ParseAsync(stream, (SbomFormat)999);
await act.Should().ThrowAsync<ArgumentException>()
.WithMessage("*Unsupported SBOM format*");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ParseAsync_InvalidJson_ThrowsJsonException()
{
using var stream = new MemoryStream("not-json"u8.ToArray());
var act = () => _parser.ParseAsync(stream, SbomFormat.CycloneDX);
await act.Should().ThrowAsync<JsonException>();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ParseAsync_SeekableStream_ResetsPosition()
{
var json = """
{
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": "urn:uuid:test"
}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
// Advance the stream position past the beginning
stream.Position = 5;
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
result.SerialNumber.Should().Be("urn:uuid:test");
result.Format.Should().Be("cyclonedx");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ParseAsync_CycloneDx_MinimalDocument_ReturnsDefaults()
{
var json = """
{
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": "urn:uuid:min"
}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
result.Components.Should().BeEmpty();
result.Services.Should().BeEmpty();
result.Dependencies.Should().BeEmpty();
result.Vulnerabilities.Should().BeEmpty();
result.Compositions.Should().BeEmpty();
result.Annotations.Should().BeEmpty();
result.Formulation.Should().BeNull();
result.Declarations.Should().BeNull();
result.Definitions.Should().BeNull();
result.Signature.Should().BeNull();
result.Metadata.Should().NotBeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ParseAsync_Spdx3_MinimalDocument_ReturnsDefaults()
{
var json = """
{
"@graph": [
{
"@type": "SpdxDocument",
"spdxId": "SPDXRef-DOCUMENT",
"creationInfo": {
"specVersion": "3.0.1",
"created": "2026-01-01T00:00:00Z"
},
"name": "test-doc"
}
]
}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
var result = await _parser.ParseAsync(stream, SbomFormat.SPDX);
result.Format.Should().Be("spdx");
result.SpecVersion.Should().Be("3.0.1");
result.Components.Should().BeEmpty();
result.Services.Should().BeEmpty();
result.Dependencies.Should().BeEmpty();
result.Vulnerabilities.Should().BeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ParseAsync_CycloneDx_ComponentWithoutName_IsSkipped()
{
var json = """
{
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": "urn:uuid:skip",
"components": [
{ "bom-ref": "noname" },
{ "bom-ref": "hasname", "name": "valid-lib", "version": "1.0" }
]
}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
result.Components.Should().ContainSingle(c => c.Name == "valid-lib");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ParseAsync_CycloneDx_DuplicateBomRefs_AreDeduped()
{
var json = """
{
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": "urn:uuid:dedup",
"components": [
{ "bom-ref": "dup", "name": "lib-a", "version": "1.0" },
{ "bom-ref": "dup", "name": "lib-b", "version": "2.0" },
{ "bom-ref": "unique", "name": "lib-c", "version": "3.0" }
]
}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
// Duplicate bom-refs should be deduped (first wins)
result.Components.Length.Should().Be(2);
result.Components.Should().Contain(c => c.BomRef == "dup");
result.Components.Should().Contain(c => c.BomRef == "unique");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ParseAsync_CancellationToken_Honored()
{
var json = """
{
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": "urn:uuid:cancel"
}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
using var cts = new CancellationTokenSource();
cts.Cancel();
var act = () => _parser.ParseAsync(stream, SbomFormat.CycloneDX, cts.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
}