consolidate the tests locations
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Acsc/StellaOps.Concelier.Connector.Acsc.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Cve/StellaOps.Concelier.Connector.Cve.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Source\Distro\Alpine\Fixtures\**\*">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Ghsa/StellaOps.Concelier.Connector.Ghsa.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Kev/StellaOps.Concelier.Connector.Kev.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Kisa/StellaOps.Concelier.Connector.Kisa.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Concelier.Connector.Common.Tests/StellaOps.Concelier.Connector.Common.Tests.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Vndr.Apple/StellaOps.Concelier.Connector.Vndr.Apple.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="Apple/Fixtures/*.html" CopyToOutputDirectory="Always" TargetPath="Source/Vndr/Apple/Fixtures/%(Filename)%(Extension)" />
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Vndr.Msrc/StellaOps.Concelier.Connector.Vndr.Msrc.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InterestScoringServiceTests.cs
|
||||
// Sprint: SPRINT_8200_0013_0002_CONCEL_interest_scoring
|
||||
// Tasks: ISCORE-8200-018, ISCORE-8200-023, ISCORE-8200-028
|
||||
// Description: Integration tests for scoring service, job execution, and degradation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Interest.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Interest.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="InterestScoringService"/>.
|
||||
/// Tests job execution, score consistency, and degradation/restoration cycles.
|
||||
/// </summary>
|
||||
public class InterestScoringServiceTests
|
||||
{
|
||||
private readonly Mock<IInterestScoreRepository> _repositoryMock;
|
||||
private readonly InterestScoringService _service;
|
||||
private readonly InterestScoreWeights _defaultWeights = new();
|
||||
|
||||
public InterestScoringServiceTests()
|
||||
{
|
||||
_repositoryMock = new Mock<IInterestScoreRepository>();
|
||||
|
||||
var options = Options.Create(new InterestScoreOptions
|
||||
{
|
||||
DegradationPolicy = new StubDegradationPolicy
|
||||
{
|
||||
DegradationThreshold = 0.2,
|
||||
RestorationThreshold = 0.4,
|
||||
MinAgeDays = 30,
|
||||
BatchSize = 1000,
|
||||
Enabled = true
|
||||
},
|
||||
Job = new ScoringJobOptions
|
||||
{
|
||||
Enabled = true,
|
||||
FullRecalculationBatchSize = 100
|
||||
}
|
||||
});
|
||||
|
||||
_service = new InterestScoringService(
|
||||
_repositoryMock.Object,
|
||||
new InterestScoreCalculator(_defaultWeights),
|
||||
options,
|
||||
advisoryStore: null,
|
||||
cacheService: null,
|
||||
logger: NullLogger<InterestScoringService>.Instance);
|
||||
}
|
||||
|
||||
#region Task 18: Integration Tests - Score Persistence
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScoreAsync_PersistsToRepository()
|
||||
{
|
||||
// Arrange
|
||||
var score = CreateTestScore(0.75, ["in_sbom", "reachable"]);
|
||||
|
||||
// Act
|
||||
await _service.UpdateScoreAsync(score);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(
|
||||
r => r.SaveAsync(score, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScoreAsync_RetrievesFromRepository()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var expected = CreateTestScore(0.5, ["in_sbom"], canonicalId);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByCanonicalIdAsync(canonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(expected);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetScoreAsync(canonicalId);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.CanonicalId.Should().Be(canonicalId);
|
||||
result.Score.Should().Be(0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScoreAsync_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByCanonicalIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((InterestScore?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetScoreAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateAsync_UpdatesMultipleScores()
|
||||
{
|
||||
// Arrange
|
||||
var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||||
|
||||
// Act
|
||||
await _service.BatchUpdateAsync(ids);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(
|
||||
r => r.SaveManyAsync(It.Is<IEnumerable<InterestScore>>(s => s.Count() == 3), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateAsync_HandlesEmptyInput()
|
||||
{
|
||||
// Act
|
||||
await _service.BatchUpdateAsync([]);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(
|
||||
r => r.SaveManyAsync(It.IsAny<IEnumerable<InterestScore>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Task 23: Job Execution and Score Consistency
|
||||
|
||||
[Fact]
|
||||
public async Task RecalculateAllAsync_ReturnsZero_WhenNoAdvisoryStore()
|
||||
{
|
||||
// The service is created without an ICanonicalAdvisoryStore,
|
||||
// so RecalculateAllAsync returns 0 immediately
|
||||
// (which is correct behavior for tests without full integration setup)
|
||||
|
||||
// Act
|
||||
var result = await _service.RecalculateAllAsync();
|
||||
|
||||
// Assert - returns 0 because advisory store is not available
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeScoreAsync_ProducesDeterministicResults()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
|
||||
// Act - compute twice with same input
|
||||
var result1 = await _service.ComputeScoreAsync(canonicalId);
|
||||
var result2 = await _service.ComputeScoreAsync(canonicalId);
|
||||
|
||||
// Assert - same inputs should produce same outputs
|
||||
result1.Score.Should().Be(result2.Score);
|
||||
result1.Reasons.Should().BeEquivalentTo(result2.Reasons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeScoreAsync_ReturnsValidScoreRange()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeScoreAsync(canonicalId);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().BeInRange(0.0, 1.0);
|
||||
result.CanonicalId.Should().Be(canonicalId);
|
||||
result.ComputedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScoreAsync_PreservesScoreConsistency()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
InterestScore? savedScore = null;
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.SaveAsync(It.IsAny<InterestScore>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<InterestScore, CancellationToken>((s, _) => savedScore = s)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var score = CreateTestScore(0.75, ["in_sbom", "reachable"], canonicalId);
|
||||
|
||||
// Act
|
||||
await _service.UpdateScoreAsync(score);
|
||||
|
||||
// Assert
|
||||
savedScore.Should().NotBeNull();
|
||||
savedScore!.CanonicalId.Should().Be(canonicalId);
|
||||
savedScore.Score.Should().Be(0.75);
|
||||
savedScore.Reasons.Should().BeEquivalentTo(["in_sbom", "reachable"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateAsync_MaintainsScoreOrdering()
|
||||
{
|
||||
// Arrange
|
||||
var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
|
||||
IEnumerable<InterestScore>? savedScores = null;
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.SaveManyAsync(It.IsAny<IEnumerable<InterestScore>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<InterestScore>, CancellationToken>((s, _) => savedScores = s.ToList())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _service.BatchUpdateAsync(ids);
|
||||
|
||||
// Assert
|
||||
savedScores.Should().NotBeNull();
|
||||
var scoreList = savedScores!.ToList();
|
||||
scoreList.Should().HaveCount(3);
|
||||
scoreList.Select(s => s.CanonicalId).Should().BeEquivalentTo(ids);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Task 28: Degradation/Restoration Cycle
|
||||
|
||||
[Fact]
|
||||
public async Task DegradeToStubsAsync_ReturnsZero_WhenNoAdvisoryStore()
|
||||
{
|
||||
// The service is created without an ICanonicalAdvisoryStore,
|
||||
// so degradation operations should return 0 immediately
|
||||
// (which is correct behavior for tests without full integration setup)
|
||||
|
||||
// Act
|
||||
var result = await _service.DegradeToStubsAsync(0.2);
|
||||
|
||||
// Assert - returns 0 because advisory store is not available
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreFromStubsAsync_ReturnsZero_WhenNoAdvisoryStore()
|
||||
{
|
||||
// The service is created without an ICanonicalAdvisoryStore,
|
||||
// so restoration operations should return 0 immediately
|
||||
|
||||
// Act
|
||||
var result = await _service.RestoreFromStubsAsync(0.4);
|
||||
|
||||
// Assert - returns 0 because advisory store is not available
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DegradeRestoreCycle_MaintainsDataIntegrity()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var scores = new Dictionary<Guid, InterestScore>();
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.SaveAsync(It.IsAny<InterestScore>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<InterestScore, CancellationToken>((s, _) => scores[s.CanonicalId] = s)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByCanonicalIdAsync(canonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => scores.GetValueOrDefault(canonicalId));
|
||||
|
||||
// Initial low score
|
||||
var lowScore = CreateTestScore(0.1, [], canonicalId);
|
||||
await _service.UpdateScoreAsync(lowScore);
|
||||
|
||||
// Verify low score stored
|
||||
var stored = await _service.GetScoreAsync(canonicalId);
|
||||
stored!.Score.Should().Be(0.1);
|
||||
|
||||
// Update to high score (simulating new evidence)
|
||||
var highScore = CreateTestScore(0.8, ["in_sbom", "reachable", "deployed"], canonicalId);
|
||||
await _service.UpdateScoreAsync(highScore);
|
||||
|
||||
// Verify high score stored
|
||||
stored = await _service.GetScoreAsync(canonicalId);
|
||||
stored!.Score.Should().Be(0.8);
|
||||
stored.Reasons.Should().Contain("in_sbom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DegradeToStubsAsync_ReturnsZero_WhenNoLowScores()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetLowScoreCanonicalIdsAsync(
|
||||
It.IsAny<double>(),
|
||||
It.IsAny<TimeSpan>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Guid>());
|
||||
|
||||
// Act
|
||||
var result = await _service.DegradeToStubsAsync(0.2);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreFromStubsAsync_ReturnsZero_WhenNoHighScores()
|
||||
{
|
||||
// Arrange
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetHighScoreCanonicalIdsAsync(
|
||||
It.IsAny<double>(),
|
||||
It.IsAny<int>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Guid>());
|
||||
|
||||
// Act
|
||||
var result = await _service.RestoreFromStubsAsync(0.4);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScoreAsync_HandlesBoundaryScores()
|
||||
{
|
||||
// Arrange
|
||||
var minScore = CreateTestScore(0.0, []);
|
||||
var maxScore = CreateTestScore(1.0, ["in_sbom", "reachable", "deployed", "no_vex_na", "recent"]);
|
||||
|
||||
// Act & Assert - should not throw
|
||||
await _service.UpdateScoreAsync(minScore);
|
||||
await _service.UpdateScoreAsync(maxScore);
|
||||
|
||||
_repositoryMock.Verify(
|
||||
r => r.SaveAsync(It.IsAny<InterestScore>(), It.IsAny<CancellationToken>()),
|
||||
Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeScoreAsync_HandlesNullInputGracefully()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.ComputeScoreAsync(Guid.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.CanonicalId.Should().Be(Guid.Empty);
|
||||
result.Score.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static InterestScore CreateTestScore(
|
||||
double score,
|
||||
string[] reasons,
|
||||
Guid? canonicalId = null)
|
||||
{
|
||||
return new InterestScore
|
||||
{
|
||||
CanonicalId = canonicalId ?? Guid.NewGuid(),
|
||||
Score = score,
|
||||
Reasons = reasons,
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -9,11 +9,17 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Concelier.Interest.Tests</RootNamespace>
|
||||
<!-- Unit tests use mocks, no need for Postgres test infrastructure -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InterestScoringServiceIntegrationTests.cs
|
||||
// Sprint: SPRINT_8200_0013_0002_CONCEL_interest_scoring
|
||||
// Task: ISCORE-8200-018
|
||||
// Description: Integration tests for InterestScoringService with Postgres + Valkey
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Cache.Valkey;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Concelier.Interest;
|
||||
using StellaOps.Concelier.Interest.Models;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="InterestScoringService"/> with real PostgreSQL
|
||||
/// and mocked Valkey cache service.
|
||||
/// </summary>
|
||||
[Collection(ConcelierPostgresCollection.Name)]
|
||||
public sealed class InterestScoringServiceIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConcelierPostgresFixture _fixture;
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly InterestScoreRepository _repository;
|
||||
private readonly Mock<IAdvisoryCacheService> _cacheServiceMock;
|
||||
private readonly Mock<ICanonicalAdvisoryStore> _advisoryStoreMock;
|
||||
private readonly InterestScoreCalculator _calculator;
|
||||
private readonly InterestScoreOptions _options;
|
||||
private InterestScoringService _service = null!;
|
||||
|
||||
public InterestScoringServiceIntegrationTests(ConcelierPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
|
||||
_repository = new InterestScoreRepository(_dataSource, NullLogger<InterestScoreRepository>.Instance);
|
||||
|
||||
_cacheServiceMock = new Mock<IAdvisoryCacheService>();
|
||||
_advisoryStoreMock = new Mock<ICanonicalAdvisoryStore>();
|
||||
|
||||
var weights = new InterestScoreWeights();
|
||||
_calculator = new InterestScoreCalculator(weights);
|
||||
|
||||
_options = new InterestScoreOptions
|
||||
{
|
||||
EnableCache = true,
|
||||
DegradationPolicy = new StubDegradationPolicy
|
||||
{
|
||||
Enabled = true,
|
||||
DegradationThreshold = 0.2,
|
||||
RestorationThreshold = 0.4,
|
||||
MinAgeDays = 30,
|
||||
BatchSize = 100
|
||||
},
|
||||
Job = new ScoringJobOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMinutes(60),
|
||||
FullRecalculationBatchSize = 100
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_service = new InterestScoringService(
|
||||
_repository,
|
||||
_calculator,
|
||||
Options.Create(_options),
|
||||
_advisoryStoreMock.Object,
|
||||
_cacheServiceMock.Object,
|
||||
NullLogger<InterestScoringService>.Instance);
|
||||
|
||||
return _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
#region ComputeScoreAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeScoreAsync_WithNoSignals_ReturnsBaseScore()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var score = await _service.ComputeScoreAsync(canonicalId);
|
||||
|
||||
// Assert
|
||||
score.Score.Should().Be(0.15); // Only no_vex_na
|
||||
score.CanonicalId.Should().Be(canonicalId);
|
||||
score.Reasons.Should().Contain("no_vex_na");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeScoreAsync_WithSbomMatch_IncludesInSbomFactor()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
await _service.RecordSbomMatchAsync(
|
||||
canonicalId,
|
||||
sbomDigest: "sha256:test123",
|
||||
purl: "pkg:npm/lodash@4.17.21",
|
||||
isReachable: false,
|
||||
isDeployed: false);
|
||||
|
||||
// Act
|
||||
var score = await _service.ComputeScoreAsync(canonicalId);
|
||||
|
||||
// Assert
|
||||
score.Score.Should().Be(0.45); // in_sbom (0.30) + no_vex_na (0.15)
|
||||
score.Reasons.Should().Contain("in_sbom");
|
||||
score.Reasons.Should().Contain("no_vex_na");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeScoreAsync_WithReachableAndDeployed_IncludesAllFactors()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
await _service.RecordSbomMatchAsync(
|
||||
canonicalId,
|
||||
sbomDigest: "sha256:test123",
|
||||
purl: "pkg:npm/lodash@4.17.21",
|
||||
isReachable: true,
|
||||
isDeployed: true);
|
||||
|
||||
// Act
|
||||
var score = await _service.ComputeScoreAsync(canonicalId);
|
||||
|
||||
// Assert
|
||||
score.Score.Should().Be(0.90); // in_sbom (0.30) + reachable (0.25) + deployed (0.20) + no_vex_na (0.15)
|
||||
score.Reasons.Should().Contain("in_sbom");
|
||||
score.Reasons.Should().Contain("reachable");
|
||||
score.Reasons.Should().Contain("deployed");
|
||||
score.Reasons.Should().Contain("no_vex_na");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeScoreAsync_WithVexNotAffected_ExcludesNoVexFactor()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
await _service.RecordSbomMatchAsync(
|
||||
canonicalId,
|
||||
sbomDigest: "sha256:test123",
|
||||
purl: "pkg:npm/lodash@4.17.21");
|
||||
|
||||
await _service.RecordVexStatementAsync(
|
||||
canonicalId,
|
||||
new VexStatement
|
||||
{
|
||||
StatementId = "VEX-001",
|
||||
Status = VexStatus.NotAffected,
|
||||
Justification = "Not applicable"
|
||||
});
|
||||
|
||||
// Act
|
||||
var score = await _service.ComputeScoreAsync(canonicalId);
|
||||
|
||||
// Assert
|
||||
score.Score.Should().Be(0.30); // Only in_sbom, no no_vex_na
|
||||
score.Reasons.Should().Contain("in_sbom");
|
||||
score.Reasons.Should().NotContain("no_vex_na");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateScoreAsync Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScoreAsync_PersistsToPostgres()
|
||||
{
|
||||
// Arrange
|
||||
var score = new InterestScore
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
Score = 0.75,
|
||||
Reasons = ["in_sbom", "reachable", "deployed"],
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.UpdateScoreAsync(score);
|
||||
|
||||
// Assert - verify persisted to Postgres
|
||||
var retrieved = await _repository.GetByCanonicalIdAsync(score.CanonicalId);
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.Score.Should().Be(0.75);
|
||||
retrieved.Reasons.Should().BeEquivalentTo(["in_sbom", "reachable", "deployed"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScoreAsync_UpdatesCacheWhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var score = new InterestScore
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
Score = 0.85,
|
||||
Reasons = ["in_sbom"],
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.UpdateScoreAsync(score);
|
||||
|
||||
// Assert - verify cache was updated
|
||||
_cacheServiceMock.Verify(
|
||||
x => x.UpdateScoreAsync(
|
||||
score.CanonicalId.ToString(),
|
||||
0.85,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScoreAsync_UpsertsBehavior()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
var initialScore = new InterestScore
|
||||
{
|
||||
CanonicalId = canonicalId,
|
||||
Score = 0.30,
|
||||
Reasons = ["in_sbom"],
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _service.UpdateScoreAsync(initialScore);
|
||||
|
||||
var updatedScore = new InterestScore
|
||||
{
|
||||
CanonicalId = canonicalId,
|
||||
Score = 0.90,
|
||||
Reasons = ["in_sbom", "reachable", "deployed", "no_vex_na"],
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.UpdateScoreAsync(updatedScore);
|
||||
|
||||
// Assert
|
||||
var retrieved = await _repository.GetByCanonicalIdAsync(canonicalId);
|
||||
retrieved!.Score.Should().Be(0.90);
|
||||
retrieved.Reasons.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetScoreAsync Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetScoreAsync_ReturnsPersistedScore()
|
||||
{
|
||||
// Arrange
|
||||
var score = new InterestScore
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
Score = 0.65,
|
||||
Reasons = ["in_sbom", "deployed"],
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _repository.SaveAsync(score);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetScoreAsync(score.CanonicalId);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Score.Should().Be(0.65);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScoreAsync_ReturnsNullForNonExistent()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.GetScoreAsync(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BatchUpdateAsync Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateAsync_ComputesAndPersistsMultipleScores()
|
||||
{
|
||||
// Arrange
|
||||
var id1 = Guid.NewGuid();
|
||||
var id2 = Guid.NewGuid();
|
||||
var id3 = Guid.NewGuid();
|
||||
|
||||
// Setup signals for different scores
|
||||
await _service.RecordSbomMatchAsync(id1, "sha256:a", "pkg:npm/a@1.0.0");
|
||||
await _service.RecordSbomMatchAsync(id2, "sha256:b", "pkg:npm/b@1.0.0", isReachable: true);
|
||||
// id3 has no signals
|
||||
|
||||
// Act
|
||||
var updated = await _service.BatchUpdateAsync([id1, id2, id3]);
|
||||
|
||||
// Assert
|
||||
updated.Should().Be(3);
|
||||
|
||||
var score1 = await _repository.GetByCanonicalIdAsync(id1);
|
||||
var score2 = await _repository.GetByCanonicalIdAsync(id2);
|
||||
var score3 = await _repository.GetByCanonicalIdAsync(id3);
|
||||
|
||||
score1!.Score.Should().Be(0.45); // in_sbom + no_vex_na
|
||||
score2!.Score.Should().Be(0.70); // in_sbom + reachable + no_vex_na
|
||||
score3!.Score.Should().Be(0.15); // only no_vex_na
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateAsync_UpdatesCacheForEachScore()
|
||||
{
|
||||
// Arrange
|
||||
var id1 = Guid.NewGuid();
|
||||
var id2 = Guid.NewGuid();
|
||||
|
||||
await _service.RecordSbomMatchAsync(id1, "sha256:a", "pkg:npm/a@1.0.0");
|
||||
await _service.RecordSbomMatchAsync(id2, "sha256:b", "pkg:npm/b@1.0.0");
|
||||
|
||||
// Act
|
||||
await _service.BatchUpdateAsync([id1, id2]);
|
||||
|
||||
// Assert
|
||||
_cacheServiceMock.Verify(
|
||||
x => x.UpdateScoreAsync(id1.ToString(), It.IsAny<double>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
_cacheServiceMock.Verify(
|
||||
x => x.UpdateScoreAsync(id2.ToString(), It.IsAny<double>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetTopScoresAsync Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetTopScoresAsync_ReturnsScoresInDescendingOrder()
|
||||
{
|
||||
// Arrange
|
||||
var scores = new[]
|
||||
{
|
||||
CreateScore(0.3),
|
||||
CreateScore(0.9),
|
||||
CreateScore(0.5),
|
||||
CreateScore(0.7)
|
||||
};
|
||||
|
||||
foreach (var score in scores)
|
||||
{
|
||||
await _repository.SaveAsync(score);
|
||||
}
|
||||
|
||||
// Act
|
||||
var topScores = await _service.GetTopScoresAsync(limit: 10);
|
||||
|
||||
// Assert
|
||||
topScores.Should().HaveCount(4);
|
||||
topScores[0].Score.Should().Be(0.9);
|
||||
topScores[1].Score.Should().Be(0.7);
|
||||
topScores[2].Score.Should().Be(0.5);
|
||||
topScores[3].Score.Should().Be(0.3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetDistributionAsync Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetDistributionAsync_ReturnsCorrectDistribution()
|
||||
{
|
||||
// Arrange
|
||||
// High tier
|
||||
await _repository.SaveAsync(CreateScore(0.9));
|
||||
await _repository.SaveAsync(CreateScore(0.8));
|
||||
// Medium tier
|
||||
await _repository.SaveAsync(CreateScore(0.5));
|
||||
// Low tier
|
||||
await _repository.SaveAsync(CreateScore(0.3));
|
||||
// None tier
|
||||
await _repository.SaveAsync(CreateScore(0.1));
|
||||
|
||||
// Act
|
||||
var distribution = await _service.GetDistributionAsync();
|
||||
|
||||
// Assert
|
||||
distribution.TotalCount.Should().Be(5);
|
||||
distribution.HighCount.Should().Be(2);
|
||||
distribution.MediumCount.Should().Be(1);
|
||||
distribution.LowCount.Should().Be(1);
|
||||
distribution.NoneCount.Should().Be(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DegradeToStubsAsync Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DegradeToStubsAsync_DelegatesToAdvisoryStore()
|
||||
{
|
||||
// Arrange
|
||||
var oldDate = DateTimeOffset.UtcNow.AddDays(-60);
|
||||
var lowScore1 = CreateScore(0.1, oldDate);
|
||||
var lowScore2 = CreateScore(0.15, oldDate);
|
||||
var highScore = CreateScore(0.8, oldDate);
|
||||
|
||||
await _repository.SaveAsync(lowScore1);
|
||||
await _repository.SaveAsync(lowScore2);
|
||||
await _repository.SaveAsync(highScore);
|
||||
|
||||
_advisoryStoreMock
|
||||
.Setup(x => x.UpdateStatusAsync(It.IsAny<Guid>(), CanonicalStatus.Stub, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var degraded = await _service.DegradeToStubsAsync(0.2);
|
||||
|
||||
// Assert
|
||||
degraded.Should().Be(2);
|
||||
_advisoryStoreMock.Verify(
|
||||
x => x.UpdateStatusAsync(lowScore1.CanonicalId, CanonicalStatus.Stub, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
_advisoryStoreMock.Verify(
|
||||
x => x.UpdateStatusAsync(lowScore2.CanonicalId, CanonicalStatus.Stub, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DegradeToStubsAsync_RespectsMinAge()
|
||||
{
|
||||
// Arrange - one old, one recent
|
||||
var lowOld = CreateScore(0.1, DateTimeOffset.UtcNow.AddDays(-60));
|
||||
var lowRecent = CreateScore(0.1, DateTimeOffset.UtcNow.AddDays(-5));
|
||||
|
||||
await _repository.SaveAsync(lowOld);
|
||||
await _repository.SaveAsync(lowRecent);
|
||||
|
||||
_advisoryStoreMock
|
||||
.Setup(x => x.UpdateStatusAsync(It.IsAny<Guid>(), CanonicalStatus.Stub, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var degraded = await _service.DegradeToStubsAsync(0.2);
|
||||
|
||||
// Assert - only old one should be degraded
|
||||
degraded.Should().Be(1);
|
||||
_advisoryStoreMock.Verify(
|
||||
x => x.UpdateStatusAsync(lowOld.CanonicalId, CanonicalStatus.Stub, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
_advisoryStoreMock.Verify(
|
||||
x => x.UpdateStatusAsync(lowRecent.CanonicalId, CanonicalStatus.Stub, It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RestoreFromStubsAsync Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreFromStubsAsync_RestoresHighScoreStubs()
|
||||
{
|
||||
// Arrange
|
||||
var highScore = CreateScore(0.8);
|
||||
await _repository.SaveAsync(highScore);
|
||||
|
||||
var stubAdvisory = CreateMockCanonicalAdvisory(highScore.CanonicalId, CanonicalStatus.Stub);
|
||||
_advisoryStoreMock
|
||||
.Setup(x => x.GetByIdAsync(highScore.CanonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(stubAdvisory);
|
||||
_advisoryStoreMock
|
||||
.Setup(x => x.UpdateStatusAsync(highScore.CanonicalId, CanonicalStatus.Active, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var restored = await _service.RestoreFromStubsAsync(0.4);
|
||||
|
||||
// Assert
|
||||
restored.Should().Be(1);
|
||||
_advisoryStoreMock.Verify(
|
||||
x => x.UpdateStatusAsync(highScore.CanonicalId, CanonicalStatus.Active, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreFromStubsAsync_SkipsNonStubs()
|
||||
{
|
||||
// Arrange
|
||||
var highScore = CreateScore(0.8);
|
||||
await _repository.SaveAsync(highScore);
|
||||
|
||||
var activeAdvisory = CreateMockCanonicalAdvisory(highScore.CanonicalId, CanonicalStatus.Active);
|
||||
_advisoryStoreMock
|
||||
.Setup(x => x.GetByIdAsync(highScore.CanonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(activeAdvisory);
|
||||
|
||||
// Act
|
||||
var restored = await _service.RestoreFromStubsAsync(0.4);
|
||||
|
||||
// Assert - should not restore already active
|
||||
restored.Should().Be(0);
|
||||
_advisoryStoreMock.Verify(
|
||||
x => x.UpdateStatusAsync(It.IsAny<Guid>(), CanonicalStatus.Active, It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Full Flow Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FullFlow_RecordSignals_ComputeScore_PersistAndCache()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
|
||||
// Act 1: Record SBOM match
|
||||
await _service.RecordSbomMatchAsync(
|
||||
canonicalId,
|
||||
sbomDigest: "sha256:prod123",
|
||||
purl: "pkg:npm/express@4.18.0",
|
||||
isReachable: true,
|
||||
isDeployed: true);
|
||||
|
||||
// Act 2: Compute score
|
||||
var computedScore = await _service.ComputeScoreAsync(canonicalId);
|
||||
|
||||
// Act 3: Persist score
|
||||
await _service.UpdateScoreAsync(computedScore);
|
||||
|
||||
// Assert: Verify in database
|
||||
var dbScore = await _repository.GetByCanonicalIdAsync(canonicalId);
|
||||
dbScore.Should().NotBeNull();
|
||||
dbScore!.Score.Should().Be(0.90);
|
||||
dbScore.Reasons.Should().Contain("in_sbom");
|
||||
dbScore.Reasons.Should().Contain("reachable");
|
||||
dbScore.Reasons.Should().Contain("deployed");
|
||||
dbScore.Reasons.Should().Contain("no_vex_na");
|
||||
|
||||
// Assert: Verify cache was updated
|
||||
_cacheServiceMock.Verify(
|
||||
x => x.UpdateScoreAsync(canonicalId.ToString(), 0.90, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
|
||||
// Act 4: Retrieve via service
|
||||
var retrievedScore = await _service.GetScoreAsync(canonicalId);
|
||||
retrievedScore.Should().NotBeNull();
|
||||
retrievedScore!.Score.Should().Be(0.90);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullFlow_VexStatementReducesScore()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = Guid.NewGuid();
|
||||
|
||||
// Record signals with high score potential
|
||||
await _service.RecordSbomMatchAsync(
|
||||
canonicalId,
|
||||
sbomDigest: "sha256:prod123",
|
||||
purl: "pkg:npm/express@4.18.0",
|
||||
isReachable: true,
|
||||
isDeployed: true);
|
||||
|
||||
// Compute initial score
|
||||
var initialScore = await _service.ComputeScoreAsync(canonicalId);
|
||||
initialScore.Score.Should().Be(0.90);
|
||||
|
||||
// Act: Add VEX not_affected statement
|
||||
await _service.RecordVexStatementAsync(
|
||||
canonicalId,
|
||||
new VexStatement
|
||||
{
|
||||
StatementId = "VEX-123",
|
||||
Status = VexStatus.NotAffected,
|
||||
Justification = "Component not used in production context"
|
||||
});
|
||||
|
||||
// Recompute score
|
||||
var reducedScore = await _service.ComputeScoreAsync(canonicalId);
|
||||
|
||||
// Assert: Score should be reduced (no no_vex_na factor)
|
||||
reducedScore.Score.Should().Be(0.75);
|
||||
reducedScore.Reasons.Should().NotContain("no_vex_na");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cache Disabled Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScoreAsync_SkipsCacheWhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var optionsWithCacheDisabled = new InterestScoreOptions { EnableCache = false };
|
||||
var serviceWithCacheDisabled = new InterestScoringService(
|
||||
_repository,
|
||||
_calculator,
|
||||
Options.Create(optionsWithCacheDisabled),
|
||||
_advisoryStoreMock.Object,
|
||||
_cacheServiceMock.Object,
|
||||
NullLogger<InterestScoringService>.Instance);
|
||||
|
||||
var score = new InterestScore
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
Score = 0.75,
|
||||
Reasons = ["in_sbom"],
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
await serviceWithCacheDisabled.UpdateScoreAsync(score);
|
||||
|
||||
// Assert - cache should not be called
|
||||
_cacheServiceMock.Verify(
|
||||
x => x.UpdateScoreAsync(It.IsAny<string>(), It.IsAny<double>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
|
||||
// But database should still be updated
|
||||
var retrieved = await _repository.GetByCanonicalIdAsync(score.CanonicalId);
|
||||
retrieved.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static InterestScore CreateScore(double score, DateTimeOffset? computedAt = null)
|
||||
{
|
||||
return new InterestScore
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
Score = score,
|
||||
Reasons = score >= 0.7 ? ["in_sbom", "reachable", "deployed"] : ["no_vex_na"],
|
||||
ComputedAt = computedAt ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static CanonicalAdvisory CreateMockCanonicalAdvisory(Guid id, CanonicalStatus status)
|
||||
{
|
||||
return new CanonicalAdvisory
|
||||
{
|
||||
Id = id,
|
||||
MergeHash = $"sha256:{id:N}",
|
||||
Cve = $"CVE-2024-{id.ToString("N")[..5]}",
|
||||
AffectsKey = $"pkg:npm/test@1.0.0",
|
||||
Status = status,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Opt-out of shared test infra - this project has its own ConcelierPostgresFixture -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
@@ -20,7 +22,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Storage.Postgres\StellaOps.Concelier.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InterestScoreEndpointTests.cs
|
||||
// Sprint: SPRINT_8200_0013_0002_CONCEL_interest_scoring
|
||||
// Task: ISCORE-8200-032
|
||||
// Description: End-to-end tests for interest score API endpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Interest;
|
||||
using StellaOps.Concelier.Interest.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests for interest score endpoints.
|
||||
/// Tests the complete flow: ingest advisory, update SBOM, verify score change.
|
||||
/// </summary>
|
||||
public sealed class InterestScoreEndpointTests : IClassFixture<InterestScoreEndpointTests.InterestScoreTestFactory>
|
||||
{
|
||||
private readonly InterestScoreTestFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public InterestScoreEndpointTests(InterestScoreTestFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
#region Task 32: E2E Test - Ingest Advisory, Update SBOM, Verify Score Change
|
||||
|
||||
[Fact]
|
||||
public async Task GetInterestScore_ReturnsNotFound_WhenScoreDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistentId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/canonical/{nonExistentId}/score");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInterestScore_ReturnsScore_WhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = _factory.ExistingCanonicalId;
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/canonical/{canonicalId}/score");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<InterestScoreResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.CanonicalId.Should().Be(canonicalId);
|
||||
result.Score.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeInterestScore_ComputesAndPersistsScore()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalId = _factory.ComputeCanonicalId;
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync(
|
||||
$"/api/v1/canonical/{canonicalId}/score/compute",
|
||||
null);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<InterestScoreResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.CanonicalId.Should().Be(canonicalId);
|
||||
result.Score.Should().BeGreaterThanOrEqualTo(0);
|
||||
result.ComputedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryInterestScores_ReturnsFilteredResults()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v1/scores?minScore=0.3&maxScore=0.9&limit=10");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<InterestScoreListResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.Items.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScoreDistribution_ReturnsStatistics()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v1/scores/distribution");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<ScoreDistributionResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.TotalCount.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecalculateScores_AcceptsBatchRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new RecalculateRequest
|
||||
{
|
||||
CanonicalIds = [Guid.NewGuid(), Guid.NewGuid()]
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scores/recalculate", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Accepted);
|
||||
var result = await response.Content.ReadFromJsonAsync<RecalculateResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.Mode.Should().Be("batch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecalculateScores_AcceptsFullRequest()
|
||||
{
|
||||
// Arrange - empty body triggers full recalculation
|
||||
var request = new RecalculateRequest();
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scores/recalculate", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Accepted);
|
||||
var result = await response.Content.ReadFromJsonAsync<RecalculateResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.Mode.Should().Be("full");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DegradeToStubs_ExecutesDegradation()
|
||||
{
|
||||
// Arrange
|
||||
var request = new DegradeRequest { Threshold = 0.2 };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scores/degrade", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<DegradeResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.Threshold.Should().Be(0.2);
|
||||
result.Degraded.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreFromStubs_ExecutesRestoration()
|
||||
{
|
||||
// Arrange
|
||||
var request = new RestoreRequest { Threshold = 0.4 };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scores/restore", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<RestoreResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.Threshold.Should().Be(0.4);
|
||||
result.Restored.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task E2E_IngestAdvisoryUpdateSbomVerifyScoreChange()
|
||||
{
|
||||
// This tests the full workflow:
|
||||
// 1. Advisory exists with no SBOM match → low score
|
||||
// 2. Record SBOM match → score increases
|
||||
// 3. Record reachability → score increases further
|
||||
|
||||
var canonicalId = _factory.E2ECanonicalId;
|
||||
|
||||
// Step 1: Compute initial score (no SBOM matches)
|
||||
var computeResponse = await _client.PostAsync(
|
||||
$"/api/v1/canonical/{canonicalId}/score/compute", null);
|
||||
computeResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var initialScore = await computeResponse.Content.ReadFromJsonAsync<InterestScoreResponse>();
|
||||
initialScore.Should().NotBeNull();
|
||||
var initialValue = initialScore!.Score;
|
||||
|
||||
// Step 2: Record SBOM match via service (simulated by mock)
|
||||
// The mock is set up to include SBOM signals for this ID
|
||||
_factory.AddSbomMatchForCanonical(canonicalId);
|
||||
|
||||
// Recompute score
|
||||
computeResponse = await _client.PostAsync(
|
||||
$"/api/v1/canonical/{canonicalId}/score/compute", null);
|
||||
var updatedScore = await computeResponse.Content.ReadFromJsonAsync<InterestScoreResponse>();
|
||||
|
||||
// Step 3: Verify score increased
|
||||
updatedScore.Should().NotBeNull();
|
||||
updatedScore!.Reasons.Should().Contain("in_sbom");
|
||||
// Score should be higher after SBOM match
|
||||
updatedScore.Score.Should().BeGreaterThanOrEqualTo(initialValue);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response DTOs (matching endpoint responses)
|
||||
|
||||
public record InterestScoreResponse
|
||||
{
|
||||
public Guid CanonicalId { get; init; }
|
||||
public double Score { get; init; }
|
||||
public string Tier { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string> Reasons { get; init; } = [];
|
||||
public Guid? LastSeenInBuild { get; init; }
|
||||
public DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
public record InterestScoreListResponse
|
||||
{
|
||||
public IReadOnlyList<InterestScoreResponse> Items { get; init; } = [];
|
||||
public int TotalCount { get; init; }
|
||||
public int Offset { get; init; }
|
||||
public int Limit { get; init; }
|
||||
}
|
||||
|
||||
public record ScoreDistributionResponse
|
||||
{
|
||||
public long HighCount { get; init; }
|
||||
public long MediumCount { get; init; }
|
||||
public long LowCount { get; init; }
|
||||
public long NoneCount { get; init; }
|
||||
public long TotalCount { get; init; }
|
||||
public double AverageScore { get; init; }
|
||||
public double MedianScore { get; init; }
|
||||
}
|
||||
|
||||
public record RecalculateRequest
|
||||
{
|
||||
public IReadOnlyList<Guid>? CanonicalIds { get; init; }
|
||||
}
|
||||
|
||||
public record RecalculateResponse
|
||||
{
|
||||
public int Updated { get; init; }
|
||||
public string Mode { get; init; } = string.Empty;
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
}
|
||||
|
||||
public record DegradeRequest
|
||||
{
|
||||
public double? Threshold { get; init; }
|
||||
}
|
||||
|
||||
public record DegradeResponse
|
||||
{
|
||||
public int Degraded { get; init; }
|
||||
public double Threshold { get; init; }
|
||||
public DateTimeOffset ExecutedAt { get; init; }
|
||||
}
|
||||
|
||||
public record RestoreRequest
|
||||
{
|
||||
public double? Threshold { get; init; }
|
||||
}
|
||||
|
||||
public record RestoreResponse
|
||||
{
|
||||
public int Restored { get; init; }
|
||||
public double Threshold { get; init; }
|
||||
public DateTimeOffset ExecutedAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Factory
|
||||
|
||||
/// <summary>
|
||||
/// Test factory that sets up mocked dependencies for interest score testing.
|
||||
/// </summary>
|
||||
public sealed class InterestScoreTestFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
public Guid ExistingCanonicalId { get; } = Guid.NewGuid();
|
||||
public Guid ComputeCanonicalId { get; } = Guid.NewGuid();
|
||||
public Guid E2ECanonicalId { get; } = Guid.NewGuid();
|
||||
|
||||
private readonly Dictionary<Guid, List<SbomMatch>> _sbomMatches = new();
|
||||
|
||||
public void AddSbomMatchForCanonical(Guid canonicalId)
|
||||
{
|
||||
if (!_sbomMatches.ContainsKey(canonicalId))
|
||||
{
|
||||
_sbomMatches[canonicalId] = [];
|
||||
}
|
||||
_sbomMatches[canonicalId].Add(new SbomMatch
|
||||
{
|
||||
SbomDigest = "sha256:test123",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
IsReachable = true,
|
||||
ScannedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__DSN", "Host=localhost;Port=5432;Database=test-interest");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__DRIVER", "postgres");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove existing registrations
|
||||
var scoringServiceDescriptor = services
|
||||
.SingleOrDefault(d => d.ServiceType == typeof(IInterestScoringService));
|
||||
if (scoringServiceDescriptor != null)
|
||||
{
|
||||
services.Remove(scoringServiceDescriptor);
|
||||
}
|
||||
|
||||
var repositoryDescriptor = services
|
||||
.SingleOrDefault(d => d.ServiceType == typeof(IInterestScoreRepository));
|
||||
if (repositoryDescriptor != null)
|
||||
{
|
||||
services.Remove(repositoryDescriptor);
|
||||
}
|
||||
|
||||
// Create mock repository
|
||||
var mockRepository = new Mock<IInterestScoreRepository>();
|
||||
|
||||
// Set up existing score
|
||||
var existingScore = new InterestScore
|
||||
{
|
||||
CanonicalId = ExistingCanonicalId,
|
||||
Score = 0.75,
|
||||
Reasons = ["in_sbom", "reachable"],
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
mockRepository
|
||||
.Setup(r => r.GetByCanonicalIdAsync(ExistingCanonicalId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existingScore);
|
||||
|
||||
mockRepository
|
||||
.Setup(r => r.GetByCanonicalIdAsync(It.Is<Guid>(g => g != ExistingCanonicalId), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((InterestScore?)null);
|
||||
|
||||
mockRepository
|
||||
.Setup(r => r.GetAllAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<InterestScore> { existingScore });
|
||||
|
||||
mockRepository
|
||||
.Setup(r => r.GetScoreDistributionAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ScoreDistribution
|
||||
{
|
||||
TotalCount = 100,
|
||||
HighCount = 25,
|
||||
MediumCount = 35,
|
||||
LowCount = 25,
|
||||
NoneCount = 15,
|
||||
AverageScore = 0.52,
|
||||
MedianScore = 0.48
|
||||
});
|
||||
|
||||
mockRepository
|
||||
.Setup(r => r.GetLowScoreCanonicalIdsAsync(
|
||||
It.IsAny<double>(), It.IsAny<TimeSpan>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Guid>());
|
||||
|
||||
mockRepository
|
||||
.Setup(r => r.GetHighScoreCanonicalIdsAsync(
|
||||
It.IsAny<double>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Guid>());
|
||||
|
||||
services.AddSingleton(mockRepository.Object);
|
||||
|
||||
// Add scoring service with mock repository
|
||||
var options = Options.Create(new InterestScoreOptions
|
||||
{
|
||||
EnableCache = false,
|
||||
DegradationPolicy = new DegradationPolicyOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DegradationThreshold = 0.2,
|
||||
RestorationThreshold = 0.4,
|
||||
MinAgeDays = 30,
|
||||
BatchSize = 100
|
||||
},
|
||||
Job = new InterestScoreJobOptions
|
||||
{
|
||||
Enabled = false
|
||||
}
|
||||
});
|
||||
|
||||
var calculator = new InterestScoreCalculator(new InterestScoreWeights());
|
||||
|
||||
services.AddSingleton<IInterestScoringService>(sp =>
|
||||
new InterestScoringService(
|
||||
mockRepository.Object,
|
||||
calculator,
|
||||
options));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Update="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Interest/StellaOps.Concelier.Interest.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user