partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryLens.DependencyInjection;
|
||||
using StellaOps.AdvisoryLens.Models;
|
||||
using StellaOps.AdvisoryLens.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Tests;
|
||||
|
||||
public sealed class AdvisoryLensIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void DI_Registration_Resolves_Service()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddAdvisoryLens();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var service = provider.GetService<IAdvisoryLensService>();
|
||||
|
||||
service.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenant_Scoping_Flows_Through_Context()
|
||||
{
|
||||
var service = new AdvisoryLensService(CreatePatterns(), new FakeTimeProvider(new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var tenantA = service.Evaluate(CreateContext("tenant-a"));
|
||||
var tenantB = service.Evaluate(CreateContext("tenant-b"));
|
||||
|
||||
tenantA.Should().NotBeNull();
|
||||
tenantA.InputHash.Should().StartWith("sha256:");
|
||||
tenantA.InputHash.Should().NotBe(tenantB.InputHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_Mapping_For_Invalid_Input()
|
||||
{
|
||||
var service = new AdvisoryLensService(Array.Empty<CasePattern>());
|
||||
|
||||
Action evaluate = () => service.Evaluate(null!);
|
||||
|
||||
evaluate.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offline_Execution_No_Network()
|
||||
{
|
||||
var service = new AdvisoryLensService(CreatePatterns());
|
||||
|
||||
var result = service.Evaluate(CreateContext("tenant-offline"));
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.MatchedPatterns.Should().ContainSingle().Which.Should().Be("custom-pattern");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DI_Registration_With_Custom_Patterns()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddAdvisoryLens(
|
||||
CreatePatterns(),
|
||||
new FakeTimeProvider(new DateTimeOffset(2026, 2, 8, 1, 0, 0, TimeSpan.Zero)));
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var service = provider.GetRequiredService<IAdvisoryLensService>();
|
||||
var result = service.Evaluate(CreateContext("tenant-custom"));
|
||||
|
||||
result.MatchedPatterns.Should().ContainSingle().Which.Should().Be("custom-pattern");
|
||||
result.Suggestions.Should().ContainSingle();
|
||||
result.Suggestions[0].Title.Should().Be("Custom escalation");
|
||||
result.EvaluatedAtUtc.Should().Be(new DateTime(2026, 2, 8, 1, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
private static LensContext CreateContext(string tenantId)
|
||||
{
|
||||
return new LensContext
|
||||
{
|
||||
AdvisoryCase = new AdvisoryCase
|
||||
{
|
||||
AdvisoryId = "ADV-INT-01",
|
||||
Cve = "CVE-2026-7777",
|
||||
Purl = "pkg:nuget/integration.demo@1.2.3",
|
||||
Severity = AdvisorySeverity.High,
|
||||
Source = "NVD"
|
||||
},
|
||||
TenantId = tenantId,
|
||||
VexStatements = ImmutableArray.Create("vex-int-1"),
|
||||
ReachabilityData = ImmutableArray.Create("reach-int-1"),
|
||||
PolicyTraces = ImmutableArray.Create("policy-int-1")
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CasePattern> CreatePatterns()
|
||||
{
|
||||
return
|
||||
[
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "custom-pattern",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Medium, Max = AdvisorySeverity.Critical },
|
||||
EcosystemMatch = "nuget",
|
||||
CvePattern = "CVE-2026",
|
||||
DefaultAction = SuggestionAction.Escalate,
|
||||
SuggestionTitle = "Custom escalation",
|
||||
SuggestionRationale = "Integration-registered pattern should be selected"
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryLens.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Tests;
|
||||
|
||||
public sealed class AdvisoryLensModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Models_SerializeRoundTrip_AllTypes_ShouldRemainEquivalent()
|
||||
{
|
||||
var advisoryCase = new AdvisoryCase
|
||||
{
|
||||
AdvisoryId = "ADV-001",
|
||||
Cve = "CVE-2026-1234",
|
||||
Purl = "pkg:nuget/test.pkg@1.2.3",
|
||||
Severity = AdvisorySeverity.High,
|
||||
Source = "NVD",
|
||||
Title = "Sample advisory",
|
||||
Description = "Sample description",
|
||||
Metadata = ImmutableDictionary<string, string>.Empty.Add("region", "us")
|
||||
};
|
||||
|
||||
var suggestion = new LensSuggestion
|
||||
{
|
||||
Rank = 1,
|
||||
Title = "Patch now",
|
||||
Rationale = "Exploitability is high",
|
||||
Confidence = 0.95,
|
||||
Action = SuggestionAction.Mitigate,
|
||||
PatternId = "pat-critical"
|
||||
};
|
||||
|
||||
var hint = new LensHint
|
||||
{
|
||||
Text = "Reachability data available",
|
||||
Category = HintCategory.Reachability,
|
||||
EvidenceRefs = ImmutableArray.Create("reach-1")
|
||||
};
|
||||
|
||||
var pattern = new CasePattern
|
||||
{
|
||||
PatternId = "pat-critical",
|
||||
Description = "Critical nuget pattern",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.High, Max = AdvisorySeverity.Critical },
|
||||
EcosystemMatch = "nuget",
|
||||
CvePattern = "CVE-2026",
|
||||
RequiredVexStatus = ImmutableArray.Create("affected"),
|
||||
DefaultAction = SuggestionAction.Mitigate,
|
||||
SuggestionTitle = "Patch now",
|
||||
SuggestionRationale = "Critical package issue"
|
||||
};
|
||||
|
||||
var context = new LensContext
|
||||
{
|
||||
AdvisoryCase = advisoryCase,
|
||||
TenantId = "tenant-a",
|
||||
VexStatements = ImmutableArray.Create("vex-1"),
|
||||
PolicyTraces = ImmutableArray.Create("policy-1"),
|
||||
ReachabilityData = ImmutableArray.Create("reach-1"),
|
||||
EvaluationTimestampUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
var result = new LensResult
|
||||
{
|
||||
Suggestions = ImmutableArray.Create(suggestion),
|
||||
Hints = ImmutableArray.Create(hint),
|
||||
MatchedPatterns = ImmutableArray.Create(pattern.PatternId),
|
||||
EvaluatedAtUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
InputHash = "sha256:abc"
|
||||
};
|
||||
|
||||
RoundTrip(advisoryCase).Should().BeEquivalentTo(advisoryCase);
|
||||
RoundTrip(suggestion).Should().BeEquivalentTo(suggestion);
|
||||
RoundTrip(hint).Should().BeEquivalentTo(hint);
|
||||
RoundTrip(pattern).Should().BeEquivalentTo(pattern);
|
||||
RoundTrip(pattern.SeverityRange!).Should().BeEquivalentTo(pattern.SeverityRange);
|
||||
RoundTrip(context).Should().BeEquivalentTo(context);
|
||||
RoundTrip(result).Should().BeEquivalentTo(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Models_SerializeTwice_SameInput_ShouldProduceSameJson()
|
||||
{
|
||||
var payload = new LensContext
|
||||
{
|
||||
AdvisoryCase = new AdvisoryCase
|
||||
{
|
||||
AdvisoryId = "ADV-002",
|
||||
Cve = "CVE-2026-2222",
|
||||
Purl = "pkg:nuget/pkg@2.0.0",
|
||||
Severity = AdvisorySeverity.Medium,
|
||||
Source = "OSV",
|
||||
Metadata = ImmutableDictionary<string, string>.Empty.Add("k", "v")
|
||||
},
|
||||
TenantId = "tenant-deterministic",
|
||||
VexStatements = ImmutableArray.Create("vex-a"),
|
||||
PolicyTraces = ImmutableArray.Create("policy-a"),
|
||||
ReachabilityData = ImmutableArray.Create("reach-a"),
|
||||
EvaluationTimestampUtc = new DateTime(2026, 2, 2, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
var first = JsonSerializer.Serialize(payload);
|
||||
var second = JsonSerializer.Serialize(payload);
|
||||
|
||||
second.Should().Be(first);
|
||||
}
|
||||
|
||||
private static T RoundTrip<T>(T instance)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(instance);
|
||||
return JsonSerializer.Deserialize<T>(json)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryLens.DependencyInjection;
|
||||
using StellaOps.AdvisoryLens.Models;
|
||||
using StellaOps.AdvisoryLens.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Tests;
|
||||
|
||||
public sealed class AdvisoryLensServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Evaluate_FullFlow_ReturnsExpectedResult()
|
||||
{
|
||||
var context = CreateContext();
|
||||
var service = new AdvisoryLensService(CreatePatterns(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var result = service.Evaluate(context);
|
||||
|
||||
result.Suggestions.Should().NotBeEmpty();
|
||||
result.Hints.Should().HaveCount(4);
|
||||
result.MatchedPatterns.Should().ContainSingle().Which.Should().Be("pat-core");
|
||||
result.EvaluatedAtUtc.Should().Be(context.EvaluationTimestampUtc);
|
||||
result.InputHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_SameFrozenInput_IsDeterministic()
|
||||
{
|
||||
var context = CreateContext();
|
||||
var service = new AdvisoryLensService(CreatePatterns(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var first = service.Evaluate(context);
|
||||
var second = service.Evaluate(context);
|
||||
|
||||
JsonSerializer.Serialize(second).Should().Be(JsonSerializer.Serialize(first));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_HintsGeneration_ContainsSeverityVexReachabilityPolicy()
|
||||
{
|
||||
var context = CreateContext();
|
||||
var service = new AdvisoryLensService(Array.Empty<CasePattern>(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var result = service.Evaluate(context);
|
||||
|
||||
result.Hints.Select(h => h.Category).Should().Equal(
|
||||
HintCategory.Severity,
|
||||
HintCategory.Reachability,
|
||||
HintCategory.Vex,
|
||||
HintCategory.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_EmptyPatterns_ReturnsEmptySuggestionsWithValidResult()
|
||||
{
|
||||
var context = CreateContext();
|
||||
var service = new AdvisoryLensService(Array.Empty<CasePattern>(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var result = service.Evaluate(context);
|
||||
|
||||
result.Suggestions.Should().BeEmpty();
|
||||
result.MatchedPatterns.Should().BeEmpty();
|
||||
result.InputHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_InputHashStability_SameContextProducesSameHash()
|
||||
{
|
||||
var context = CreateContext();
|
||||
var service = new AdvisoryLensService(Array.Empty<CasePattern>(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
var first = service.Evaluate(context);
|
||||
var second = service.Evaluate(context);
|
||||
|
||||
second.InputHash.Should().Be(first.InputHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddAdvisoryLens_RegistersResolvableService()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddAdvisoryLens(CreatePatterns(), new FakeTimeProvider(new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero)));
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var service = provider.GetService<IAdvisoryLensService>();
|
||||
|
||||
service.Should().NotBeNull();
|
||||
var result = service!.Evaluate(CreateContext(withEvaluationTimestamp: false));
|
||||
result.EvaluatedAtUtc.Should().Be(new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
private static LensContext CreateContext(bool withEvaluationTimestamp = true)
|
||||
{
|
||||
return new LensContext
|
||||
{
|
||||
AdvisoryCase = new AdvisoryCase
|
||||
{
|
||||
AdvisoryId = "ADV-900",
|
||||
Cve = "CVE-2026-9000",
|
||||
Purl = "pkg:nuget/test.lib@9.0.0",
|
||||
Severity = AdvisorySeverity.Critical,
|
||||
Source = "NVD"
|
||||
},
|
||||
TenantId = "tenant-1",
|
||||
VexStatements = ImmutableArray.Create("vex-1"),
|
||||
PolicyTraces = ImmutableArray.Create("policy-1"),
|
||||
ReachabilityData = ImmutableArray.Create("reach-1"),
|
||||
EvaluationTimestampUtc = withEvaluationTimestamp
|
||||
? new DateTime(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CasePattern> CreatePatterns()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "pat-core",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.High, Max = AdvisorySeverity.Critical },
|
||||
EcosystemMatch = "nuget",
|
||||
CvePattern = "CVE-2026",
|
||||
DefaultAction = SuggestionAction.Escalate,
|
||||
SuggestionTitle = "Escalate review",
|
||||
SuggestionRationale = "Critical advisory in primary ecosystem"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryLens.Matching;
|
||||
using StellaOps.AdvisoryLens.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryLens.Tests;
|
||||
|
||||
public sealed class CaseMatcherTests
|
||||
{
|
||||
private static AdvisoryCase CreateCase(AdvisorySeverity severity = AdvisorySeverity.High)
|
||||
=> new()
|
||||
{
|
||||
AdvisoryId = "ADV-101",
|
||||
Cve = "CVE-2026-1001",
|
||||
Purl = "pkg:nuget/demo.package@1.0.0",
|
||||
Severity = severity
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Match_HappyPath_ReturnsPositiveScore()
|
||||
{
|
||||
var matcher = new CaseMatcher();
|
||||
var patterns = new[]
|
||||
{
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "pat-1",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Medium, Max = AdvisorySeverity.Critical },
|
||||
EcosystemMatch = "nuget",
|
||||
DefaultAction = SuggestionAction.Mitigate,
|
||||
SuggestionTitle = "Mitigate",
|
||||
SuggestionRationale = "Matching pattern"
|
||||
}
|
||||
};
|
||||
|
||||
var results = matcher.Match(CreateCase(), patterns);
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].PatternId.Should().Be("pat-1");
|
||||
results[0].Score.Should().BeGreaterThan(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_SeverityOutsideRange_ReturnsEmpty()
|
||||
{
|
||||
var matcher = new CaseMatcher();
|
||||
var patterns = new[]
|
||||
{
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "pat-2",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Critical, Max = AdvisorySeverity.Critical },
|
||||
DefaultAction = SuggestionAction.Escalate,
|
||||
SuggestionTitle = "Escalate",
|
||||
SuggestionRationale = "Severity mismatch"
|
||||
}
|
||||
};
|
||||
|
||||
var results = matcher.Match(CreateCase(AdvisorySeverity.Low), patterns);
|
||||
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_MultiplePatterns_OrdersByScoreThenPatternId()
|
||||
{
|
||||
var matcher = new CaseMatcher();
|
||||
var patterns = new[]
|
||||
{
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "b-pattern",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Medium, Max = AdvisorySeverity.Critical },
|
||||
DefaultAction = SuggestionAction.Mitigate,
|
||||
SuggestionTitle = "B",
|
||||
SuggestionRationale = "B"
|
||||
},
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "a-pattern",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Medium, Max = AdvisorySeverity.Critical },
|
||||
DefaultAction = SuggestionAction.Mitigate,
|
||||
SuggestionTitle = "A",
|
||||
SuggestionRationale = "A"
|
||||
},
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "c-pattern",
|
||||
DefaultAction = SuggestionAction.Defer,
|
||||
SuggestionTitle = "C",
|
||||
SuggestionRationale = "C"
|
||||
}
|
||||
};
|
||||
|
||||
var results = matcher.Match(CreateCase(), patterns);
|
||||
|
||||
results.Select(r => r.PatternId).Should().Equal("a-pattern", "b-pattern", "c-pattern");
|
||||
results[0].Score.Should().Be(1.0);
|
||||
results[2].Score.Should().Be(0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_SameInputRepeated_IsDeterministic()
|
||||
{
|
||||
var matcher = new CaseMatcher();
|
||||
var patterns = new[]
|
||||
{
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "pat-det-1",
|
||||
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Low, Max = AdvisorySeverity.Critical },
|
||||
DefaultAction = SuggestionAction.Accept,
|
||||
SuggestionTitle = "Det",
|
||||
SuggestionRationale = "Det"
|
||||
},
|
||||
new CasePattern
|
||||
{
|
||||
PatternId = "pat-det-2",
|
||||
DefaultAction = SuggestionAction.Defer,
|
||||
SuggestionTitle = "Det2",
|
||||
SuggestionRationale = "Det2"
|
||||
}
|
||||
};
|
||||
|
||||
var first = matcher.Match(CreateCase(), patterns);
|
||||
var second = matcher.Match(CreateCase(), patterns);
|
||||
|
||||
second.Should().Equal(first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_EmptyPatterns_ReturnsEmpty()
|
||||
{
|
||||
var matcher = new CaseMatcher();
|
||||
|
||||
var results = matcher.Match(CreateCase(), Array.Empty<CasePattern>());
|
||||
|
||||
results.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_NullArguments_ThrowArgumentNullException()
|
||||
{
|
||||
var matcher = new CaseMatcher();
|
||||
|
||||
Action nullCase = () => matcher.Match(null!, Array.Empty<CasePattern>());
|
||||
Action nullPatterns = () => matcher.Match(CreateCase(), null!);
|
||||
|
||||
nullCase.Should().Throw<ArgumentNullException>();
|
||||
nullPatterns.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryLens\StellaOps.AdvisoryLens.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false,
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Provcache.Events;
|
||||
using StellaOps.Provcache.Invalidation;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Intent", "Operational")]
|
||||
public sealed partial class FeedEpochInvalidatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 2, 9, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly Mock<IEventStream<FeedEpochAdvancedEvent>> _eventStreamMock = new();
|
||||
private readonly Mock<IProvcacheService> _provcacheServiceMock = new();
|
||||
private readonly Mock<ILogger<FeedEpochInvalidator>> _loggerMock = new();
|
||||
private readonly FixedTimeProvider _timeProvider = new(FixedNow);
|
||||
|
||||
private FeedEpochInvalidator CreateSut()
|
||||
{
|
||||
return new FeedEpochInvalidator(
|
||||
_eventStreamMock.Object,
|
||||
_provcacheServiceMock.Object,
|
||||
_loggerMock.Object,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
private static FeedEpochAdvancedEvent CreateEvent()
|
||||
{
|
||||
return FeedEpochAdvancedEvent.Create(
|
||||
feedId: "cve",
|
||||
previousEpoch: "2026-02-08T00:00:00Z",
|
||||
newEpoch: "2026-02-09T00:00:00Z",
|
||||
effectiveAt: FixedNow,
|
||||
advisoriesAdded: 1,
|
||||
advisoriesModified: 2,
|
||||
advisoriesWithdrawn: 0,
|
||||
tenantId: null,
|
||||
correlationId: "corr-feed-1",
|
||||
eventId: Guid.Parse("44444444-4444-4444-4444-444444444444"),
|
||||
timestamp: FixedNow);
|
||||
}
|
||||
|
||||
private static StreamEvent<FeedEpochAdvancedEvent> ToStreamEvent(FeedEpochAdvancedEvent @event)
|
||||
{
|
||||
return new StreamEvent<FeedEpochAdvancedEvent>("2-0", @event, @event.Timestamp, null, @event.CorrelationId);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<StreamEvent<FeedEpochAdvancedEvent>> StreamEvents(IEnumerable<StreamEvent<FeedEpochAdvancedEvent>> events, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var streamEvent in events)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return streamEvent;
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<StreamEvent<FeedEpochAdvancedEvent>> WaitUntilCancelled([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
yield break;
|
||||
}
|
||||
|
||||
private static void VerifyLog(Mock<ILogger<FeedEpochInvalidator>> logger, LogLevel level, string containsText, Times times)
|
||||
{
|
||||
logger.Verify(x => x.Log(
|
||||
level,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, _) => v.ToString() != null && v.ToString()!.Contains(containsText, StringComparison.Ordinal)),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
times);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Provcache.Events;
|
||||
using StellaOps.Provcache.Invalidation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
public sealed partial class FeedEpochInvalidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ValidatesDependencies()
|
||||
{
|
||||
var es = new Mock<IEventStream<FeedEpochAdvancedEvent>>();
|
||||
var svc = new Mock<IProvcacheService>();
|
||||
var log = new Mock<ILogger<FeedEpochInvalidator>>();
|
||||
FluentActions.Invoking(() => new FeedEpochInvalidator(null!, svc.Object, log.Object)).Should().Throw<ArgumentNullException>().WithParameterName("eventStream");
|
||||
FluentActions.Invoking(() => new FeedEpochInvalidator(es.Object, null!, log.Object)).Should().Throw<ArgumentNullException>().WithParameterName("provcacheService");
|
||||
FluentActions.Invoking(() => new FeedEpochInvalidator(es.Object, svc.Object, null!)).Should().Throw<ArgumentNullException>().WithParameterName("logger");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAndStop_ManageRunningState()
|
||||
{
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => WaitUntilCancelled(ct));
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
sut.IsRunning.Should().BeTrue();
|
||||
await sut.StopAsync();
|
||||
sut.IsRunning.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WhenAlreadyRunning_LogsWarningAndReturns()
|
||||
{
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => WaitUntilCancelled(ct));
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await sut.StartAsync();
|
||||
_eventStreamMock.Verify(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>()), Times.Once);
|
||||
VerifyLog(_loggerMock, LogLevel.Warning, "already running", Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingEvent_CallsInvalidateByWithFeedEpochRequest()
|
||||
{
|
||||
var evt = CreateEvent();
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
InvalidationRequest? captured = null;
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([ToStreamEvent(evt)], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).Callback<InvalidationRequest, CancellationToken>((r, _) => { captured = r; done.TrySetResult(); }).ReturnsAsync(new InvalidationResult { EntriesAffected = 4, Request = InvalidationRequest.ByFeedEpochOlderThan(evt.NewEpoch), Timestamp = FixedNow });
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
captured.Should().NotBeNull();
|
||||
captured!.Type.Should().Be(InvalidationType.FeedEpochOlderThan);
|
||||
captured.Value.Should().Be(evt.NewEpoch);
|
||||
captured.Reason.Should().Contain("Feed cve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingEvent_SuccessUpdatesMetrics()
|
||||
{
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([ToStreamEvent(CreateEvent())], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).Callback(() => done.TrySetResult()).ReturnsAsync(new InvalidationResult { EntriesAffected = 9, Request = InvalidationRequest.ByFeedEpochOlderThan("2026-02-09T00:00:00Z"), Timestamp = FixedNow });
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var metrics = sut.GetMetrics();
|
||||
metrics.EventsProcessed.Should().Be(1);
|
||||
metrics.EntriesInvalidated.Should().Be(9);
|
||||
metrics.Errors.Should().Be(0);
|
||||
metrics.LastEventAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingEvent_ErrorIsCaughtLoggedAndCounted()
|
||||
{
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([ToStreamEvent(CreateEvent())], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).Callback(() => done.TrySetResult()).ThrowsAsync(new InvalidOperationException("boom"));
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var metrics = sut.GetMetrics();
|
||||
metrics.Errors.Should().Be(1);
|
||||
metrics.EventsProcessed.Should().Be(0);
|
||||
VerifyLog(_loggerMock, LogLevel.Error, "Error processing FeedEpochAdvancedEvent", Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetrics_ReturnsAccurateCountersAfterMultipleEvents()
|
||||
{
|
||||
var e1 = ToStreamEvent(CreateEvent());
|
||||
var e2 = ToStreamEvent(CreateEvent() with { EventId = Guid.Parse("55555555-5555-5555-5555-555555555555"), CorrelationId = "corr-feed-2" });
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var calls = 0;
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([e1, e2], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).ReturnsAsync(() =>
|
||||
{
|
||||
calls++;
|
||||
if (calls >= 2)
|
||||
{
|
||||
done.TrySetResult();
|
||||
return new InvalidationResult { EntriesAffected = 6, Request = InvalidationRequest.ByFeedEpochOlderThan("2026-02-09T00:00:00Z"), Timestamp = FixedNow };
|
||||
}
|
||||
|
||||
return new InvalidationResult { EntriesAffected = 1, Request = InvalidationRequest.ByFeedEpochOlderThan("2026-02-09T00:00:00Z"), Timestamp = FixedNow };
|
||||
});
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var metrics = sut.GetMetrics();
|
||||
metrics.EventsProcessed.Should().Be(2);
|
||||
metrics.EntriesInvalidated.Should().Be(7);
|
||||
metrics.Errors.Should().Be(0);
|
||||
metrics.CollectedAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_StopsAndIsIdempotent()
|
||||
{
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => WaitUntilCancelled(ct));
|
||||
var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await sut.DisposeAsync();
|
||||
sut.IsRunning.Should().BeFalse();
|
||||
await FluentActions.Awaiting(() => sut.DisposeAsync().AsTask()).Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Provcache.Invalidation;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Intent", "Operational")]
|
||||
public sealed class InvalidatorHostedServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ValidatesDependencies()
|
||||
{
|
||||
var logger = new Mock<ILogger<InvalidatorHostedService>>();
|
||||
FluentActions.Invoking(() => new InvalidatorHostedService(null!, logger.Object)).Should().Throw<ArgumentNullException>().WithParameterName("invalidators");
|
||||
FluentActions.Invoking(() => new InvalidatorHostedService([], null!)).Should().Throw<ArgumentNullException>().WithParameterName("logger");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_StartsAllInvalidators()
|
||||
{
|
||||
var one = new Mock<IProvcacheInvalidator>();
|
||||
var two = new Mock<IProvcacheInvalidator>();
|
||||
var logger = new Mock<ILogger<InvalidatorHostedService>>();
|
||||
one.Setup(x => x.StartAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||
two.Setup(x => x.StartAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||
|
||||
var sut = new InvalidatorHostedService([one.Object, two.Object], logger.Object);
|
||||
await sut.StartAsync(CancellationToken.None);
|
||||
|
||||
one.Verify(x => x.StartAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
two.Verify(x => x.StartAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_StopsAllInvalidators()
|
||||
{
|
||||
var one = new Mock<IProvcacheInvalidator>();
|
||||
var two = new Mock<IProvcacheInvalidator>();
|
||||
var logger = new Mock<ILogger<InvalidatorHostedService>>();
|
||||
one.Setup(x => x.StopAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||
two.Setup(x => x.StopAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||
|
||||
var sut = new InvalidatorHostedService([one.Object, two.Object], logger.Object);
|
||||
await sut.StopAsync(CancellationToken.None);
|
||||
|
||||
one.Verify(x => x.StopAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
two.Verify(x => x.StopAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAndStop_WithNoInvalidators_DoesNotThrow()
|
||||
{
|
||||
var logger = new Mock<ILogger<InvalidatorHostedService>>();
|
||||
var sut = new InvalidatorHostedService([], logger.Object);
|
||||
await FluentActions.Awaiting(() => sut.StartAsync(CancellationToken.None)).Should().NotThrowAsync();
|
||||
await FluentActions.Awaiting(() => sut.StopAsync(CancellationToken.None)).Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Provcache.Events;
|
||||
using StellaOps.Provcache.Invalidation;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Intent", "Operational")]
|
||||
public sealed partial class SignerSetInvalidatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 2, 9, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly Mock<IEventStream<SignerRevokedEvent>> _eventStreamMock = new();
|
||||
private readonly Mock<IProvcacheService> _provcacheServiceMock = new();
|
||||
private readonly Mock<ILogger<SignerSetInvalidator>> _loggerMock = new();
|
||||
private readonly FixedTimeProvider _timeProvider = new(FixedNow);
|
||||
|
||||
private SignerSetInvalidator CreateSut()
|
||||
{
|
||||
return new SignerSetInvalidator(
|
||||
_eventStreamMock.Object,
|
||||
_provcacheServiceMock.Object,
|
||||
_loggerMock.Object,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
private static SignerRevokedEvent CreateEvent()
|
||||
{
|
||||
return SignerRevokedEvent.Create(
|
||||
anchorId: Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
keyId: "key-1",
|
||||
signerHash: "sha256:signer-hash",
|
||||
effectiveAt: FixedNow.AddMinutes(-1),
|
||||
reason: "key compromise",
|
||||
actor: "authority",
|
||||
correlationId: "corr-1",
|
||||
eventId: Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
timestamp: FixedNow);
|
||||
}
|
||||
|
||||
private static StreamEvent<SignerRevokedEvent> ToStreamEvent(SignerRevokedEvent @event)
|
||||
{
|
||||
return new StreamEvent<SignerRevokedEvent>(
|
||||
EntryId: "1-0",
|
||||
Event: @event,
|
||||
Timestamp: @event.Timestamp,
|
||||
TenantId: null,
|
||||
CorrelationId: @event.CorrelationId);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<StreamEvent<SignerRevokedEvent>> StreamEvents(
|
||||
IEnumerable<StreamEvent<SignerRevokedEvent>> events,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var streamEvent in events)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return streamEvent;
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<StreamEvent<SignerRevokedEvent>> WaitUntilCancelled(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
yield break;
|
||||
}
|
||||
|
||||
private static void VerifyLog(
|
||||
Mock<ILogger<SignerSetInvalidator>> logger,
|
||||
LogLevel level,
|
||||
string containsText,
|
||||
Times times)
|
||||
{
|
||||
logger.Verify(x => x.Log(
|
||||
level,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, _) => v.ToString() != null && v.ToString()!.Contains(containsText, StringComparison.Ordinal)),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
times);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Provcache.Events;
|
||||
using StellaOps.Provcache.Invalidation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
public sealed partial class SignerSetInvalidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ValidatesDependencies()
|
||||
{
|
||||
var es = new Mock<IEventStream<SignerRevokedEvent>>();
|
||||
var svc = new Mock<IProvcacheService>();
|
||||
var log = new Mock<ILogger<SignerSetInvalidator>>();
|
||||
FluentActions.Invoking(() => new SignerSetInvalidator(null!, svc.Object, log.Object)).Should().Throw<ArgumentNullException>().WithParameterName("eventStream");
|
||||
FluentActions.Invoking(() => new SignerSetInvalidator(es.Object, null!, log.Object)).Should().Throw<ArgumentNullException>().WithParameterName("provcacheService");
|
||||
FluentActions.Invoking(() => new SignerSetInvalidator(es.Object, svc.Object, null!)).Should().Throw<ArgumentNullException>().WithParameterName("logger");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAndStop_ManageRunningState()
|
||||
{
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => WaitUntilCancelled(ct));
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
sut.IsRunning.Should().BeTrue();
|
||||
await sut.StopAsync();
|
||||
sut.IsRunning.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WhenAlreadyRunning_LogsWarningAndReturns()
|
||||
{
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => WaitUntilCancelled(ct));
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await sut.StartAsync();
|
||||
_eventStreamMock.Verify(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>()), Times.Once);
|
||||
VerifyLog(_loggerMock, LogLevel.Warning, "already running", Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingEvent_CallsInvalidateByWithSignerHashRequest()
|
||||
{
|
||||
var evt = CreateEvent();
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
InvalidationRequest? captured = null;
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([ToStreamEvent(evt)], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).Callback<InvalidationRequest, CancellationToken>((r, _) => { captured = r; done.TrySetResult(); }).ReturnsAsync(new InvalidationResult { EntriesAffected = 3, Request = InvalidationRequest.BySignerSetHash("sha256:signer-hash"), Timestamp = FixedNow });
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
captured.Should().NotBeNull();
|
||||
captured!.Type.Should().Be(InvalidationType.SignerSetHash);
|
||||
captured.Value.Should().Be(evt.SignerHash);
|
||||
captured.Actor.Should().Be(evt.Actor);
|
||||
captured.Reason.Should().Contain("Signer revoked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingEvent_SuccessUpdatesMetrics()
|
||||
{
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([ToStreamEvent(CreateEvent())], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).Callback(() => done.TrySetResult()).ReturnsAsync(new InvalidationResult { EntriesAffected = 7, Request = InvalidationRequest.BySignerSetHash("sha256:signer-hash"), Timestamp = FixedNow });
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var metrics = sut.GetMetrics();
|
||||
metrics.EventsProcessed.Should().Be(1);
|
||||
metrics.EntriesInvalidated.Should().Be(7);
|
||||
metrics.Errors.Should().Be(0);
|
||||
metrics.LastEventAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessingEvent_ErrorIsCaughtLoggedAndCounted()
|
||||
{
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([ToStreamEvent(CreateEvent())], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).Callback(() => done.TrySetResult()).ThrowsAsync(new InvalidOperationException("boom"));
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var metrics = sut.GetMetrics();
|
||||
metrics.Errors.Should().Be(1);
|
||||
metrics.EventsProcessed.Should().Be(0);
|
||||
VerifyLog(_loggerMock, LogLevel.Error, "Error processing SignerRevokedEvent", Times.Once());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetrics_ReturnsAccurateCountersAfterMultipleEvents()
|
||||
{
|
||||
var e1 = ToStreamEvent(CreateEvent());
|
||||
var e2 = ToStreamEvent(CreateEvent() with { EventId = Guid.Parse("33333333-3333-3333-3333-333333333333"), CorrelationId = "corr-2" });
|
||||
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var calls = 0;
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => StreamEvents([e1, e2], ct));
|
||||
_provcacheServiceMock.Setup(x => x.InvalidateByAsync(It.IsAny<InvalidationRequest>(), It.IsAny<CancellationToken>())).ReturnsAsync(() =>
|
||||
{
|
||||
calls++;
|
||||
if (calls >= 2)
|
||||
{
|
||||
done.TrySetResult();
|
||||
return new InvalidationResult { EntriesAffected = 5, Request = InvalidationRequest.BySignerSetHash("sha256:signer-hash"), Timestamp = FixedNow };
|
||||
}
|
||||
|
||||
return new InvalidationResult { EntriesAffected = 2, Request = InvalidationRequest.BySignerSetHash("sha256:signer-hash"), Timestamp = FixedNow };
|
||||
});
|
||||
await using var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
var metrics = sut.GetMetrics();
|
||||
metrics.EventsProcessed.Should().Be(2);
|
||||
metrics.EntriesInvalidated.Should().Be(7);
|
||||
metrics.Errors.Should().Be(0);
|
||||
metrics.CollectedAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_StopsAndIsIdempotent()
|
||||
{
|
||||
_eventStreamMock.Setup(x => x.SubscribeAsync(StreamPosition.End, It.IsAny<CancellationToken>())).Returns((StreamPosition _, CancellationToken ct) => WaitUntilCancelled(ct));
|
||||
var sut = CreateSut();
|
||||
await sut.StartAsync();
|
||||
await sut.DisposeAsync();
|
||||
sut.IsRunning.Should().BeFalse();
|
||||
await FluentActions.Awaiting(() => sut.DisposeAsync().AsTask()).Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user