173 lines
8.6 KiB
C#
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));
|
|
}
|
|
}
|