Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/SmartDiffEndpointsTests.cs
2026-02-12 10:27:23 +02:00

173 lines
8.6 KiB
C#

using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.SmartDiff.Detection;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
[Trait("Category", TestCategories.Integration)]
public sealed class SmartDiffEndpointsTests
{
private const string BasePath = "/api/v1/smart-diff";
private static readonly DateTimeOffset FixedNow = new(2026, 2, 12, 8, 0, 0, TimeSpan.Zero);
[Fact]
public async Task GetScanScopedVexCandidates_ReturnsCandidatesForResolvedTargetDigest()
{
var imageDigest = "sha256:target-123";
var candidate = CreateCandidate("candidate-1", imageDigest, requiresReview: true);
var candidateStore = new InMemoryVexCandidateStore();
await candidateStore.StoreCandidatesAsync([candidate], TestContext.Current.CancellationToken);
var metadataRepository = new StubScanMetadataRepository();
metadataRepository.Set("scan-1", new ScanMetadata(null, imageDigest, FixedNow));
await using var factory = CreateFactory(candidateStore, metadataRepository);
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-1/vex-candidates", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken), cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(imageDigest, payload.RootElement.GetProperty("imageDigest").GetString());
Assert.Equal(1, payload.RootElement.GetProperty("totalCandidates").GetInt32());
var first = payload.RootElement.GetProperty("candidates")[0];
Assert.Equal("candidate-1", first.GetProperty("candidateId").GetString());
Assert.Equal("CVE-2026-0001", first.GetProperty("vulnId").GetString());
Assert.Equal("vulnerable_code_not_in_execute_path", first.GetProperty("justification").GetString());
Assert.True(first.GetProperty("requiresReview").GetBoolean());
Assert.Equal("delta", first.GetProperty("evidenceLinks")[0].GetProperty("type").GetString());
}
[Fact]
public async Task ReviewScanScopedCandidate_UpdatesCandidateReviewState()
{
var imageDigest = "sha256:target-456";
var candidateStore = new InMemoryVexCandidateStore();
await candidateStore.StoreCandidatesAsync(
[CreateCandidate("candidate-2", imageDigest, requiresReview: true)],
TestContext.Current.CancellationToken);
var metadataRepository = new StubScanMetadataRepository();
metadataRepository.Set("scan-2", new ScanMetadata(null, imageDigest, FixedNow));
await using var factory = CreateFactory(candidateStore, metadataRepository);
await factory.InitializeAsync();
using var client = factory.CreateClient();
var reviewResponse = await client.PostAsJsonAsync(
$"{BasePath}/scan-2/vex-candidates/review",
new ScanReviewRequestDto("candidate-2", "accept", "verified"),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, reviewResponse.StatusCode);
var candidateResponse = await client.GetAsync($"{BasePath}/candidates/candidate-2", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, candidateResponse.StatusCode);
using var payload = await JsonDocument.ParseAsync(await candidateResponse.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken), cancellationToken: TestContext.Current.CancellationToken);
var candidate = payload.RootElement.GetProperty("candidate");
Assert.False(candidate.GetProperty("requiresReview").GetBoolean());
}
[Fact]
public async Task GetScanSarif_EmbedsVexCandidateResults()
{
var imageDigest = "sha256:target-789";
var candidateStore = new InMemoryVexCandidateStore();
await candidateStore.StoreCandidatesAsync(
[CreateCandidate("candidate-3", imageDigest, requiresReview: false)],
TestContext.Current.CancellationToken);
var metadataRepository = new StubScanMetadataRepository();
metadataRepository.Set("scan-3", new ScanMetadata("sha256:base-111", imageDigest, FixedNow));
await using var factory = CreateFactory(candidateStore, metadataRepository);
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scans/scan-3/sarif", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Contains("\"ruleId\":\"SDIFF003\"", body, StringComparison.Ordinal);
Assert.Contains("CVE-2026-0001", body, StringComparison.Ordinal);
Assert.Contains("pkg:npm/test-component@1.0.0", body, StringComparison.Ordinal);
}
private static ScannerApplicationFactory CreateFactory(
IVexCandidateStore candidateStore,
IScanMetadataRepository metadataRepository)
{
return ScannerApplicationFactory.CreateLightweight()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexCandidateStore>();
services.RemoveAll<IMaterialRiskChangeRepository>();
services.RemoveAll<IScanMetadataRepository>();
services.AddSingleton(candidateStore);
services.AddSingleton<IMaterialRiskChangeRepository, StubMaterialRiskChangeRepository>();
services.AddSingleton(metadataRepository);
});
}
private static VexCandidate CreateCandidate(string candidateId, string imageDigest, bool requiresReview)
{
return new VexCandidate(
CandidateId: candidateId,
FindingKey: new FindingKey("CVE-2026-0001", "pkg:npm/test-component@1.0.0"),
SuggestedStatus: VexStatusType.NotAffected,
Justification: VexJustification.VulnerableCodeNotInExecutePath,
Rationale: "Reachability evidence shows vulnerable APIs are not reachable.",
EvidenceLinks: ImmutableArray.Create(new EvidenceLink("delta", "oci://scanner/delta/scan-1", "sha256:evidence")),
Confidence: 0.97,
ImageDigest: imageDigest,
GeneratedAt: FixedNow,
ExpiresAt: FixedNow.AddDays(30),
RequiresReview: requiresReview);
}
private sealed record ScanReviewRequestDto(string CandidateId, string Action, string? Comment);
private sealed class StubScanMetadataRepository : IScanMetadataRepository
{
private readonly Dictionary<string, ScanMetadata> _metadataByScanId = new(StringComparer.OrdinalIgnoreCase);
public void Set(string scanId, ScanMetadata metadata)
{
_metadataByScanId[scanId] = metadata;
}
public Task<ScanMetadata?> GetScanMetadataAsync(string scanId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_metadataByScanId.TryGetValue(scanId, out var metadata) ? metadata : null);
}
}
private sealed class StubMaterialRiskChangeRepository : IMaterialRiskChangeRepository
{
public Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default) => Task.CompletedTask;
public Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default) => Task.CompletedTask;
public Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<MaterialRiskChangeResult>>(Array.Empty<MaterialRiskChangeResult>());
public Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForFindingAsync(FindingKey findingKey, int limit = 10, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<MaterialRiskChangeResult>>(Array.Empty<MaterialRiskChangeResult>());
public Task<MaterialRiskChangeQueryResult> QueryChangesAsync(MaterialRiskChangeQuery query, CancellationToken ct = default) =>
Task.FromResult(new MaterialRiskChangeQueryResult(ImmutableArray<MaterialRiskChangeResult>.Empty, 0, query.Offset, query.Limit));
}
}