audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Core.AirGap;
using StellaOps.Concelier.Core.AirGap.Models;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.AirGap;
public sealed class BundleCatalogServiceTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task GetCatalogAsync_SortsSourcesAndUsesInvariantCursor()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
var tempRoot = Path.Combine(
Path.GetTempPath(),
$"concelier-bundle-catalog-{Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)}");
var dirA = Path.Combine(tempRoot, "a");
var dirB = Path.Combine(tempRoot, "b");
Directory.CreateDirectory(dirA);
Directory.CreateDirectory(dirB);
File.WriteAllText(Path.Combine(dirA, "bundle-b.bundle.json"), "{}");
File.WriteAllText(Path.Combine(dirB, "bundle-a.bundle.json"), "{}");
try
{
CultureInfo.CurrentCulture = new CultureInfo("ar-SA");
CultureInfo.CurrentUICulture = new CultureInfo("ar-SA");
var sources = new[]
{
new BundleSourceInfo
{
Id = "b-source",
Type = "directory",
Location = dirA,
Enabled = true,
RegisteredAt = FixedNow
},
new BundleSourceInfo
{
Id = "a-source",
Type = "directory",
Location = dirB,
Enabled = true,
RegisteredAt = FixedNow
}
};
var registry = new FakeBundleSourceRegistry(sources);
var service = new BundleCatalogService(
registry,
NullLogger<BundleCatalogService>.Instance,
new FakeTimeProvider(FixedNow));
var firstPage = await service.GetCatalogAsync(
cursor: null,
limit: 1,
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(new[] { "a-source", "b-source" }, firstPage.SourceIds.ToArray());
Assert.Single(firstPage.Entries);
Assert.Equal("bundle-a.bundle", firstPage.Entries[0].BundleId);
Assert.False(string.IsNullOrWhiteSpace(firstPage.NextCursor));
Assert.All(firstPage.NextCursor!, ch => Assert.InRange(ch, '0', '9'));
var secondPage = await service.GetCatalogAsync(
firstPage.NextCursor,
limit: 1,
cancellationToken: TestContext.Current.CancellationToken);
Assert.Single(secondPage.Entries);
Assert.Equal("bundle-b.bundle", secondPage.Entries[0].BundleId);
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
private sealed class FakeBundleSourceRegistry : IBundleSourceRegistry
{
private readonly IReadOnlyList<BundleSourceInfo> _sources;
public FakeBundleSourceRegistry(IReadOnlyList<BundleSourceInfo> sources)
{
_sources = sources;
}
public IReadOnlyList<BundleSourceInfo> GetSources() => _sources;
public BundleSourceInfo? GetSource(string sourceId)
=> _sources.FirstOrDefault(source => string.Equals(source.Id, sourceId, StringComparison.OrdinalIgnoreCase));
public Task<BundleSourceInfo> RegisterAsync(BundleSourceRegistration registration, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("RegisterAsync is not used by BundleCatalogServiceTests.");
public Task<bool> UnregisterAsync(string sourceId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("UnregisterAsync is not used by BundleCatalogServiceTests.");
public Task<BundleSourceValidationResult> ValidateAsync(string sourceId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("ValidateAsync is not used by BundleCatalogServiceTests.");
public Task<bool> SetEnabledAsync(string sourceId, bool enabled, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("SetEnabledAsync is not used by BundleCatalogServiceTests.");
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Globalization;
using System.IO;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Core.AirGap;
using StellaOps.Concelier.Core.AirGap.Models;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.AirGap;
public sealed class BundleSourceRegistryTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task ValidateAsync_UsesTimeProviderForValidatedAt()
{
var tempRoot = Path.Combine(
Path.GetTempPath(),
$"concelier-bundle-registry-{Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)}");
Directory.CreateDirectory(tempRoot);
try
{
var registry = new BundleSourceRegistry(
NullLogger<BundleSourceRegistry>.Instance,
new FakeTimeProvider(FixedNow));
var registration = new BundleSourceRegistration
{
Id = "source-a",
Type = "directory",
Location = tempRoot
};
await registry.RegisterAsync(registration, TestContext.Current.CancellationToken);
var result = await registry.ValidateAsync("source-a", TestContext.Current.CancellationToken);
Assert.Equal(FixedNow, result.ValidatedAt);
Assert.Equal(BundleSourceStatus.Healthy, result.Status);
Assert.Equal(0, result.BundleCount);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
}
}

View File

@@ -14,6 +14,7 @@ using StellaOps.Concelier.BackportProof.Services;
using StellaOps.Concelier.Merge.Comparers;
using StellaOps.TestKit;
using StellaOps.VersionComparison.Comparers;
using System.Globalization;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.BackportProof;
@@ -30,7 +31,10 @@ namespace StellaOps.Concelier.Core.Tests.BackportProof;
[Trait("Category", TestCategories.Unit)]
public sealed class BackportVerdictDeterminismTests
{
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
private static readonly DateTimeOffset FixedTimestamp = DateTimeOffset.Parse(
"2025-01-01T00:00:00Z",
CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind);
private readonly ITestOutputHelper _output;
private readonly IVersionComparatorFactory _comparatorFactory;
@@ -43,7 +47,7 @@ public sealed class BackportVerdictDeterminismTests
ApkVersionComparer.Instance);
}
#region Same Input Same Verdict Tests
#region Same Input -> Same Verdict Tests
[Fact]
public async Task SameInput_ProducesIdenticalVerdict_Across10Iterations()
@@ -98,7 +102,7 @@ public sealed class BackportVerdictDeterminismTests
// Create rules in different orders
var rulesOrder1 = CreateTestRules(context, package.Key, cve).ToList();
var rulesOrder2 = rulesOrder1.AsEnumerable().Reverse().ToList();
var rulesOrder3 = rulesOrder1.OrderBy(_ => Guid.NewGuid()).ToList();
var rulesOrder3 = rulesOrder1.Skip(1).Concat(rulesOrder1.Take(1)).ToList();
var repository1 = CreateMockRepository(rulesOrder1);
var repository2 = CreateMockRepository(rulesOrder2);
@@ -432,7 +436,7 @@ public sealed class BackportVerdictDeterminismTests
"test-source",
"https://example.com/advisory",
"sha256:test123",
DateTimeOffset.Parse("2025-01-01T00:00:00Z")),
DateTimeOffset.Parse("2025-01-01T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)),
FixedVersion = "1.36.1-r16"
},
new BoundaryRule
@@ -447,7 +451,7 @@ public sealed class BackportVerdictDeterminismTests
"vendor-csaf",
"https://vendor.example.com/csaf",
"sha256:vendor456",
DateTimeOffset.Parse("2025-01-02T00:00:00Z")),
DateTimeOffset.Parse("2025-01-02T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)),
FixedVersion = "1.36.1-r16"
}
};

View File

@@ -2,11 +2,12 @@
// BugCveMappingIntegrationTests.cs
// Sprint: SPRINT_20251230_001_BE_backport_resolver (BP-409)
// Task: Integration test: Debian tracker lookup
// Description: E2E tests for bug ID CVE mapping services
// Description: E2E tests for bug ID -> CVE mapping services
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
@@ -19,7 +20,7 @@ using System.Text;
namespace StellaOps.Concelier.Core.Tests.BackportProof;
/// <summary>
/// Integration tests for bug ID CVE mapping services.
/// Integration tests for bug ID -> CVE mapping services.
/// Tests the full flow from bug reference extraction to CVE lookup.
/// </summary>
[Trait("Category", TestCategories.Integration)]
@@ -27,18 +28,25 @@ public sealed class BugCveMappingIntegrationTests : IDisposable
{
private readonly IMemoryCache _cache;
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
private readonly HttpClient _httpClient;
private readonly ServiceProvider _serviceProvider;
private readonly IHttpClientFactory _httpClientFactory;
public BugCveMappingIntegrationTests()
{
_cache = new MemoryCache(new MemoryCacheOptions());
_httpHandlerMock = new Mock<HttpMessageHandler>();
_httpClient = new HttpClient(_httpHandlerMock.Object);
var services = new ServiceCollection();
services.AddHttpClient("DebianSecurityTracker")
.ConfigurePrimaryHttpMessageHandler(() => _httpHandlerMock.Object);
services.AddHttpClient("RedHatErrata")
.ConfigurePrimaryHttpMessageHandler(() => _httpHandlerMock.Object);
_serviceProvider = services.BuildServiceProvider();
_httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>();
}
public void Dispose()
{
_httpClient.Dispose();
_serviceProvider.Dispose();
_cache.Dispose();
}
@@ -477,9 +485,7 @@ public sealed class BugCveMappingIntegrationTests : IDisposable
private IHttpClientFactory CreateHttpClientFactory()
{
var factory = new Mock<IHttpClientFactory>();
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(_httpClient);
return factory.Object;
return _httpClientFactory;
}
#endregion

View File

@@ -8,6 +8,7 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Concelier.Core.Canonical;
@@ -25,6 +26,8 @@ public sealed class CanonicalAdvisoryServiceTests
private static readonly Guid TestCanonicalId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid TestSourceId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid TestEdgeId = Guid.Parse("33333333-3333-3333-3333-333333333333");
private static readonly DateTimeOffset FixedNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static readonly FakeTimeProvider FixedTimeProvider = new(FixedNow);
public CanonicalAdvisoryServiceTests()
{
@@ -83,6 +86,50 @@ public sealed class CanonicalAdvisoryServiceTests
Times.Once);
}
[Fact]
public async Task IngestAsync_UsesTimeProvider_WhenFetchedAtIsDefault()
{
// Arrange
_storeMock
.Setup(x => x.GetByMergeHashAsync(TestMergeHash, It.IsAny<CancellationToken>()))
.ReturnsAsync((CanonicalAdvisory?)null);
_storeMock
.Setup(x => x.UpsertCanonicalAsync(It.IsAny<UpsertCanonicalRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TestCanonicalId);
_storeMock
.Setup(x => x.SourceEdgeExistsAsync(It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
AddSourceEdgeRequest? captured = null;
_storeMock
.Setup(x => x.AddSourceEdgeAsync(It.IsAny<AddSourceEdgeRequest>(), It.IsAny<CancellationToken>()))
.Callback<AddSourceEdgeRequest, CancellationToken>((request, _) => captured = request)
.ReturnsAsync(SourceEdgeResult.Created(TestEdgeId));
var service = CreateService();
var advisory = new RawAdvisory
{
SourceAdvisoryId = "ADV-CVE-2025-0200",
Cve = "CVE-2025-0200",
AffectsKey = "pkg:npm/example@1",
VersionRangeJson = "{\"introduced\":\"1.0.0\",\"fixed\":\"1.2.3\"}",
Weaknesses = [],
Severity = "high",
Title = "Test Advisory for CVE-2025-0200",
Summary = "Test summary",
RawPayloadJson = null
};
// Act
await service.IngestAsync(TestSource, advisory);
// Assert
Assert.NotNull(captured);
Assert.Equal(FixedNow, captured!.FetchedAt);
}
[Fact]
public async Task IngestAsync_ComputesMergeHash_FromAdvisoryFields()
{
@@ -741,10 +788,10 @@ public sealed class CanonicalAdvisoryServiceTests
#region Helpers
private CanonicalAdvisoryService CreateService() =>
new(_storeMock.Object, _hashCalculatorMock.Object, _logger);
new(_storeMock.Object, _hashCalculatorMock.Object, _logger, FixedTimeProvider);
private CanonicalAdvisoryService CreateServiceWithSigner() =>
new(_storeMock.Object, _hashCalculatorMock.Object, _logger, _signerMock.Object);
new(_storeMock.Object, _hashCalculatorMock.Object, _logger, FixedTimeProvider, _signerMock.Object);
private static RawAdvisory CreateRawAdvisory(
string cve,
@@ -764,7 +811,7 @@ public sealed class CanonicalAdvisoryServiceTests
Title = $"Test Advisory for {cve}",
Summary = "Test summary",
RawPayloadJson = rawPayloadJson,
FetchedAt = DateTimeOffset.UtcNow
FetchedAt = FixedNow
};
}
@@ -780,7 +827,7 @@ public sealed class CanonicalAdvisoryServiceTests
SourceAdvisoryId = $"VENDOR-{cve}",
SourceDocHash = "sha256:existing",
PrecedenceRank = 10, // High precedence
FetchedAt = DateTimeOffset.UtcNow
FetchedAt = FixedNow
}
}
: new List<SourceEdge>();
@@ -791,8 +838,8 @@ public sealed class CanonicalAdvisoryServiceTests
Cve = cve,
AffectsKey = "pkg:npm/example@1",
MergeHash = TestMergeHash,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
CreatedAt = FixedNow,
UpdatedAt = FixedNow,
SourceEdges = sourceEdges
};
}

View File

@@ -1,6 +1,7 @@
using StellaOps.Concelier.Models;
using StellaOps.TestKit;
using System.Linq;
namespace StellaOps.Concelier.Core.Tests;
public sealed class CanonicalMergerTests
@@ -8,7 +9,7 @@ public sealed class CanonicalMergerTests
private static readonly DateTimeOffset BaseTimestamp = new(2025, 10, 10, 0, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_PrefersGhsaTitleAndSummaryByPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));
@@ -45,7 +46,7 @@ public sealed class CanonicalMergerTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_FreshnessOverrideUsesOsvSummaryWhenNewerByThreshold()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(10)));
@@ -81,7 +82,7 @@ public sealed class CanonicalMergerTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_AffectedPackagesPreferOsvPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(4)));
@@ -168,7 +169,7 @@ public sealed class CanonicalMergerTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_CvssMetricsOrderedByPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(5)));
@@ -190,7 +191,7 @@ public sealed class CanonicalMergerTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_ReferencesNormalizedAndFreshnessOverrides()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(80)));
@@ -241,7 +242,78 @@ public sealed class CanonicalMergerTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_OrdersCreditsReferencesAndPackagesDeterministically()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));
var ghsaCredit = new AdvisoryCredit(
"Bob",
"analyst",
new[] { "bob@example.com" },
CreateProvenance("ghsa", ProvenanceFieldMasks.Credits));
var osvCredit = new AdvisoryCredit(
"Alice",
"researcher",
new[] { "alice@example.com" },
CreateProvenance("osv", ProvenanceFieldMasks.Credits));
var ghsaReference = new AdvisoryReference(
"https://example.com/b",
kind: "advisory",
sourceTag: null,
summary: null,
CreateProvenance("ghsa", ProvenanceFieldMasks.References));
var osvReference = new AdvisoryReference(
"https://example.com/a",
kind: "advisory",
sourceTag: null,
summary: null,
CreateProvenance("osv", ProvenanceFieldMasks.References));
var ghsaPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/b@1",
platform: null,
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { CreateProvenance("ghsa", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var osvPackage = new AffectedPackage(
AffectedPackageTypes.SemVer,
"pkg:npm/a@1",
platform: null,
versionRanges: Array.Empty<AffectedVersionRange>(),
statuses: Array.Empty<AffectedPackageStatus>(),
provenance: new[] { CreateProvenance("osv", ProvenanceFieldMasks.AffectedPackages) },
normalizedVersions: Array.Empty<NormalizedVersionRule>());
var ghsa = CreateAdvisory(
source: "ghsa",
advisoryKey: "GHSA-ordering",
title: "GHSA Title",
modified: BaseTimestamp.AddHours(1),
credits: new[] { ghsaCredit },
references: new[] { ghsaReference },
packages: new[] { ghsaPackage });
var osv = CreateAdvisory(
source: "osv",
advisoryKey: "OSV-ordering",
title: "OSV Title",
modified: BaseTimestamp.AddHours(2),
credits: new[] { osvCredit },
references: new[] { osvReference },
packages: new[] { osvPackage });
var result = merger.Merge("CVE-2025-4242", ghsa, null, osv);
Assert.Equal(new[] { "Alice", "Bob" }, result.Advisory.Credits.Select(c => c.DisplayName).ToArray());
Assert.Equal(new[] { "https://example.com/a", "https://example.com/b" }, result.Advisory.References.Select(r => r.Url).ToArray());
Assert.Equal(new[] { "pkg:npm/a@1", "pkg:npm/b@1" }, result.Advisory.AffectedPackages.Select(p => p.Identifier).ToArray());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Merge_DescriptionFreshnessOverride()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(12)));
@@ -280,7 +352,7 @@ public sealed class CanonicalMergerTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public void Merge_CwesPreferNvdPrecedence()
{
var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using FluentAssertions;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.Models;
@@ -9,7 +10,7 @@ using Xunit;
namespace StellaOps.Concelier.Core.Tests.Linksets;
/// <summary>
/// Determinism and provenance-focused tests aligned with CI1CI10 gap remediation.
/// Determinism and provenance-focused tests aligned with CI1-CI10 gap remediation.
/// </summary>
public sealed class AdvisoryLinksetDeterminismTests
{
@@ -54,14 +55,14 @@ public sealed class AdvisoryLinksetDeterminismTests
{
new LinksetCorrelation.Input(
Vendor: "nvd",
FetchedAt: DateTimeOffset.Parse("2025-12-01T00:00:00Z"),
Aliases: new[] { "CVE-2025-1111" },
FetchedAt: DateTimeOffset.Parse("2025-12-01T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
Aliases: new[] { "CVE-2025-2222", "CVE-2025-1111" },
Purls: Array.Empty<string>(),
Cpes: Array.Empty<string>(),
References: Array.Empty<string>()),
new LinksetCorrelation.Input(
Vendor: "vendor",
FetchedAt: DateTimeOffset.Parse("2025-12-01T00:05:00Z"),
FetchedAt: DateTimeOffset.Parse("2025-12-01T00:05:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
Aliases: new[] { "CVE-2025-2222" },
Purls: Array.Empty<string>(),
Cpes: Array.Empty<string>(),
@@ -82,5 +83,6 @@ public sealed class AdvisoryLinksetDeterminismTests
conflicts[0].Field.Should().Be("aliases");
conflicts[0].Reason.Should().Be("alias-inconsistency");
conflicts[0].SourceIds.Should().ContainInOrder("nvd", "vendor");
conflicts[0].Values.Should().ContainInOrder("nvd:CVE-2025-1111", "vendor:CVE-2025-2222");
}
}

View File

@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using StellaOps.Concelier.Core.Linksets;
using Xunit;
@@ -16,17 +18,17 @@ public sealed class AdvisoryLinksetQueryServiceTests
ImmutableArray.Create("obs-003"),
new AdvisoryLinksetNormalized(new[]{"pkg:npm/a"}, null, new[]{"1.0.0"}, null, null),
null, null, null,
DateTimeOffset.Parse("2025-11-10T12:00:00Z"), null),
DateTimeOffset.Parse("2025-11-10T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), null),
new("tenant", "ghsa", "adv-002",
ImmutableArray.Create("obs-002"),
new AdvisoryLinksetNormalized(new[]{"pkg:npm/b"}, null, new[]{"2.0.0"}, null, null),
null, null, null,
DateTimeOffset.Parse("2025-11-09T12:00:00Z"), null),
DateTimeOffset.Parse("2025-11-09T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), null),
new("tenant", "ghsa", "adv-001",
ImmutableArray.Create("obs-001"),
new AdvisoryLinksetNormalized(new[]{"pkg:npm/c"}, null, new[]{"3.0.0"}, null, null),
null, null, null,
DateTimeOffset.Parse("2025-11-08T12:00:00Z"), null),
DateTimeOffset.Parse("2025-11-08T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), null),
};
var lookup = new FakeLinksetLookup(linksets);
@@ -60,6 +62,48 @@ public sealed class AdvisoryLinksetQueryServiceTests
});
}
[Fact]
public async Task QueryAsync_EncodesCursorWithInvariantDigits()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("ar-SA");
CultureInfo.CurrentUICulture = new CultureInfo("ar-SA");
var linksets = new List<AdvisoryLinkset>
{
new("tenant", "ghsa", "adv-001",
ImmutableArray.Create("obs-001"),
null,
null, null, null,
new DateTimeOffset(2025, 11, 8, 12, 0, 0, TimeSpan.Zero), null),
new("tenant", "ghsa", "adv-000",
ImmutableArray.Create("obs-000"),
null,
null, null, null,
new DateTimeOffset(2025, 11, 7, 12, 0, 0, TimeSpan.Zero), null),
};
var lookup = new FakeLinksetLookup(linksets);
var service = new AdvisoryLinksetQueryService(lookup);
var page = await service.QueryAsync(new AdvisoryLinksetQueryOptions("tenant", Limit: 1), TestContext.Current.CancellationToken);
Assert.False(string.IsNullOrWhiteSpace(page.NextCursor));
var payload = Encoding.UTF8.GetString(Convert.FromBase64String(page.NextCursor!));
var ticksText = payload.Split(':')[0];
Assert.All(ticksText, ch => Assert.InRange(ch, '0', '9'));
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
private sealed class FakeLinksetLookup : IAdvisoryLinksetLookup
{
private readonly IReadOnlyList<AdvisoryLinkset> _linksets;

View File

@@ -13,6 +13,8 @@ namespace StellaOps.Concelier.Core.Tests.Linksets;
/// </summary>
public sealed class AdvisoryLinksetUpdatedEventTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 2, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void FromLinkset_NewLinkset_CreatesEventWithCreatedDelta()
{
@@ -97,7 +99,7 @@ public sealed class AdvisoryLinksetUpdatedEventTests
{
// Arrange
var provenance = new AdvisoryLinksetProvenance(
ObservationHashes: new[] { "sha256:abc123", "sha256:def456" },
ObservationHashes: new[] { "sha256:def456", "sha256:abc123" },
ToolVersion: "1.0.0",
PolicyHash: "policy-hash-123");
@@ -112,6 +114,29 @@ public sealed class AdvisoryLinksetUpdatedEventTests
@event.Provenance.PolicyHash.Should().Be("policy-hash-123");
}
[Fact]
public void FromLinkset_ConflictOrderingDoesNotTriggerDeltaChange()
{
var conflictsA = new List<AdvisoryLinksetConflict>
{
new("severity", "severity-mismatch", new[] { "nvd:9.8", "ghsa:8.5" }, new[] { "nvd", "ghsa" }),
new("aliases", "alias-inconsistency", new[] { "CVE-2024-1234", "CVE-2024-5678" }, null)
};
var conflictsB = new List<AdvisoryLinksetConflict>
{
new("aliases", "alias-inconsistency", new[] { "CVE-2024-5678", "CVE-2024-1234" }, null),
new("severity", "severity-mismatch", new[] { "ghsa:8.5", "nvd:9.8" }, new[] { "ghsa", "nvd" })
};
var previousLinkset = CreateLinksetWithConflicts("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1" }, conflictsA);
var currentLinkset = CreateLinksetWithConflicts("tenant-1", "nvd", "CVE-2024-1234", new[] { "obs-1" }, conflictsB);
var @event = AdvisoryLinksetUpdatedEvent.FromLinkset(currentLinkset, previousLinkset, "linkset-1", null);
@event.Delta.ConflictsChanged.Should().BeFalse();
}
[Fact]
public void FromLinkset_ConfidenceChanged_SetsConfidenceChangedFlag()
{
@@ -152,7 +177,7 @@ public sealed class AdvisoryLinksetUpdatedEventTests
var event2 = AdvisoryLinksetUpdatedEvent.FromLinkset(linkset, null, "linkset-1", null);
// Assert
event1.EventId.Should().NotBe(event2.EventId);
event1.EventId.Should().Be(event2.EventId);
event1.EventId.Should().NotBe(Guid.Empty);
}
@@ -178,7 +203,7 @@ public sealed class AdvisoryLinksetUpdatedEventTests
Provenance: null,
Confidence: null,
Conflicts: null,
CreatedAt: DateTimeOffset.UtcNow,
CreatedAt: FixedNow,
BuiltByJobId: null);
}
@@ -194,7 +219,7 @@ public sealed class AdvisoryLinksetUpdatedEventTests
Provenance: null,
Confidence: null,
Conflicts: conflicts,
CreatedAt: DateTimeOffset.UtcNow,
CreatedAt: FixedNow,
BuiltByJobId: null);
}
@@ -210,7 +235,7 @@ public sealed class AdvisoryLinksetUpdatedEventTests
Provenance: provenance,
Confidence: null,
Conflicts: null,
CreatedAt: DateTimeOffset.UtcNow,
CreatedAt: FixedNow,
BuiltByJobId: null);
}
@@ -226,7 +251,7 @@ public sealed class AdvisoryLinksetUpdatedEventTests
Provenance: null,
Confidence: confidence,
Conflicts: null,
CreatedAt: DateTimeOffset.UtcNow,
CreatedAt: FixedNow,
BuiltByJobId: null);
}
}

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Text.Json.Nodes;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Models.Observations;
@@ -28,14 +30,43 @@ public sealed class AdvisoryObservationEventFactoryTests
Assert.Contains("pkg:npm/foo@1.0.0", evt.LinksetSummary.Purls);
}
[Fact]
public void FromObservation_OrdersRelationshipsAndUsesInvariantCursor()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("ar-SA");
CultureInfo.CurrentUICulture = new CultureInfo("ar-SA");
var observation = CreateObservation();
var evt = AdvisoryObservationUpdatedEvent.FromObservation(
observation,
supersedesId: null,
traceId: null);
var relationshipTypes = evt.LinksetSummary.Relationships.Select(r => r.Type).ToArray();
Assert.Equal(new[] { "affects", "contains" }, relationshipTypes);
var expectedCursor = observation.CreatedAt.ToUniversalTime().Ticks.ToString(CultureInfo.InvariantCulture);
Assert.Equal(expectedCursor, evt.ReplayCursor);
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
private static AdvisoryObservation CreateObservation()
{
var source = new AdvisoryObservationSource("ghsa", "advisories", "https://api");
var upstream = new AdvisoryObservationUpstream(
"adv-1",
"v1",
DateTimeOffset.Parse("2025-11-20T12:00:00Z"),
DateTimeOffset.Parse("2025-11-20T12:00:00Z"),
DateTimeOffset.Parse("2025-11-20T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
DateTimeOffset.Parse("2025-11-20T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
"2f8f568cc1ed3474f0a4564ddb8c64f4b4d176fbe0a2a98a02b88e822a4f5b6d",
new AdvisoryObservationSignature(false, null, null, null));
@@ -52,7 +83,9 @@ public sealed class AdvisoryObservationEventFactoryTests
PackageUrls = ImmutableArray.Create("pkg:npm/foo@1.0.0"),
Cpes = ImmutableArray.Create("cpe:/a:foo:foo:1.0.0"),
Scopes = ImmutableArray.Create("runtime"),
Relationships = ImmutableArray.Create(new RawRelationship("contains", "pkg:npm/foo@1.0.0", "file://dist/foo.js")),
Relationships = ImmutableArray.Create(
new RawRelationship("contains", "pkg:npm/foo@1.0.0", "file://dist/foo.js"),
new RawRelationship("affects", "pkg:npm/foo@1.0.0", "file://dist/bar.js")),
};
return new AdvisoryObservation(
@@ -63,6 +96,6 @@ public sealed class AdvisoryObservationEventFactoryTests
content,
linkset,
rawLinkset,
DateTimeOffset.Parse("2025-11-20T12:01:00Z"));
DateTimeOffset.Parse("2025-11-20T12:01:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind));
}
}

View File

@@ -1,13 +1,18 @@
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Core.Orchestration;
namespace StellaOps.Concelier.Core.Tests.Orchestration;
public sealed class OrchestratorRegistryStoreTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static InMemoryOrchestratorRegistryStore CreateStore()
=> new(new FakeTimeProvider(FixedNow));
[Fact]
public async Task UpsertAsync_CreatesNewRecord()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var record = CreateRegistryRecord("tenant-1", "connector-1");
await store.UpsertAsync(record, TestContext.Current.CancellationToken);
@@ -21,7 +26,7 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task UpsertAsync_UpdatesExistingRecord()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var record1 = CreateRegistryRecord("tenant-1", "connector-1", source: "nvd");
var record2 = CreateRegistryRecord("tenant-1", "connector-1", source: "osv");
@@ -36,7 +41,7 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task GetAsync_ReturnsNullForNonExistentRecord()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var retrieved = await store.GetAsync("tenant-1", "nonexistent", TestContext.Current.CancellationToken);
@@ -46,7 +51,7 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task ListAsync_ReturnsRecordsForTenant()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
await store.UpsertAsync(CreateRegistryRecord("tenant-1", "connector-a"), TestContext.Current.CancellationToken);
await store.UpsertAsync(CreateRegistryRecord("tenant-1", "connector-b"), TestContext.Current.CancellationToken);
await store.UpsertAsync(CreateRegistryRecord("tenant-2", "connector-c"), TestContext.Current.CancellationToken);
@@ -60,7 +65,7 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task ListAsync_ReturnsOrderedByConnectorId()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
await store.UpsertAsync(CreateRegistryRecord("tenant-1", "zzz-connector"), TestContext.Current.CancellationToken);
await store.UpsertAsync(CreateRegistryRecord("tenant-1", "aaa-connector"), TestContext.Current.CancellationToken);
@@ -73,12 +78,12 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task AppendHeartbeatAsync_StoresHeartbeat()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
var heartbeat = new OrchestratorHeartbeatRecord(
"tenant-1", "connector-1", runId, 1,
OrchestratorHeartbeatStatus.Running, 50, 10,
null, null, null, null, DateTimeOffset.UtcNow);
null, null, null, null, FixedNow);
await store.AppendHeartbeatAsync(heartbeat, TestContext.Current.CancellationToken);
@@ -91,9 +96,9 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task GetLatestHeartbeatAsync_ReturnsHighestSequence()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var now = FixedNow;
await store.AppendHeartbeatAsync(CreateHeartbeat("tenant-1", "connector-1", runId, 1, OrchestratorHeartbeatStatus.Starting, now), TestContext.Current.CancellationToken);
await store.AppendHeartbeatAsync(CreateHeartbeat("tenant-1", "connector-1", runId, 3, OrchestratorHeartbeatStatus.Succeeded, now.AddMinutes(2)), TestContext.Current.CancellationToken);
@@ -109,12 +114,12 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task EnqueueCommandAsync_StoresCommand()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
var command = new OrchestratorCommandRecord(
"tenant-1", "connector-1", runId, 1,
OrchestratorCommandKind.Pause, null, null,
DateTimeOffset.UtcNow, null);
FixedNow, null);
await store.EnqueueCommandAsync(command, TestContext.Current.CancellationToken);
@@ -126,9 +131,9 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task GetPendingCommandsAsync_FiltersAfterSequence()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var now = FixedNow;
await store.EnqueueCommandAsync(CreateCommand("tenant-1", "connector-1", runId, 1, OrchestratorCommandKind.Pause, now), TestContext.Current.CancellationToken);
await store.EnqueueCommandAsync(CreateCommand("tenant-1", "connector-1", runId, 2, OrchestratorCommandKind.Resume, now), TestContext.Current.CancellationToken);
@@ -144,9 +149,9 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task GetPendingCommandsAsync_ExcludesExpiredCommands()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var now = FixedNow;
var expired = now.AddMinutes(-5);
var future = now.AddMinutes(5);
@@ -162,14 +167,14 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task StoreManifestAsync_StoresManifest()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
var manifest = new OrchestratorRunManifest(
runId, "connector-1", "tenant-1",
new OrchestratorBackfillRange("cursor-a", "cursor-z"),
["hash1", "hash2"],
"dsse-hash",
DateTimeOffset.UtcNow);
FixedNow);
await store.StoreManifestAsync(manifest, TestContext.Current.CancellationToken);
@@ -183,7 +188,7 @@ public sealed class OrchestratorRegistryStoreTests
[Fact]
public async Task GetManifestAsync_ReturnsNullForNonExistentManifest()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var manifest = await store.GetManifestAsync("tenant-1", "connector-1", Guid.NewGuid(), TestContext.Current.CancellationToken);
@@ -191,18 +196,18 @@ public sealed class OrchestratorRegistryStoreTests
}
[Fact]
public void Clear_RemovesAllData()
public async Task Clear_RemovesAllData()
{
var store = new InMemoryOrchestratorRegistryStore();
var store = CreateStore();
var runId = Guid.NewGuid();
store.UpsertAsync(CreateRegistryRecord("tenant-1", "connector-1"), TestContext.Current.CancellationToken).Wait();
store.AppendHeartbeatAsync(CreateHeartbeat("tenant-1", "connector-1", runId, 1, OrchestratorHeartbeatStatus.Running, DateTimeOffset.UtcNow), TestContext.Current.CancellationToken).Wait();
await store.UpsertAsync(CreateRegistryRecord("tenant-1", "connector-1"), TestContext.Current.CancellationToken);
await store.AppendHeartbeatAsync(CreateHeartbeat("tenant-1", "connector-1", runId, 1, OrchestratorHeartbeatStatus.Running, FixedNow), TestContext.Current.CancellationToken);
store.Clear();
Assert.Null(store.GetAsync("tenant-1", "connector-1", TestContext.Current.CancellationToken).Result);
Assert.Null(store.GetLatestHeartbeatAsync("tenant-1", "connector-1", runId, TestContext.Current.CancellationToken).Result);
Assert.Null(await store.GetAsync("tenant-1", "connector-1", TestContext.Current.CancellationToken));
Assert.Null(await store.GetLatestHeartbeatAsync("tenant-1", "connector-1", runId, TestContext.Current.CancellationToken));
}
private static OrchestratorRegistryRecord CreateRegistryRecord(string tenant, string connectorId, string source = "nvd")
@@ -216,8 +221,8 @@ public sealed class OrchestratorRegistryStoreTests
["raw-advisory"],
$"concelier:{tenant}:{connectorId}",
new OrchestratorEgressGuard(["example.com"], false),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow);
FixedNow,
FixedNow);
}
private static OrchestratorHeartbeatRecord CreateHeartbeat(

View File

@@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Aoc;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Core.Linksets;
@@ -21,6 +22,8 @@ namespace StellaOps.Concelier.Core.Tests.Raw;
public sealed class AdvisoryRawServiceTests
{
private const string GhsaAlias = "GHSA-AAAA-BBBB-CCCC";
private static readonly DateTimeOffset FixedNow = new(2025, 11, 20, 12, 0, 0, TimeSpan.Zero);
private static readonly TimeProvider FixedTimeProvider = new FakeTimeProvider(FixedNow);
[Fact]
public async Task IngestAsync_RemovesClientSupersedesBeforeUpsert()
@@ -177,7 +180,7 @@ public sealed class AdvisoryRawServiceTests
observationFactory,
observationSink,
linksetSink,
TimeProvider.System,
FixedTimeProvider,
NullLogger<AdvisoryRawService>.Instance);
}
@@ -210,7 +213,7 @@ public sealed class AdvisoryRawServiceTests
Upstream: new RawUpstreamMetadata(
UpstreamId: GhsaAlias,
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
RetrievedAt: FixedNow,
ContentHash: "sha256:abc",
Signature: new RawSignatureMetadata(
Present: true,
@@ -250,7 +253,7 @@ public sealed class AdvisoryRawServiceTests
return new AdvisoryRawRecord(
Id: "advisory_raw:vendor-x:ghsa-aaaa-bbbb-cccc:sha256-1",
Document: resolvedDocument,
IngestedAt: DateTimeOffset.UtcNow,
IngestedAt: FixedNow,
CreatedAt: document.Upstream.RetrievedAt);
}

View File

@@ -0,0 +1,86 @@
using System;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Core.Risk;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Risk;
public sealed class AdvisoryFieldChangeEmitterTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task EmitChangesAsync_FormatsCvssScoreWithInvariantCulture()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("fr-FR");
CultureInfo.CurrentUICulture = new CultureInfo("fr-FR");
var timeProvider = new FakeTimeProvider(FixedNow);
var publisher = new RecordingPublisher();
var emitter = new AdvisoryFieldChangeEmitter(
publisher,
NullLogger<AdvisoryFieldChangeEmitter>.Instance,
timeProvider);
var previousProvenance = new VendorRiskProvenance("vendor", "source", "hash-prev", FixedNow, null, null);
var currentProvenance = new VendorRiskProvenance("vendor", "source", "hash-cur", FixedNow, null, null);
var previousSignal = new VendorRiskSignal(
TenantId: "tenant-1",
AdvisoryId: "CVE-2025-0001",
ObservationId: "obs-1",
Provenance: previousProvenance,
CvssScores: ImmutableArray.Create(new VendorCvssScore("cvss_v31", 7.5, null, null, previousProvenance)),
KevStatus: null,
FixAvailability: ImmutableArray<VendorFixAvailability>.Empty,
ExtractedAt: FixedNow);
var currentSignal = new VendorRiskSignal(
TenantId: "tenant-1",
AdvisoryId: "CVE-2025-0001",
ObservationId: "obs-1",
Provenance: currentProvenance,
CvssScores: ImmutableArray.Create(new VendorCvssScore("cvss_v31", 8.0, null, null, currentProvenance)),
KevStatus: null,
FixAvailability: ImmutableArray<VendorFixAvailability>.Empty,
ExtractedAt: FixedNow);
var notification = await emitter.EmitChangesAsync(
tenantId: "tenant-1",
observationId: "obs-1",
previousSignal: previousSignal,
currentSignal: currentSignal,
linksetId: null,
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(notification);
var change = notification!.Changes.First(c => c.Field == "cvss_score");
Assert.Equal("7.5", change.PreviousValue);
Assert.Equal("8.0", change.CurrentValue);
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
private sealed class RecordingPublisher : IAdvisoryFieldChangeNotificationPublisher
{
public AdvisoryFieldChangeNotification? LastNotification { get; private set; }
public Task PublishAsync(AdvisoryFieldChangeNotification notification, CancellationToken cancellationToken)
{
LastNotification = notification;
return Task.CompletedTask;
}
}
}

View File

@@ -7,7 +7,8 @@ namespace StellaOps.Concelier.Core.Tests.Signals;
public sealed class AffectedSymbolProviderTests
{
private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.UtcNow);
private static readonly DateTimeOffset FixedNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _timeProvider = new(FixedNow);
[Fact]
public async Task GetByAdvisoryAsync_ReturnsEmptySetForUnknownAdvisory()
@@ -268,52 +269,52 @@ public sealed class AffectedSymbolProviderTests
[Fact]
public void AffectedSymbol_CanonicalId_GeneratesCorrectFormat()
{
var provenance = AffectedSymbolProvenance.FromOsv("sha256:test", DateTimeOffset.UtcNow);
var provenance = AffectedSymbolProvenance.FromOsv("sha256:test", FixedNow);
var function = AffectedSymbol.Function(
"tenant-1", "CVE-2024-0001", "obs-1", "myFunc", provenance, DateTimeOffset.UtcNow,
"tenant-1", "CVE-2024-0001", "obs-1", "myFunc", provenance, FixedNow,
module: "myModule");
Assert.Equal("myModule::myFunc", function.CanonicalId);
var method = AffectedSymbol.Method(
"tenant-1", "CVE-2024-0001", "obs-1", "myMethod", "MyClass", provenance, DateTimeOffset.UtcNow,
"tenant-1", "CVE-2024-0001", "obs-1", "myMethod", "MyClass", provenance, FixedNow,
module: "myModule");
Assert.Equal("myModule::MyClass.myMethod", method.CanonicalId);
var globalFunc = AffectedSymbol.Function(
"tenant-1", "CVE-2024-0001", "obs-1", "globalFunc", provenance, DateTimeOffset.UtcNow);
"tenant-1", "CVE-2024-0001", "obs-1", "globalFunc", provenance, FixedNow);
Assert.Equal("global::globalFunc", globalFunc.CanonicalId);
}
[Fact]
public void AffectedSymbol_HasSourceLocation_ReturnsCorrectValue()
{
var provenance = AffectedSymbolProvenance.FromOsv("sha256:test", DateTimeOffset.UtcNow);
var provenance = AffectedSymbolProvenance.FromOsv("sha256:test", FixedNow);
var withLocation = AffectedSymbol.Function(
"tenant-1", "CVE-2024-0001", "obs-1", "func1", provenance, DateTimeOffset.UtcNow,
"tenant-1", "CVE-2024-0001", "obs-1", "func1", provenance, FixedNow,
filePath: "/src/lib.js", lineNumber: 42);
Assert.True(withLocation.HasSourceLocation);
var withoutLocation = AffectedSymbol.Function(
"tenant-1", "CVE-2024-0001", "obs-1", "func2", provenance, DateTimeOffset.UtcNow);
"tenant-1", "CVE-2024-0001", "obs-1", "func2", provenance, FixedNow);
Assert.False(withoutLocation.HasSourceLocation);
}
[Fact]
public void AffectedSymbolSet_UniqueSymbolCount_CountsDistinctCanonicalIds()
{
var provenance = AffectedSymbolProvenance.FromOsv("sha256:test", DateTimeOffset.UtcNow);
var provenance = AffectedSymbolProvenance.FromOsv("sha256:test", FixedNow);
var symbols = ImmutableArray.Create(
AffectedSymbol.Function("tenant-1", "CVE-2024-0001", "obs-1", "func1", provenance, DateTimeOffset.UtcNow, module: "mod1"),
AffectedSymbol.Function("tenant-1", "CVE-2024-0001", "obs-2", "func1", provenance, DateTimeOffset.UtcNow, module: "mod1"), // duplicate
AffectedSymbol.Function("tenant-1", "CVE-2024-0001", "obs-3", "func2", provenance, DateTimeOffset.UtcNow, module: "mod1")
AffectedSymbol.Function("tenant-1", "CVE-2024-0001", "obs-1", "func1", provenance, FixedNow, module: "mod1"),
AffectedSymbol.Function("tenant-1", "CVE-2024-0001", "obs-2", "func1", provenance, FixedNow, module: "mod1"), // duplicate
AffectedSymbol.Function("tenant-1", "CVE-2024-0001", "obs-3", "func2", provenance, FixedNow, module: "mod1")
);
var set = new AffectedSymbolSet(
"tenant-1", "CVE-2024-0001", symbols,
ImmutableArray<AffectedSymbolSourceSummary>.Empty, DateTimeOffset.UtcNow);
ImmutableArray<AffectedSymbolSourceSummary>.Empty, FixedNow);
Assert.Equal(2, set.UniqueSymbolCount);
}
@@ -321,7 +322,7 @@ public sealed class AffectedSymbolProviderTests
[Fact]
public void AffectedSymbolProvenance_FromOsv_CreatesCorrectProvenance()
{
var now = DateTimeOffset.UtcNow;
var now = FixedNow;
var provenance = AffectedSymbolProvenance.FromOsv(
observationHash: "sha256:abc123",
fetchedAt: now,
@@ -340,7 +341,7 @@ public sealed class AffectedSymbolProviderTests
[Fact]
public void AffectedSymbolProvenance_FromNvd_CreatesCorrectProvenance()
{
var now = DateTimeOffset.UtcNow;
var now = FixedNow;
var provenance = AffectedSymbolProvenance.FromNvd(
observationHash: "sha256:def456",
fetchedAt: now,
@@ -355,7 +356,7 @@ public sealed class AffectedSymbolProviderTests
[Fact]
public void AffectedSymbolProvenance_FromGhsa_CreatesCorrectProvenance()
{
var now = DateTimeOffset.UtcNow;
var now = FixedNow;
var provenance = AffectedSymbolProvenance.FromGhsa(
observationHash: "sha256:ghi789",
fetchedAt: now,

View File

@@ -0,0 +1,545 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Federation.Export;
using StellaOps.Concelier.Federation.Import;
using StellaOps.Concelier.Federation.Models;
using StellaOps.Concelier.WebService.Options;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Concelier.WebService.Tests;
public sealed class FederationEndpointTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationDisabled_ReturnsServiceUnavailable()
{
using var factory = new FederationWebAppFactory(federationEnabled: false, FixedNow);
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/federation/export");
response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable);
var payload = await ReadJsonAsync(response);
payload.GetProperty("error").GetProperty("code").GetString().Should().Be("FEDERATION_DISABLED");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationStatus_ReturnsConfiguration()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/federation/status");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("enabled").GetBoolean().Should().BeTrue();
payload.GetProperty("site_id").GetString().Should().Be("site-a");
payload.GetProperty("default_compression_level").GetInt32().Should().Be(3);
payload.GetProperty("default_max_items").GetInt32().Should().Be(10000);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationExport_ReturnsHeaders()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/federation/export");
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Headers.TryGetValues("X-Bundle-Hash", out var hashValues).Should().BeTrue();
hashValues!.Single().Should().Be("sha256:bundle");
response.Headers.TryGetValues("X-Export-Cursor", out var cursorValues).Should().BeTrue();
cursorValues!.Single().Should().Be("cursor-1");
response.Headers.TryGetValues("X-Items-Count", out var countValues).Should().BeTrue();
countValues!.Single().Should().Be("3");
response.Headers.TryGetValues("Content-Disposition", out var dispositionValues).Should().BeTrue();
dispositionValues!.Single().Should().Contain("feedser-bundle-20250101-000000.zst");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationExportPreview_ReturnsPreview()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/federation/export/preview?since_cursor=cursor-0");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("since_cursor").GetString().Should().Be("cursor-0");
payload.GetProperty("estimated_canonicals").GetInt32().Should().Be(5);
payload.GetProperty("estimated_edges").GetInt32().Should().Be(6);
payload.GetProperty("estimated_deletions").GetInt32().Should().Be(7);
payload.GetProperty("estimated_size_bytes").GetInt64().Should().Be(1024);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationImport_ReturnsSuccess()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
using var content = new ByteArrayContent(Encoding.UTF8.GetBytes("bundle"));
content.Headers.ContentType = new MediaTypeHeaderValue("application/zstd");
var response = await client.PostAsync("/api/v1/federation/import", content);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("success").GetBoolean().Should().BeTrue();
payload.GetProperty("bundle_hash").GetString().Should().Be("sha256:import");
payload.GetProperty("imported_cursor").GetString().Should().Be("cursor-2");
payload.GetProperty("counts").GetProperty("total").GetInt32().Should().Be(3);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationValidate_ReturnsValidation()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
using var content = new ByteArrayContent(Encoding.UTF8.GetBytes("bundle"));
content.Headers.ContentType = new MediaTypeHeaderValue("application/zstd");
var response = await client.PostAsync("/api/v1/federation/import/validate", content);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("is_valid").GetBoolean().Should().BeTrue();
payload.GetProperty("hash_valid").GetBoolean().Should().BeTrue();
payload.GetProperty("signature_valid").GetBoolean().Should().BeTrue();
payload.GetProperty("cursor_valid").GetBoolean().Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationPreview_ReturnsManifest()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
using var content = new ByteArrayContent(Encoding.UTF8.GetBytes("bundle"));
content.Headers.ContentType = new MediaTypeHeaderValue("application/zstd");
var response = await client.PostAsync("/api/v1/federation/import/preview", content);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("is_valid").GetBoolean().Should().BeTrue();
payload.GetProperty("manifest").GetProperty("site_id").GetString().Should().Be("site-a");
payload.GetProperty("manifest").GetProperty("export_cursor").GetString().Should().Be("cursor-1");
payload.GetProperty("manifest").GetProperty("bundle_hash").GetString().Should().Be("sha256:preview");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationSites_ReturnsPolicies()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/federation/sites");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("count").GetInt32().Should().Be(1);
var sites = payload.GetProperty("sites").EnumerateArray().ToList();
sites.Should().HaveCount(1);
sites[0].GetProperty("site_id").GetString().Should().Be("site-a");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationSite_ReturnsDetails()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/federation/sites/site-a");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("site_id").GetString().Should().Be("site-a");
payload.GetProperty("recent_history").EnumerateArray().Should().NotBeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FederationSitePolicy_UpdatesPolicy()
{
using var factory = new FederationWebAppFactory(federationEnabled: true, FixedNow);
using var client = factory.CreateClient();
var body = new
{
displayName = "Updated Site",
enabled = false,
allowedSources = new[] { "nvd" },
maxBundleSizeBytes = 512L
};
var response = await client.PutAsJsonAsync("/api/v1/federation/sites/site-a/policy", body);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await ReadJsonAsync(response);
payload.GetProperty("site_id").GetString().Should().Be("site-a");
payload.GetProperty("display_name").GetString().Should().Be("Updated Site");
payload.GetProperty("enabled").GetBoolean().Should().BeFalse();
payload.GetProperty("max_bundle_size_bytes").GetInt64().Should().Be(512);
}
private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response)
{
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}
private sealed class FederationWebAppFactory : WebApplicationFactory<Program>
{
private readonly bool _federationEnabled;
private readonly DateTimeOffset _fixedNow;
public FederationWebAppFactory(bool federationEnabled, DateTimeOffset fixedNow)
{
_federationEnabled = federationEnabled;
_fixedNow = fixedNow;
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-federation");
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((_, config) =>
{
var overrides = new Dictionary<string, string?>
{
{"Concelier:PostgresStorage:Enabled", "true"},
{"Concelier:PostgresStorage:ConnectionString", "Host=localhost;Port=5432;Database=test-federation"},
{"Concelier:PostgresStorage:CommandTimeoutSeconds", "30"},
{"Concelier:Telemetry:Enabled", "false"},
{"Concelier:Federation:Enabled", _federationEnabled ? "true" : "false"},
{"Concelier:Federation:SiteId", "site-a"},
{"Concelier:Federation:DefaultCompressionLevel", "3"},
{"Concelier:Federation:DefaultMaxItems", "10000"}
};
config.AddInMemoryCollection(overrides);
});
builder.ConfigureServices(services =>
{
services.RemoveAll<IBundleExportService>();
services.RemoveAll<IBundleImportService>();
services.RemoveAll<ISyncLedgerRepository>();
services.RemoveAll<TimeProvider>();
services.RemoveAll<IOptions<ConcelierOptions>>();
services.RemoveAll<ConcelierOptions>();
var options = new ConcelierOptions
{
PostgresStorage = new ConcelierOptions.PostgresStorageOptions
{
Enabled = true,
ConnectionString = "Host=localhost;Port=5432;Database=test-federation",
CommandTimeoutSeconds = 30,
SchemaName = "vuln"
},
Telemetry = new ConcelierOptions.TelemetryOptions
{
Enabled = false
},
Federation = new ConcelierOptions.FederationOptions
{
Enabled = _federationEnabled,
SiteId = "site-a",
DefaultCompressionLevel = 3,
DefaultMaxItems = 10000,
RequireSignature = true
}
};
services.AddSingleton(options);
services.AddSingleton<IOptions<ConcelierOptions>>(Options.Create(options));
services.AddSingleton<TimeProvider>(new FixedTimeProvider(_fixedNow));
services.AddSingleton<IBundleExportService>(new FakeBundleExportService());
services.AddSingleton<IBundleImportService>(new FakeBundleImportService(_fixedNow));
services.AddSingleton<ISyncLedgerRepository>(new FakeSyncLedgerRepository(_fixedNow));
});
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public override long GetTimestamp() => 0;
public override TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp) => TimeSpan.Zero;
}
private sealed class FakeBundleExportService : IBundleExportService
{
private readonly byte[] _payload = Encoding.UTF8.GetBytes("bundle");
public Task<BundleExportResult> ExportAsync(string? sinceCursor = null, BundleExportOptions? options = null, CancellationToken ct = default)
{
return Task.FromResult(CreateResult(sinceCursor));
}
public async Task<BundleExportResult> ExportToStreamAsync(Stream output, string? sinceCursor = null, BundleExportOptions? options = null, CancellationToken ct = default)
{
await output.WriteAsync(_payload, ct);
return CreateResult(sinceCursor);
}
public Task<BundleExportPreview> PreviewAsync(string? sinceCursor = null, CancellationToken ct = default)
{
return Task.FromResult(new BundleExportPreview
{
EstimatedCanonicals = 5,
EstimatedEdges = 6,
EstimatedDeletions = 7,
EstimatedSizeBytes = 1024
});
}
private static BundleExportResult CreateResult(string? sinceCursor) => new()
{
BundleHash = "sha256:bundle",
ExportCursor = "cursor-1",
SinceCursor = sinceCursor,
Counts = new BundleCounts
{
Canonicals = 1,
Edges = 1,
Deletions = 1
},
CompressedSizeBytes = 3,
Duration = TimeSpan.FromSeconds(1)
};
}
private sealed class FakeBundleImportService : IBundleImportService
{
private readonly DateTimeOffset _now;
public FakeBundleImportService(DateTimeOffset now) => _now = now;
public Task<BundleImportResult> ImportAsync(Stream bundleStream, BundleImportOptions? options = null, CancellationToken cancellationToken = default)
{
return Task.FromResult(BundleImportResult.Succeeded(
"sha256:import",
"cursor-2",
new ImportCounts
{
CanonicalCreated = 1,
CanonicalUpdated = 1,
EdgesAdded = 1
},
duration: TimeSpan.FromSeconds(1)));
}
public Task<BundleImportResult> ImportFromFileAsync(string filePath, BundleImportOptions? options = null, CancellationToken cancellationToken = default)
{
return ImportAsync(Stream.Null, options, cancellationToken);
}
public Task<BundleValidationResult> ValidateAsync(Stream bundleStream, CancellationToken cancellationToken = default)
{
var manifest = CreateManifest("cursor-1", "sha256:preview");
return Task.FromResult(new BundleValidationResult
{
IsValid = true,
HashValid = true,
SignatureValid = true,
CursorValid = true,
Manifest = manifest
});
}
public Task<BundleImportPreview> PreviewAsync(Stream bundleStream, CancellationToken cancellationToken = default)
{
return Task.FromResult(new BundleImportPreview
{
Manifest = CreateManifest("cursor-1", "sha256:preview"),
IsValid = true,
IsDuplicate = false,
CurrentCursor = "cursor-0"
});
}
private BundleManifest CreateManifest(string exportCursor, string bundleHash) => new()
{
SiteId = "site-a",
ExportCursor = exportCursor,
BundleHash = bundleHash,
ExportedAt = _now,
Counts = new BundleCounts
{
Canonicals = 1,
Edges = 1,
Deletions = 1
}
};
}
private sealed class FakeSyncLedgerRepository : ISyncLedgerRepository
{
private readonly List<SitePolicy> _policies;
private readonly List<SyncLedgerEntry> _entries;
public FakeSyncLedgerRepository(DateTimeOffset now)
{
_policies =
[
new SitePolicy
{
SiteId = "site-a",
DisplayName = "Site A",
Enabled = true,
LastSyncAt = now,
LastCursor = "cursor-1",
TotalImports = 1,
AllowedSources = ["nvd", "osv"],
MaxBundleSizeBytes = 1024
}
];
_entries =
[
new SyncLedgerEntry
{
SiteId = "site-a",
Cursor = "cursor-1",
BundleHash = "sha256:bundle",
ItemCount = 3,
ExportedAt = now.AddMinutes(-10),
ImportedAt = now.AddMinutes(-9)
}
];
}
public Task<string?> GetCursorAsync(string siteId, CancellationToken ct = default)
{
var policy = _policies.FirstOrDefault(p => p.SiteId == siteId);
return Task.FromResult(policy?.LastCursor);
}
public Task<SyncLedgerEntry?> GetByBundleHashAsync(string bundleHash, CancellationToken ct = default)
{
var entry = _entries.FirstOrDefault(e => e.BundleHash == bundleHash);
return Task.FromResult(entry);
}
public Task AdvanceCursorAsync(string siteId, string cursor, string bundleHash, int itemCount, DateTimeOffset exportedAt, CancellationToken ct = default)
{
_entries.Add(new SyncLedgerEntry
{
SiteId = siteId,
Cursor = cursor,
BundleHash = bundleHash,
ItemCount = itemCount,
ExportedAt = exportedAt,
ImportedAt = exportedAt
});
var policyIndex = _policies.FindIndex(p => p.SiteId == siteId);
if (policyIndex >= 0)
{
var current = _policies[policyIndex];
_policies[policyIndex] = current with
{
LastCursor = cursor,
LastSyncAt = exportedAt,
TotalImports = current.TotalImports + 1
};
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<SitePolicy>> GetAllPoliciesAsync(bool enabledOnly = true, CancellationToken ct = default)
{
IReadOnlyList<SitePolicy> policies = enabledOnly
? _policies.Where(p => p.Enabled).ToList()
: _policies.ToList();
return Task.FromResult(policies);
}
public Task<SitePolicy?> GetPolicyAsync(string siteId, CancellationToken ct = default)
{
var policy = _policies.FirstOrDefault(p => p.SiteId == siteId);
return Task.FromResult(policy);
}
public Task UpsertPolicyAsync(SitePolicy policy, CancellationToken ct = default)
{
var index = _policies.FindIndex(p => p.SiteId == policy.SiteId);
if (index >= 0)
{
_policies[index] = policy;
}
else
{
_policies.Add(policy);
}
return Task.CompletedTask;
}
public Task<SyncLedgerEntry?> GetLatestAsync(string siteId, CancellationToken ct = default)
{
var latest = _entries.LastOrDefault(e => e.SiteId == siteId);
return Task.FromResult(latest);
}
public async IAsyncEnumerable<SyncLedgerEntry> GetHistoryAsync(
string siteId,
int limit,
[EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var entry in _entries.Where(e => e.SiteId == siteId).Take(limit))
{
yield return entry;
await Task.Yield();
}
}
}
}