consolidate the tests locations

This commit is contained in:
StellaOps Bot
2025-12-26 01:48:24 +02:00
parent 17613acf57
commit 39359da171
2031 changed files with 2607 additions and 476 deletions

View File

@@ -0,0 +1,218 @@
# StellaOps.Concelier.Interest
Interest scoring service for canonical advisories. This module learns which advisories matter to your organization by analyzing SBOM intersections, reachability data, VEX statements, and runtime signals.
## Overview
Interest scoring helps prioritize advisories by computing a relevance score (0.0 to 1.0) based on:
- **SBOM Intersection** (30%): Advisory affects packages in your SBOMs
- **Reachability** (25%): Vulnerable code is reachable from application entrypoints
- **Deployment** (20%): Affected component is deployed in production
- **VEX Status** (15%): No `not_affected` VEX statement exists
- **Recency** (10%): How recently the advisory was seen in builds (decays over 365 days)
## Key Features
### Score Tiers
| Tier | Score Range | Description |
|------|-------------|-------------|
| **High** | ≥ 0.7 | Urgent attention required |
| **Medium** | 0.4 - 0.7 | Should be reviewed |
| **Low** | 0.2 - 0.4 | Lower priority |
| **None** | < 0.2 | Can be ignored or degraded to stub |
### Stub Degradation
Low-interest advisories (score < 0.2) can be automatically degraded to lightweight stubs:
- Only essential fields retained (ID, CVE, severity, title)
- Full details discarded to save storage
- Stubs auto-restore when interest score increases above threshold (0.4)
## Usage
### Computing Scores
```csharp
// Inject the service
var scoringService = serviceProvider.GetRequiredService<IInterestScoringService>();
// Compute score for a canonical advisory
var score = await scoringService.ComputeScoreAsync(canonicalId);
// Or compute from explicit signals
var input = new InterestScoreInput
{
CanonicalId = canonicalId,
SbomMatches = [
new SbomMatch
{
SbomDigest = "sha256:...",
Purl = "pkg:npm/lodash@4.17.21",
IsReachable = true,
IsDeployed = false
}
],
VexStatements = []
};
var score = await scoringService.ComputeScoreAsync(input);
```
### Recording Signals
```csharp
// Record an SBOM match
await scoringService.RecordSbomMatchAsync(
canonicalId,
sbomDigest: "sha256:abc123",
purl: "pkg:npm/lodash@4.17.21",
isReachable: true,
isDeployed: false);
// Record a VEX statement
await scoringService.RecordVexStatementAsync(canonicalId, new VexStatement
{
StatementId = "VEX-2025-001",
Status = VexStatus.NotAffected,
Justification = "Component not used in production"
});
```
### Batch Operations
```csharp
// Update scores for specific canonicals
await scoringService.BatchUpdateAsync(canonicalIds);
// Full recalculation (all active advisories)
await scoringService.RecalculateAllAsync();
```
### Degradation/Restoration
```csharp
// Degrade low-interest advisories to stubs
int degraded = await scoringService.DegradeToStubsAsync(threshold: 0.2);
// Restore stubs when interest increases
int restored = await scoringService.RestoreFromStubsAsync(threshold: 0.4);
```
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/canonical/{id}/score` | GET | Get interest score for a canonical |
| `/api/v1/canonical/{id}/score/compute` | POST | Compute and update score |
| `/api/v1/scores` | GET | Query scores with filtering |
| `/api/v1/scores/distribution` | GET | Get score distribution statistics |
| `/api/v1/scores/recalculate` | POST | Trigger batch/full recalculation |
| `/api/v1/scores/degrade` | POST | Run stub degradation |
| `/api/v1/scores/restore` | POST | Run stub restoration |
### Example API Response
```json
{
"canonicalId": "550e8400-e29b-41d4-a716-446655440000",
"score": 0.75,
"tier": "High",
"reasons": ["in_sbom", "reachable", "deployed"],
"lastSeenInBuild": "b5d2c400-e29b-41d4-a716-446655440000",
"computedAt": "2025-12-26T10:30:00Z"
}
```
## Configuration
```json
{
"InterestScore": {
"EnableCache": true,
"DegradationPolicy": {
"Enabled": true,
"DegradationThreshold": 0.2,
"RestorationThreshold": 0.4,
"MinAgeDays": 30,
"BatchSize": 1000,
"JobInterval": "06:00:00"
},
"Job": {
"Enabled": true,
"Interval": "01:00:00",
"FullRecalculationHour": 3,
"FullRecalculationBatchSize": 1000
},
"Weights": {
"InSbom": 0.30,
"Reachable": 0.25,
"Deployed": 0.20,
"NoVexNotAffected": 0.15,
"Recent": 0.10
}
}
}
```
## Background Jobs
### InterestScoreRecalculationJob
Runs periodically to keep scores up-to-date:
- **Incremental mode** (hourly): Updates scores for recently changed advisories
- **Full mode** (nightly at 3 AM UTC): Recalculates all active advisories
### StubDegradationJob
Runs periodically (default: every 6 hours) to:
1. Degrade advisories with scores below threshold
2. Restore stubs whose scores have increased
## Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `concelier_interest_score_computed_total` | Counter | Total scores computed |
| `concelier_interest_score_distribution` | Histogram | Score value distribution |
| `concelier_stub_degradations_total` | Counter | Total stub degradations |
| `concelier_stub_restorations_total` | Counter | Total stub restorations |
| `concelier_scoring_job_duration_seconds` | Histogram | Job execution time |
| `concelier_scoring_job_errors_total` | Counter | Job execution errors |
## Database Schema
```sql
CREATE TABLE vuln.interest_score (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
canonical_id UUID NOT NULL REFERENCES vuln.advisory_canonical(id),
score NUMERIC(3,2) NOT NULL CHECK (score >= 0 AND score <= 1),
reasons JSONB NOT NULL DEFAULT '[]',
last_seen_in_build UUID,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_interest_score_canonical UNIQUE (canonical_id)
);
-- Indexes for common queries
CREATE INDEX idx_interest_score_score ON vuln.interest_score(score DESC);
CREATE INDEX idx_interest_score_computed ON vuln.interest_score(computed_at DESC);
-- Partial indexes for degradation queries
CREATE INDEX idx_interest_score_high ON vuln.interest_score(canonical_id) WHERE score >= 0.7;
CREATE INDEX idx_interest_score_low ON vuln.interest_score(canonical_id) WHERE score < 0.2;
```
## Testing
Run tests with:
```bash
dotnet test src/Concelier/__Tests/StellaOps.Concelier.Interest.Tests/
dotnet test src/Concelier/__Tests/StellaOps.Concelier.Storage.Postgres.Tests/ --filter "InterestScore"
```
## Sprint Reference
- Sprint: `SPRINT_8200_0013_0002_CONCEL_interest_scoring`
- Tasks: ISCORE-8200-000 through ISCORE-8200-033

View File

@@ -1,19 +0,0 @@
using System.Reflection;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Testing;
using Xunit;
namespace StellaOps.Concelier.Testing;
/// <summary>
/// PostgreSQL integration test fixture for the Concelier module.
/// Runs migrations from embedded resources and provides test isolation via schema truncation.
/// </summary>
public sealed class ConcelierPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<ConcelierPostgresFixture>
{
protected override Assembly? GetMigrationAssembly()
=> typeof(ConcelierDataSource).Assembly;
protected override string GetModuleName() => "Concelier";
}

View File

@@ -1,121 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Concelier.Testing;
/// <summary>
/// Provides a reusable container for connector integration tests with canned HTTP responses and PostgreSQL-backed storage.
/// </summary>
public sealed class ConnectorTestHarness : IAsyncDisposable
{
private readonly ConcelierPostgresFixture _fixture;
private readonly DateTimeOffset _initialTime;
private readonly string[] _httpClientNames;
private ServiceProvider? _serviceProvider;
public ConnectorTestHarness(ConcelierPostgresFixture fixture, DateTimeOffset initialTime, params string[] httpClientNames)
{
_fixture = fixture ?? throw new ArgumentNullException(nameof(fixture));
_initialTime = initialTime;
_httpClientNames = httpClientNames.Length == 0
? Array.Empty<string>()
: httpClientNames.Distinct(StringComparer.Ordinal).ToArray();
TimeProvider = CreateTimeProvider(initialTime);
Handler = new CannedHttpMessageHandler();
}
public FakeTimeProvider TimeProvider { get; private set; }
public CannedHttpMessageHandler Handler { get; }
public ServiceProvider ServiceProvider => _serviceProvider ?? throw new InvalidOperationException("Call EnsureServiceProviderAsync first.");
public async Task<ServiceProvider> EnsureServiceProviderAsync(Action<IServiceCollection> configureServices)
{
ArgumentNullException.ThrowIfNull(configureServices);
if (_serviceProvider is not null)
{
return _serviceProvider;
}
await _fixture.TruncateAllTablesAsync(CancellationToken.None);
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(TimeProvider);
services.AddSingleton(TimeProvider);
services.AddSingleton(Handler);
services.AddConcelierPostgresStorage(options =>
{
options.ConnectionString = _fixture.ConnectionString;
options.SchemaName = _fixture.SchemaName;
options.CommandTimeoutSeconds = 5;
});
services.AddSourceCommon();
configureServices(services);
foreach (var clientName in _httpClientNames)
{
services.Configure<HttpClientFactoryOptions>(clientName, options =>
{
options.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = Handler;
});
});
}
var provider = services.BuildServiceProvider();
_serviceProvider = provider;
return provider;
}
public async Task ResetAsync()
{
if (_serviceProvider is { } provider)
{
if (provider is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
provider.Dispose();
}
_serviceProvider = null;
}
await _fixture.TruncateAllTablesAsync(CancellationToken.None);
Handler.Clear();
TimeProvider = CreateTimeProvider(_initialTime);
}
public async ValueTask DisposeAsync()
{
await ResetAsync();
}
private static FakeTimeProvider CreateTimeProvider(DateTimeOffset now)
=> new(now)
{
AutoAdvanceAmount = TimeSpan.Zero,
};
}

View File

@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<IsTestProject>false</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<PackageReference Include="xunit" Version="2.9.3">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="../StellaOps.Concelier.Storage.Postgres/StellaOps.Concelier.Storage.Postgres.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj" />
</ItemGroup>
</Project>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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\**\*">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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)" />

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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" />