audit, advisories and doctors/setup work
This commit is contained in:
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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 CI1–CI10 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user