This commit is contained in:
master
2026-01-07 10:25:34 +02:00
726 changed files with 147397 additions and 1364 deletions

View File

@@ -125,7 +125,7 @@ public sealed class GoLanguageAnalyzerTests
await LanguageAnalyzerTestHarness.RunToJsonAsync(
fixturePath,
analyzers,
cancellationToken: cancellationToken).ConfigureAwait(false);
cancellationToken: cancellationToken);
listener.Dispose();

View File

@@ -0,0 +1,266 @@
// <copyright file="ScannerConfigDiffTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
// Task: CCUT-022
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TestKit;
using StellaOps.Testing.ConfigDiff;
using Xunit;
namespace StellaOps.Scanner.ConfigDiff.Tests;
/// <summary>
/// Config-diff tests for the Scanner module.
/// Verifies that configuration changes produce only expected behavioral deltas.
/// </summary>
[Trait("Category", TestCategories.ConfigDiff)]
[Trait("Category", TestCategories.Integration)]
[Trait("BlastRadius", TestCategories.BlastRadius.Scanning)]
public class ScannerConfigDiffTests : ConfigDiffTestBase
{
/// <summary>
/// Initializes a new instance of the <see cref="ScannerConfigDiffTests"/> class.
/// </summary>
public ScannerConfigDiffTests()
: base(
new ConfigDiffTestConfig(StrictMode: true),
NullLogger.Instance)
{
}
/// <summary>
/// Verifies that changing scan depth only affects traversal behavior.
/// </summary>
[Fact]
public async Task ChangingScanDepth_OnlyAffectsTraversal()
{
// Arrange
var baselineConfig = new ScannerTestConfig
{
MaxScanDepth = 10,
EnableReachabilityAnalysis = true,
MaxConcurrentAnalyzers = 4
};
var changedConfig = baselineConfig with
{
MaxScanDepth = 20
};
// Act
var result = await TestConfigIsolationAsync(
baselineConfig,
changedConfig,
changedSetting: "MaxScanDepth",
unrelatedBehaviors:
[
async config => await GetReachabilityBehaviorAsync(config),
async config => await GetConcurrencyBehaviorAsync(config),
async config => await GetOutputFormatBehaviorAsync(config)
]);
// Assert
result.IsSuccess.Should().BeTrue(
because: "changing scan depth should not affect reachability or concurrency");
}
/// <summary>
/// Verifies that enabling reachability analysis produces expected delta.
/// </summary>
[Fact]
public async Task EnablingReachability_ProducesExpectedDelta()
{
// Arrange
var baselineConfig = new ScannerTestConfig { EnableReachabilityAnalysis = false };
var changedConfig = new ScannerTestConfig { EnableReachabilityAnalysis = true };
var expectedDelta = new ConfigDelta(
ChangedBehaviors: ["ReachabilityMode", "ScanDuration", "OutputDetail"],
BehaviorDeltas:
[
new BehaviorDelta("ReachabilityMode", "disabled", "enabled", null),
new BehaviorDelta("ScanDuration", "increase", null,
"Reachability analysis adds processing time"),
new BehaviorDelta("OutputDetail", "basic", "enhanced",
"Reachability data added to findings")
]);
// Act
var result = await TestConfigBehavioralDeltaAsync(
baselineConfig,
changedConfig,
getBehavior: async config => await CaptureReachabilityBehaviorAsync(config),
computeDelta: ComputeBehaviorSnapshotDelta,
expectedDelta: expectedDelta);
// Assert
result.IsSuccess.Should().BeTrue(
because: "enabling reachability should produce expected behavioral delta");
}
/// <summary>
/// Verifies that changing SBOM format only affects output.
/// </summary>
[Fact]
public async Task ChangingSbomFormat_OnlyAffectsOutput()
{
// Arrange
var baselineConfig = new ScannerTestConfig { SbomFormat = "spdx-3.0" };
var changedConfig = new ScannerTestConfig { SbomFormat = "cyclonedx-1.7" };
// Act
var result = await TestConfigIsolationAsync(
baselineConfig,
changedConfig,
changedSetting: "SbomFormat",
unrelatedBehaviors:
[
async config => await GetScanningBehaviorAsync(config),
async config => await GetVulnMatchingBehaviorAsync(config),
async config => await GetReachabilityBehaviorAsync(config)
]);
// Assert
result.IsSuccess.Should().BeTrue(
because: "SBOM format should only affect output serialization");
}
/// <summary>
/// Verifies that changing concurrency produces expected delta.
/// </summary>
[Fact]
public async Task ChangingConcurrency_ProducesExpectedDelta()
{
// Arrange
var baselineConfig = new ScannerTestConfig { MaxConcurrentAnalyzers = 2 };
var changedConfig = new ScannerTestConfig { MaxConcurrentAnalyzers = 8 };
var expectedDelta = new ConfigDelta(
ChangedBehaviors: ["ParallelismLevel", "ResourceUsage"],
BehaviorDeltas:
[
new BehaviorDelta("ParallelismLevel", "2", "8", null),
new BehaviorDelta("ResourceUsage", "increase", null,
"More concurrent analyzers use more resources")
]);
// Act
var result = await TestConfigBehavioralDeltaAsync(
baselineConfig,
changedConfig,
getBehavior: async config => await CaptureConcurrencyBehaviorAsync(config),
computeDelta: ComputeBehaviorSnapshotDelta,
expectedDelta: expectedDelta);
// Assert
result.IsSuccess.Should().BeTrue();
}
/// <summary>
/// Verifies that changing vulnerability threshold only affects filtering.
/// </summary>
[Fact]
public async Task ChangingVulnThreshold_OnlyAffectsFiltering()
{
// Arrange
var baselineConfig = new ScannerTestConfig { MinimumSeverity = "medium" };
var changedConfig = new ScannerTestConfig { MinimumSeverity = "critical" };
// Act
var result = await TestConfigIsolationAsync(
baselineConfig,
changedConfig,
changedSetting: "MinimumSeverity",
unrelatedBehaviors:
[
async config => await GetScanningBehaviorAsync(config),
async config => await GetSbomBehaviorAsync(config)
]);
// Assert
result.IsSuccess.Should().BeTrue(
because: "severity threshold should only affect output filtering");
}
// Helper methods
private static Task<object> GetReachabilityBehaviorAsync(ScannerTestConfig config)
{
return Task.FromResult<object>(new { Enabled = config.EnableReachabilityAnalysis });
}
private static Task<object> GetConcurrencyBehaviorAsync(ScannerTestConfig config)
{
return Task.FromResult<object>(new { MaxAnalyzers = config.MaxConcurrentAnalyzers });
}
private static Task<object> GetOutputFormatBehaviorAsync(ScannerTestConfig config)
{
return Task.FromResult<object>(new { Format = config.SbomFormat });
}
private static Task<object> GetScanningBehaviorAsync(ScannerTestConfig config)
{
return Task.FromResult<object>(new { Depth = config.MaxScanDepth });
}
private static Task<object> GetVulnMatchingBehaviorAsync(ScannerTestConfig config)
{
return Task.FromResult<object>(new { MatchingMode = "standard" });
}
private static Task<object> GetSbomBehaviorAsync(ScannerTestConfig config)
{
return Task.FromResult<object>(new { Format = config.SbomFormat });
}
private static Task<BehaviorSnapshot> CaptureReachabilityBehaviorAsync(ScannerTestConfig config)
{
var snapshot = new BehaviorSnapshot(
ConfigurationId: $"reachability-{config.EnableReachabilityAnalysis}",
Behaviors:
[
new CapturedBehavior("ReachabilityMode",
config.EnableReachabilityAnalysis ? "enabled" : "disabled", DateTimeOffset.UtcNow),
new CapturedBehavior("ScanDuration",
config.EnableReachabilityAnalysis ? "increase" : "standard", DateTimeOffset.UtcNow),
new CapturedBehavior("OutputDetail",
config.EnableReachabilityAnalysis ? "enhanced" : "basic", DateTimeOffset.UtcNow)
],
CapturedAt: DateTimeOffset.UtcNow);
return Task.FromResult(snapshot);
}
private static Task<BehaviorSnapshot> CaptureConcurrencyBehaviorAsync(ScannerTestConfig config)
{
var snapshot = new BehaviorSnapshot(
ConfigurationId: $"concurrency-{config.MaxConcurrentAnalyzers}",
Behaviors:
[
new CapturedBehavior("ParallelismLevel", config.MaxConcurrentAnalyzers.ToString(), DateTimeOffset.UtcNow),
new CapturedBehavior("ResourceUsage",
config.MaxConcurrentAnalyzers > 4 ? "increase" : "standard", DateTimeOffset.UtcNow)
],
CapturedAt: DateTimeOffset.UtcNow);
return Task.FromResult(snapshot);
}
}
/// <summary>
/// Test configuration for Scanner module.
/// </summary>
public sealed record ScannerTestConfig
{
public int MaxScanDepth { get; init; } = 10;
public bool EnableReachabilityAnalysis { get; init; } = true;
public int MaxConcurrentAnalyzers { get; init; } = 4;
public string SbomFormat { get; init; } = "spdx-3.0";
public string MinimumSeverity { get; init; } = "medium";
public bool IncludeDevDependencies { get; init; } = false;
}

View File

@@ -0,0 +1,23 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>preview</LangVersion>
<Description>Config-diff tests for Scanner module</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Testing.ConfigDiff/StellaOps.Testing.ConfigDiff.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,205 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Composition;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Composition;
/// <summary>
/// Unit tests for <see cref="CompositionRecipeService"/>.
/// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api
/// </summary>
[Trait("Category", "Unit")]
public sealed class CompositionRecipeServiceTests
{
[Fact]
public void BuildRecipe_ProducesValidRecipe()
{
var compositionResult = BuildCompositionResult();
var service = new CompositionRecipeService();
var createdAt = new DateTimeOffset(2026, 1, 6, 10, 30, 0, TimeSpan.Zero);
var recipe = service.BuildRecipe(
scanId: "scan-123",
imageDigest: "sha256:abc123",
createdAt: createdAt,
compositionResult: compositionResult,
generatorName: "StellaOps.Scanner",
generatorVersion: "2026.04");
Assert.Equal("scan-123", recipe.ScanId);
Assert.Equal("sha256:abc123", recipe.ImageDigest);
Assert.Equal("2026-01-06T10:30:00.0000000+00:00", recipe.CreatedAt);
Assert.Equal("1.0.0", recipe.Recipe.Version);
Assert.Equal("StellaOps.Scanner", recipe.Recipe.GeneratorName);
Assert.Equal("2026.04", recipe.Recipe.GeneratorVersion);
Assert.Equal(2, recipe.Recipe.Layers.Length);
Assert.False(string.IsNullOrWhiteSpace(recipe.Recipe.MerkleRoot));
}
[Fact]
public void BuildRecipe_LayersAreOrderedCorrectly()
{
var compositionResult = BuildCompositionResult();
var service = new CompositionRecipeService();
var recipe = service.BuildRecipe(
scanId: "scan-123",
imageDigest: "sha256:abc123",
createdAt: DateTimeOffset.UtcNow,
compositionResult: compositionResult);
Assert.Equal(0, recipe.Recipe.Layers[0].Order);
Assert.Equal(1, recipe.Recipe.Layers[1].Order);
Assert.Equal("sha256:layer0", recipe.Recipe.Layers[0].Digest);
Assert.Equal("sha256:layer1", recipe.Recipe.Layers[1].Digest);
}
[Fact]
public void Verify_ValidRecipe_ReturnsSuccess()
{
var compositionResult = BuildCompositionResult();
var service = new CompositionRecipeService();
var recipe = service.BuildRecipe(
scanId: "scan-123",
imageDigest: "sha256:abc123",
createdAt: DateTimeOffset.UtcNow,
compositionResult: compositionResult);
var verificationResult = service.Verify(recipe, compositionResult.LayerSboms);
Assert.True(verificationResult.Valid);
Assert.True(verificationResult.MerkleRootMatch);
Assert.True(verificationResult.LayerDigestsMatch);
Assert.Empty(verificationResult.Errors);
}
[Fact]
public void Verify_MismatchedLayerCount_ReturnsFailure()
{
var compositionResult = BuildCompositionResult();
var service = new CompositionRecipeService();
var recipe = service.BuildRecipe(
scanId: "scan-123",
imageDigest: "sha256:abc123",
createdAt: DateTimeOffset.UtcNow,
compositionResult: compositionResult);
// Only provide one layer instead of two
var partialLayers = compositionResult.LayerSboms.Take(1).ToImmutableArray();
var verificationResult = service.Verify(recipe, partialLayers);
Assert.False(verificationResult.Valid);
Assert.False(verificationResult.LayerDigestsMatch);
Assert.Contains("Layer count mismatch", verificationResult.Errors.First());
}
[Fact]
public void Verify_MismatchedDigest_ReturnsFailure()
{
var compositionResult = BuildCompositionResult();
var service = new CompositionRecipeService();
var recipe = service.BuildRecipe(
scanId: "scan-123",
imageDigest: "sha256:abc123",
createdAt: DateTimeOffset.UtcNow,
compositionResult: compositionResult);
// Modify one layer's digest
var modifiedLayers = compositionResult.LayerSboms
.Select((l, i) => i == 0
? l with { CycloneDxDigest = "tampered_digest" }
: l)
.ToImmutableArray();
var verificationResult = service.Verify(recipe, modifiedLayers);
Assert.False(verificationResult.Valid);
Assert.False(verificationResult.LayerDigestsMatch);
Assert.Contains("CycloneDX digest mismatch", verificationResult.Errors.First());
}
[Fact]
public void BuildRecipe_IsDeterministic()
{
var compositionResult = BuildCompositionResult();
var service = new CompositionRecipeService();
var createdAt = new DateTimeOffset(2026, 1, 6, 10, 30, 0, TimeSpan.Zero);
var first = service.BuildRecipe("scan-123", "sha256:abc123", createdAt, compositionResult);
var second = service.BuildRecipe("scan-123", "sha256:abc123", createdAt, compositionResult);
Assert.Equal(first.Recipe.MerkleRoot, second.Recipe.MerkleRoot);
Assert.Equal(first.Recipe.Layers.Length, second.Recipe.Layers.Length);
for (var i = 0; i < first.Recipe.Layers.Length; i++)
{
Assert.Equal(first.Recipe.Layers[i].FragmentDigest, second.Recipe.Layers[i].FragmentDigest);
Assert.Equal(first.Recipe.Layers[i].SbomDigests.CycloneDx, second.Recipe.Layers[i].SbomDigests.CycloneDx);
Assert.Equal(first.Recipe.Layers[i].SbomDigests.Spdx, second.Recipe.Layers[i].SbomDigests.Spdx);
}
}
private static SbomCompositionResult BuildCompositionResult()
{
var layerSboms = ImmutableArray.Create(
new LayerSbomRef
{
LayerDigest = "sha256:layer0",
Order = 0,
FragmentDigest = "sha256:frag0",
CycloneDxDigest = "sha256:cdx0",
CycloneDxCasUri = "cas://sbom/layers/sha256:abc123/sha256:layer0.cdx.json",
SpdxDigest = "sha256:spdx0",
SpdxCasUri = "cas://sbom/layers/sha256:abc123/sha256:layer0.spdx.json",
ComponentCount = 5,
},
new LayerSbomRef
{
LayerDigest = "sha256:layer1",
Order = 1,
FragmentDigest = "sha256:frag1",
CycloneDxDigest = "sha256:cdx1",
CycloneDxCasUri = "cas://sbom/layers/sha256:abc123/sha256:layer1.cdx.json",
SpdxDigest = "sha256:spdx1",
SpdxCasUri = "cas://sbom/layers/sha256:abc123/sha256:layer1.spdx.json",
ComponentCount = 3,
});
// Create a mock CycloneDxArtifact for the composition result
var mockInventory = new CycloneDxArtifact
{
View = SbomView.Inventory,
SerialNumber = "urn:uuid:test-123",
GeneratedAt = DateTimeOffset.UtcNow,
Components = ImmutableArray<AggregatedComponent>.Empty,
JsonBytes = Array.Empty<byte>(),
JsonSha256 = "sha256:inventory123",
ContentHash = "sha256:inventory123",
JsonMediaType = "application/vnd.cyclonedx+json",
ProtobufBytes = Array.Empty<byte>(),
ProtobufSha256 = "sha256:protobuf123",
ProtobufMediaType = "application/vnd.cyclonedx+protobuf",
};
return new SbomCompositionResult
{
Inventory = mockInventory,
Graph = new ComponentGraph
{
Layers = ImmutableArray<LayerComponentFragment>.Empty,
Components = ImmutableArray<AggregatedComponent>.Empty,
ComponentMap = ImmutableDictionary<string, AggregatedComponent>.Empty,
},
CompositionRecipeJson = Array.Empty<byte>(),
CompositionRecipeSha256 = "sha256:recipe123",
LayerSboms = layerSboms,
LayerSbomMerkleRoot = "sha256:merkle123",
};
}
}

View File

@@ -0,0 +1,251 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Composition;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Composition;
/// <summary>
/// Unit tests for <see cref="LayerSbomComposer"/>.
/// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api
/// </summary>
[Trait("Category", "Unit")]
public sealed class LayerSbomComposerTests
{
[Fact]
public async Task ComposeAsync_ProducesPerLayerSboms()
{
var request = BuildRequest();
var composer = new LayerSbomComposer();
var result = await composer.ComposeAsync(request);
Assert.Equal(2, result.Artifacts.Length);
Assert.Equal(2, result.References.Length);
Assert.False(string.IsNullOrWhiteSpace(result.MerkleRoot));
// First layer
var layer0Artifact = result.Artifacts.Single(a => a.LayerDigest == "sha256:layer0");
Assert.NotNull(layer0Artifact.CycloneDxJsonBytes);
Assert.NotNull(layer0Artifact.SpdxJsonBytes);
Assert.False(string.IsNullOrWhiteSpace(layer0Artifact.CycloneDxDigest));
Assert.False(string.IsNullOrWhiteSpace(layer0Artifact.SpdxDigest));
Assert.Equal(2, layer0Artifact.ComponentCount);
var layer0Ref = result.References.Single(r => r.LayerDigest == "sha256:layer0");
Assert.Equal(0, layer0Ref.Order);
Assert.Equal(layer0Artifact.CycloneDxDigest, layer0Ref.CycloneDxDigest);
Assert.Equal(layer0Artifact.SpdxDigest, layer0Ref.SpdxDigest);
Assert.StartsWith("cas://sbom/layers/", layer0Ref.CycloneDxCasUri);
Assert.StartsWith("cas://sbom/layers/", layer0Ref.SpdxCasUri);
// Second layer
var layer1Artifact = result.Artifacts.Single(a => a.LayerDigest == "sha256:layer1");
Assert.Equal(1, layer1Artifact.ComponentCount);
var layer1Ref = result.References.Single(r => r.LayerDigest == "sha256:layer1");
Assert.Equal(1, layer1Ref.Order);
}
[Fact]
public async Task ComposeAsync_CycloneDxOutputIsValidJson()
{
var request = BuildRequest();
var composer = new LayerSbomComposer();
var result = await composer.ComposeAsync(request);
foreach (var artifact in result.Artifacts)
{
using var doc = JsonDocument.Parse(artifact.CycloneDxJsonBytes);
var root = doc.RootElement;
// Verify CycloneDX structure
Assert.True(root.TryGetProperty("bomFormat", out var bomFormat));
Assert.Equal("CycloneDX", bomFormat.GetString());
Assert.True(root.TryGetProperty("specVersion", out var specVersion));
Assert.Equal("1.7", specVersion.GetString());
Assert.True(root.TryGetProperty("components", out var components));
Assert.Equal(artifact.ComponentCount, components.GetArrayLength());
// Verify layer metadata in properties
Assert.True(root.TryGetProperty("metadata", out var metadata));
Assert.True(metadata.TryGetProperty("properties", out var props));
var properties = props.EnumerateArray()
.ToDictionary(
p => p.GetProperty("name").GetString()!,
p => p.GetProperty("value").GetString()!);
Assert.Equal("layer", properties["stellaops:sbom.type"]);
}
}
[Fact]
public async Task ComposeAsync_SpdxOutputIsValidJson()
{
var request = BuildRequest();
var composer = new LayerSbomComposer();
var result = await composer.ComposeAsync(request);
foreach (var artifact in result.Artifacts)
{
using var doc = JsonDocument.Parse(artifact.SpdxJsonBytes);
var root = doc.RootElement;
// Verify SPDX structure
Assert.True(root.TryGetProperty("@context", out _));
Assert.True(root.TryGetProperty("@graph", out _) || root.TryGetProperty("spdxVersion", out _) || root.TryGetProperty("creationInfo", out _));
}
}
[Fact]
public async Task ComposeAsync_IsDeterministic()
{
var request = BuildRequest();
var composer = new LayerSbomComposer();
var first = await composer.ComposeAsync(request);
var second = await composer.ComposeAsync(request);
// Same artifacts
Assert.Equal(first.Artifacts.Length, second.Artifacts.Length);
for (var i = 0; i < first.Artifacts.Length; i++)
{
Assert.Equal(first.Artifacts[i].LayerDigest, second.Artifacts[i].LayerDigest);
Assert.Equal(first.Artifacts[i].CycloneDxDigest, second.Artifacts[i].CycloneDxDigest);
Assert.Equal(first.Artifacts[i].SpdxDigest, second.Artifacts[i].SpdxDigest);
}
// Same Merkle root
Assert.Equal(first.MerkleRoot, second.MerkleRoot);
// Same references
Assert.Equal(first.References.Length, second.References.Length);
for (var i = 0; i < first.References.Length; i++)
{
Assert.Equal(first.References[i].FragmentDigest, second.References[i].FragmentDigest);
}
}
[Fact]
public async Task ComposeAsync_EmptyLayerFragments_ReturnsEmptyResult()
{
var request = new SbomCompositionRequest
{
Image = new ImageArtifactDescriptor
{
ImageDigest = "sha256:abc123",
Repository = "test/image",
Tag = "latest",
},
LayerFragments = ImmutableArray<LayerComponentFragment>.Empty,
GeneratedAt = DateTimeOffset.UtcNow,
};
var composer = new LayerSbomComposer();
var result = await composer.ComposeAsync(request);
Assert.Empty(result.Artifacts);
Assert.Empty(result.References);
Assert.False(string.IsNullOrWhiteSpace(result.MerkleRoot));
}
[Fact]
public async Task ComposeAsync_LayerOrderIsPreserved()
{
var request = BuildRequestWithManyLayers(5);
var composer = new LayerSbomComposer();
var result = await composer.ComposeAsync(request);
Assert.Equal(5, result.References.Length);
for (var i = 0; i < 5; i++)
{
var reference = result.References.Single(r => r.Order == i);
Assert.Equal($"sha256:layer{i}", reference.LayerDigest);
}
}
private static SbomCompositionRequest BuildRequest()
{
var layer0Components = ImmutableArray.Create(
new ComponentRecord
{
Identity = ComponentIdentity.Create("pkg:npm/a", "package-a", "1.0.0"),
LayerDigest = "sha256:layer0",
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/a/package.json")),
Usage = ComponentUsage.Create(usedByEntrypoint: true),
},
new ComponentRecord
{
Identity = ComponentIdentity.Create("pkg:npm/b", "package-b", "2.0.0"),
LayerDigest = "sha256:layer0",
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/b/package.json")),
Usage = ComponentUsage.Create(usedByEntrypoint: false),
});
var layer1Components = ImmutableArray.Create(
new ComponentRecord
{
Identity = ComponentIdentity.Create("pkg:npm/c", "package-c", "3.0.0"),
LayerDigest = "sha256:layer1",
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath("/app/node_modules/c/package.json")),
Usage = ComponentUsage.Create(usedByEntrypoint: false),
});
return new SbomCompositionRequest
{
Image = new ImageArtifactDescriptor
{
ImageDigest = "sha256:abc123def456",
ImageReference = "docker.io/test/image:v1.0.0",
Repository = "docker.io/test/image",
Tag = "v1.0.0",
Architecture = "amd64",
},
LayerFragments = ImmutableArray.Create(
LayerComponentFragment.Create("sha256:layer0", layer0Components),
LayerComponentFragment.Create("sha256:layer1", layer1Components)),
GeneratedAt = new DateTimeOffset(2026, 1, 6, 10, 30, 0, TimeSpan.Zero),
GeneratorName = "StellaOps.Scanner",
GeneratorVersion = "2026.04",
};
}
private static SbomCompositionRequest BuildRequestWithManyLayers(int layerCount)
{
var fragments = new LayerComponentFragment[layerCount];
for (var i = 0; i < layerCount; i++)
{
var component = new ComponentRecord
{
Identity = ComponentIdentity.Create($"pkg:npm/layer{i}-pkg", $"layer{i}-package", "1.0.0"),
LayerDigest = $"sha256:layer{i}",
Evidence = ImmutableArray.Create(ComponentEvidence.FromPath($"/app/layer{i}/package.json")),
};
fragments[i] = LayerComponentFragment.Create($"sha256:layer{i}", ImmutableArray.Create(component));
}
return new SbomCompositionRequest
{
Image = new ImageArtifactDescriptor
{
ImageDigest = "sha256:multilayer123",
Repository = "test/multilayer",
Tag = "latest",
},
LayerFragments = fragments.ToImmutableArray(),
GeneratedAt = new DateTimeOffset(2026, 1, 6, 10, 30, 0, TimeSpan.Zero),
};
}
}

View File

@@ -0,0 +1,230 @@
// -----------------------------------------------------------------------------
// CachingVexObservationProviderTests.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Unit tests for CachingVexObservationProvider.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace StellaOps.Scanner.Gate.Tests;
/// <summary>
/// Unit tests for <see cref="CachingVexObservationProvider"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CachingVexObservationProviderTests : IDisposable
{
private readonly Mock<IVexObservationQuery> _queryMock;
private readonly CachingVexObservationProvider _provider;
public CachingVexObservationProviderTests()
{
_queryMock = new Mock<IVexObservationQuery>();
_provider = new CachingVexObservationProvider(
_queryMock.Object,
"test-tenant",
NullLogger<CachingVexObservationProvider>.Instance,
TimeSpan.FromMinutes(5),
1000);
}
public void Dispose()
{
_provider.Dispose();
}
[Fact]
public async Task GetVexStatusAsync_CachesMissResult()
{
_queryMock
.Setup(q => q.GetEffectiveStatusAsync(
"test-tenant", "CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexObservationQueryResult
{
Status = VexStatus.NotAffected,
Confidence = 0.9,
LastUpdated = DateTimeOffset.UtcNow,
});
// First call - cache miss
var result1 = await _provider.GetVexStatusAsync("CVE-2025-1234", "pkg:npm/test@1.0.0");
Assert.NotNull(result1);
Assert.Equal(VexStatus.NotAffected, result1.Status);
// Second call - should be cache hit
var result2 = await _provider.GetVexStatusAsync("CVE-2025-1234", "pkg:npm/test@1.0.0");
Assert.NotNull(result2);
Assert.Equal(VexStatus.NotAffected, result2.Status);
// Query should only be called once
_queryMock.Verify(
q => q.GetEffectiveStatusAsync(
"test-tenant", "CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task GetVexStatusAsync_ReturnsNull_WhenQueryReturnsNull()
{
_queryMock
.Setup(q => q.GetEffectiveStatusAsync(
"test-tenant", "CVE-2025-UNKNOWN", "pkg:npm/unknown@1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync((VexObservationQueryResult?)null);
var result = await _provider.GetVexStatusAsync("CVE-2025-UNKNOWN", "pkg:npm/unknown@1.0.0");
Assert.Null(result);
}
[Fact]
public async Task GetStatementsAsync_CallsQueryDirectly()
{
var statements = new List<VexStatementQueryResult>
{
new()
{
StatementId = "stmt-1",
IssuerId = "vendor",
Status = VexStatus.NotAffected,
Timestamp = DateTimeOffset.UtcNow,
},
};
_queryMock
.Setup(q => q.GetStatementsAsync(
"test-tenant", "CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(statements);
var result = await _provider.GetStatementsAsync("CVE-2025-1234", "pkg:npm/test@1.0.0");
Assert.Single(result);
Assert.Equal("stmt-1", result[0].StatementId);
}
[Fact]
public async Task PrefetchAsync_PopulatesCache()
{
var batchResults = new Dictionary<VexQueryKey, VexObservationQueryResult>
{
[new VexQueryKey("CVE-1", "pkg:npm/a@1.0.0")] = new VexObservationQueryResult
{
Status = VexStatus.NotAffected,
Confidence = 0.9,
LastUpdated = DateTimeOffset.UtcNow,
},
[new VexQueryKey("CVE-2", "pkg:npm/b@1.0.0")] = new VexObservationQueryResult
{
Status = VexStatus.Fixed,
Confidence = 0.85,
BackportHints = ImmutableArray.Create("backport-1"),
LastUpdated = DateTimeOffset.UtcNow,
},
};
_queryMock
.Setup(q => q.BatchLookupAsync(
"test-tenant", It.IsAny<IReadOnlyList<VexQueryKey>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(batchResults);
var keys = new List<VexLookupKey>
{
new("CVE-1", "pkg:npm/a@1.0.0"),
new("CVE-2", "pkg:npm/b@1.0.0"),
};
await _provider.PrefetchAsync(keys);
// Now lookups should be cache hits
var result1 = await _provider.GetVexStatusAsync("CVE-1", "pkg:npm/a@1.0.0");
var result2 = await _provider.GetVexStatusAsync("CVE-2", "pkg:npm/b@1.0.0");
Assert.NotNull(result1);
Assert.Equal(VexStatus.NotAffected, result1.Status);
Assert.NotNull(result2);
Assert.Equal(VexStatus.Fixed, result2.Status);
Assert.Single(result2.BackportHints);
// GetEffectiveStatusAsync should not be called since we prefetched
_queryMock.Verify(
q => q.GetEffectiveStatusAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task PrefetchAsync_SkipsAlreadyCachedKeys()
{
// Pre-populate cache
_queryMock
.Setup(q => q.GetEffectiveStatusAsync(
"test-tenant", "CVE-CACHED", "pkg:npm/cached@1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexObservationQueryResult
{
Status = VexStatus.NotAffected,
Confidence = 0.9,
LastUpdated = DateTimeOffset.UtcNow,
});
await _provider.GetVexStatusAsync("CVE-CACHED", "pkg:npm/cached@1.0.0");
// Now prefetch with the same key
var keys = new List<VexLookupKey>
{
new("CVE-CACHED", "pkg:npm/cached@1.0.0"),
};
await _provider.PrefetchAsync(keys);
// BatchLookupAsync should not be called since key is already cached
_queryMock.Verify(
q => q.BatchLookupAsync(
It.IsAny<string>(), It.IsAny<IReadOnlyList<VexQueryKey>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task PrefetchAsync_EmptyList_DoesNothing()
{
await _provider.PrefetchAsync(new List<VexLookupKey>());
_queryMock.Verify(
q => q.BatchLookupAsync(
It.IsAny<string>(), It.IsAny<IReadOnlyList<VexQueryKey>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public void GetStatistics_ReturnsCurrentCount()
{
var stats = _provider.GetStatistics();
Assert.Equal(0, stats.CurrentEntryCount);
}
[Fact]
public async Task Cache_IsCaseInsensitive_ForVulnerabilityId()
{
_queryMock
.Setup(q => q.GetEffectiveStatusAsync(
"test-tenant", It.IsAny<string>(), "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexObservationQueryResult
{
Status = VexStatus.Fixed,
Confidence = 0.8,
LastUpdated = DateTimeOffset.UtcNow,
});
await _provider.GetVexStatusAsync("cve-2025-1234", "pkg:npm/test@1.0.0");
await _provider.GetVexStatusAsync("CVE-2025-1234", "pkg:npm/test@1.0.0");
// Should be treated as the same key
_queryMock.Verify(
q => q.GetEffectiveStatusAsync(
"test-tenant", It.IsAny<string>(), "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()),
Times.Once);
}
}

View File

@@ -0,0 +1,256 @@
// -----------------------------------------------------------------------------
// VexGatePolicyEvaluatorTests.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Unit tests for VexGatePolicyEvaluator.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace StellaOps.Scanner.Gate.Tests;
/// <summary>
/// Unit tests for <see cref="VexGatePolicyEvaluator"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class VexGatePolicyEvaluatorTests
{
private readonly VexGatePolicyEvaluator _evaluator;
public VexGatePolicyEvaluatorTests()
{
_evaluator = new VexGatePolicyEvaluator(NullLogger<VexGatePolicyEvaluator>.Instance);
}
[Fact]
public void Evaluate_ExploitableAndReachable_ReturnsBlock()
{
var evidence = new VexGateEvidence
{
IsExploitable = true,
IsReachable = true,
HasCompensatingControl = false,
ConfidenceScore = 0.95,
SeverityLevel = "critical",
};
var (decision, ruleId, rationale) = _evaluator.Evaluate(evidence);
Assert.Equal(VexGateDecision.Block, decision);
Assert.Equal("block-exploitable-reachable", ruleId);
Assert.Contains("Exploitable", rationale);
}
[Fact]
public void Evaluate_ExploitableAndReachableWithControl_ReturnsDefault()
{
var evidence = new VexGateEvidence
{
IsExploitable = true,
IsReachable = true,
HasCompensatingControl = true, // Has control, so block rule doesn't match
ConfidenceScore = 0.95,
SeverityLevel = "critical",
};
var (decision, ruleId, _) = _evaluator.Evaluate(evidence);
// With compensating control, the block rule doesn't match
// Next matching rule or default applies
Assert.NotEqual("block-exploitable-reachable", ruleId);
}
[Fact]
public void Evaluate_HighSeverityNotReachable_ReturnsWarn()
{
var evidence = new VexGateEvidence
{
IsExploitable = true,
IsReachable = false,
HasCompensatingControl = false,
ConfidenceScore = 0.8,
SeverityLevel = "high",
};
var (decision, ruleId, rationale) = _evaluator.Evaluate(evidence);
Assert.Equal(VexGateDecision.Warn, decision);
Assert.Equal("warn-high-not-reachable", ruleId);
Assert.Contains("not reachable", rationale, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Evaluate_CriticalSeverityNotReachable_ReturnsWarn()
{
var evidence = new VexGateEvidence
{
IsExploitable = true,
IsReachable = false,
HasCompensatingControl = false,
ConfidenceScore = 0.8,
SeverityLevel = "critical",
};
var (decision, ruleId, _) = _evaluator.Evaluate(evidence);
Assert.Equal(VexGateDecision.Warn, decision);
Assert.Equal("warn-high-not-reachable", ruleId);
}
[Fact]
public void Evaluate_VendorNotAffected_ReturnsPass()
{
var evidence = new VexGateEvidence
{
VendorStatus = VexStatus.NotAffected,
IsExploitable = false,
IsReachable = true,
HasCompensatingControl = false,
ConfidenceScore = 0.9,
};
var (decision, ruleId, rationale) = _evaluator.Evaluate(evidence);
Assert.Equal(VexGateDecision.Pass, decision);
Assert.Equal("pass-vendor-not-affected", ruleId);
Assert.Contains("not_affected", rationale);
}
[Fact]
public void Evaluate_VendorFixed_ReturnsPass()
{
var evidence = new VexGateEvidence
{
VendorStatus = VexStatus.Fixed,
IsExploitable = false,
IsReachable = true,
HasCompensatingControl = false,
ConfidenceScore = 0.85,
};
var (decision, ruleId, rationale) = _evaluator.Evaluate(evidence);
Assert.Equal(VexGateDecision.Pass, decision);
Assert.Equal("pass-backport-confirmed", ruleId);
Assert.Contains("backport", rationale, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Evaluate_NoMatchingRules_ReturnsDefaultWarn()
{
var evidence = new VexGateEvidence
{
VendorStatus = VexStatus.UnderInvestigation,
IsExploitable = false,
IsReachable = true,
HasCompensatingControl = false,
ConfidenceScore = 0.5,
SeverityLevel = "low",
};
var (decision, ruleId, rationale) = _evaluator.Evaluate(evidence);
Assert.Equal(VexGateDecision.Warn, decision);
Assert.Equal("default", ruleId);
Assert.Contains("default", rationale, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Evaluate_RulesAreEvaluatedInPriorityOrder()
{
// Evidence matches both block and pass-vendor-not-affected rules
// Block has higher priority (100) than pass (80), so block should win
var evidence = new VexGateEvidence
{
VendorStatus = VexStatus.NotAffected, // Would match pass rule
IsExploitable = true,
IsReachable = true,
HasCompensatingControl = false, // Would match block rule
ConfidenceScore = 0.9,
};
var (decision, ruleId, _) = _evaluator.Evaluate(evidence);
// Block rule has higher priority
Assert.Equal(VexGateDecision.Block, decision);
Assert.Equal("block-exploitable-reachable", ruleId);
}
[Fact]
public void DefaultPolicy_HasExpectedRules()
{
var policy = VexGatePolicy.Default;
Assert.Equal(VexGateDecision.Warn, policy.DefaultDecision);
Assert.Equal(4, policy.Rules.Length);
var ruleIds = policy.Rules.Select(r => r.RuleId).ToList();
Assert.Contains("block-exploitable-reachable", ruleIds);
Assert.Contains("warn-high-not-reachable", ruleIds);
Assert.Contains("pass-vendor-not-affected", ruleIds);
Assert.Contains("pass-backport-confirmed", ruleIds);
}
[Fact]
public void PolicyCondition_Matches_AllConditionsMustMatch()
{
var condition = new VexGatePolicyCondition
{
IsExploitable = true,
IsReachable = true,
HasCompensatingControl = false,
};
// All conditions match
var matchingEvidence = new VexGateEvidence
{
IsExploitable = true,
IsReachable = true,
HasCompensatingControl = false,
};
Assert.True(condition.Matches(matchingEvidence));
// One condition doesn't match
var nonMatchingEvidence = new VexGateEvidence
{
IsExploitable = true,
IsReachable = false, // Different
HasCompensatingControl = false,
};
Assert.False(condition.Matches(nonMatchingEvidence));
}
[Fact]
public void PolicyCondition_SeverityLevels_MatchesAny()
{
var condition = new VexGatePolicyCondition
{
SeverityLevels = ["critical", "high"],
};
var criticalEvidence = new VexGateEvidence { SeverityLevel = "critical" };
var highEvidence = new VexGateEvidence { SeverityLevel = "high" };
var mediumEvidence = new VexGateEvidence { SeverityLevel = "medium" };
var noSeverityEvidence = new VexGateEvidence();
Assert.True(condition.Matches(criticalEvidence));
Assert.True(condition.Matches(highEvidence));
Assert.False(condition.Matches(mediumEvidence));
Assert.False(condition.Matches(noSeverityEvidence));
}
[Fact]
public void PolicyCondition_NullConditionsMatch_AnyEvidence()
{
var condition = new VexGatePolicyCondition(); // All null
var anyEvidence = new VexGateEvidence
{
IsExploitable = true,
IsReachable = false,
SeverityLevel = "low",
};
Assert.True(condition.Matches(anyEvidence));
}
}

View File

@@ -0,0 +1,327 @@
// -----------------------------------------------------------------------------
// VexGateServiceTests.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Unit tests for VexGateService.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Moq;
using Xunit;
namespace StellaOps.Scanner.Gate.Tests;
/// <summary>
/// Unit tests for <see cref="VexGateService"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class VexGateServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly VexGatePolicyEvaluator _policyEvaluator;
private readonly Mock<IVexObservationProvider> _vexProviderMock;
public VexGateServiceTests()
{
_timeProvider = new FakeTimeProvider(
new DateTimeOffset(2026, 1, 6, 10, 30, 0, TimeSpan.Zero));
_policyEvaluator = new VexGatePolicyEvaluator(
NullLogger<VexGatePolicyEvaluator>.Instance);
_vexProviderMock = new Mock<IVexObservationProvider>();
}
[Fact]
public async Task EvaluateAsync_WithVexNotAffected_ReturnsPass()
{
_vexProviderMock
.Setup(p => p.GetVexStatusAsync("CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexObservationResult
{
Status = VexStatus.NotAffected,
Confidence = 0.95,
});
_vexProviderMock
.Setup(p => p.GetStatementsAsync("CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VexStatementInfo>
{
new()
{
StatementId = "stmt-001",
IssuerId = "vendor-a",
Status = VexStatus.NotAffected,
Timestamp = _timeProvider.GetUtcNow().AddDays(-1),
TrustWeight = 0.9,
},
});
var service = CreateService();
var finding = new VexGateFinding
{
FindingId = "finding-001",
VulnerabilityId = "CVE-2025-1234",
Purl = "pkg:npm/test@1.0.0",
ImageDigest = "sha256:abc123",
IsReachable = true,
};
var result = await service.EvaluateAsync(finding);
Assert.Equal(VexGateDecision.Pass, result.Decision);
Assert.Equal("pass-vendor-not-affected", result.PolicyRuleMatched);
Assert.Single(result.ContributingStatements);
Assert.Equal("stmt-001", result.ContributingStatements[0].StatementId);
}
[Fact]
public async Task EvaluateAsync_ExploitableReachable_ReturnsBlock()
{
_vexProviderMock
.Setup(p => p.GetVexStatusAsync("CVE-2025-5678", "pkg:npm/vuln@2.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexObservationResult
{
Status = VexStatus.Affected,
Confidence = 0.9,
});
_vexProviderMock
.Setup(p => p.GetStatementsAsync("CVE-2025-5678", "pkg:npm/vuln@2.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VexStatementInfo>());
var service = CreateService();
var finding = new VexGateFinding
{
FindingId = "finding-002",
VulnerabilityId = "CVE-2025-5678",
Purl = "pkg:npm/vuln@2.0.0",
ImageDigest = "sha256:def456",
IsReachable = true,
IsExploitable = true,
HasCompensatingControl = false,
SeverityLevel = "critical",
};
var result = await service.EvaluateAsync(finding);
Assert.Equal(VexGateDecision.Block, result.Decision);
Assert.Equal("block-exploitable-reachable", result.PolicyRuleMatched);
Assert.True(result.Evidence.IsReachable);
Assert.True(result.Evidence.IsExploitable);
}
[Fact]
public async Task EvaluateAsync_NoVexProvider_UsesDefaultEvidence()
{
var service = new VexGateService(
_policyEvaluator,
_timeProvider,
NullLogger<VexGateService>.Instance,
vexProvider: null);
var finding = new VexGateFinding
{
FindingId = "finding-003",
VulnerabilityId = "CVE-2025-9999",
Purl = "pkg:npm/unknown@1.0.0",
ImageDigest = "sha256:xyz789",
IsReachable = false,
SeverityLevel = "high",
};
var result = await service.EvaluateAsync(finding);
// High severity + not reachable = warn
Assert.Equal(VexGateDecision.Warn, result.Decision);
Assert.Null(result.Evidence.VendorStatus);
Assert.Empty(result.ContributingStatements);
}
[Fact]
public async Task EvaluateAsync_EvaluatedAtIsSet()
{
var service = CreateServiceWithoutVex();
var finding = new VexGateFinding
{
FindingId = "finding-004",
VulnerabilityId = "CVE-2025-1111",
Purl = "pkg:npm/pkg@1.0.0",
ImageDigest = "sha256:time123",
};
var result = await service.EvaluateAsync(finding);
Assert.Equal(_timeProvider.GetUtcNow(), result.EvaluatedAt);
}
[Fact]
public async Task EvaluateBatchAsync_ProcessesMultipleFindings()
{
var service = CreateServiceWithoutVex();
var findings = new List<VexGateFinding>
{
new()
{
FindingId = "f1",
VulnerabilityId = "CVE-1",
Purl = "pkg:npm/a@1.0.0",
ImageDigest = "sha256:batch",
IsReachable = true,
IsExploitable = true,
HasCompensatingControl = false,
},
new()
{
FindingId = "f2",
VulnerabilityId = "CVE-2",
Purl = "pkg:npm/b@1.0.0",
ImageDigest = "sha256:batch",
IsReachable = false,
SeverityLevel = "high",
},
new()
{
FindingId = "f3",
VulnerabilityId = "CVE-3",
Purl = "pkg:npm/c@1.0.0",
ImageDigest = "sha256:batch",
SeverityLevel = "low",
},
};
var results = await service.EvaluateBatchAsync(findings);
Assert.Equal(3, results.Length);
Assert.Equal(VexGateDecision.Block, results[0].GateResult.Decision);
Assert.Equal(VexGateDecision.Warn, results[1].GateResult.Decision);
Assert.Equal(VexGateDecision.Warn, results[2].GateResult.Decision); // Default
}
[Fact]
public async Task EvaluateBatchAsync_EmptyList_ReturnsEmpty()
{
var service = CreateServiceWithoutVex();
var results = await service.EvaluateBatchAsync(new List<VexGateFinding>());
Assert.Empty(results);
}
[Fact]
public async Task EvaluateBatchAsync_UsesBatchPrefetch_WhenAvailable()
{
var batchProviderMock = new Mock<IVexObservationBatchProvider>();
var prefetchedKeys = new List<VexLookupKey>();
batchProviderMock
.Setup(p => p.PrefetchAsync(It.IsAny<IReadOnlyList<VexLookupKey>>(), It.IsAny<CancellationToken>()))
.Callback<IReadOnlyList<VexLookupKey>, CancellationToken>((keys, _) => prefetchedKeys.AddRange(keys))
.Returns(Task.CompletedTask);
batchProviderMock
.Setup(p => p.GetVexStatusAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((VexObservationResult?)null);
batchProviderMock
.Setup(p => p.GetStatementsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VexStatementInfo>());
var service = new VexGateService(
_policyEvaluator,
_timeProvider,
NullLogger<VexGateService>.Instance,
batchProviderMock.Object);
var findings = new List<VexGateFinding>
{
new()
{
FindingId = "f1",
VulnerabilityId = "CVE-1",
Purl = "pkg:npm/a@1.0.0",
ImageDigest = "sha256:batch",
},
new()
{
FindingId = "f2",
VulnerabilityId = "CVE-2",
Purl = "pkg:npm/b@1.0.0",
ImageDigest = "sha256:batch",
},
};
await service.EvaluateBatchAsync(findings);
batchProviderMock.Verify(
p => p.PrefetchAsync(It.IsAny<IReadOnlyList<VexLookupKey>>(), It.IsAny<CancellationToken>()),
Times.Once);
Assert.Equal(2, prefetchedKeys.Count);
}
[Fact]
public async Task EvaluateAsync_VexFixed_ReturnsPass()
{
_vexProviderMock
.Setup(p => p.GetVexStatusAsync("CVE-2025-FIXED", "pkg:deb/fixed@1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new VexObservationResult
{
Status = VexStatus.Fixed,
Confidence = 0.85,
BackportHints = ImmutableArray.Create("deb:1.0.0-2ubuntu1"),
});
_vexProviderMock
.Setup(p => p.GetStatementsAsync("CVE-2025-FIXED", "pkg:deb/fixed@1.0.0", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VexStatementInfo>
{
new()
{
StatementId = "stmt-fixed",
IssuerId = "ubuntu",
Status = VexStatus.Fixed,
Timestamp = _timeProvider.GetUtcNow().AddHours(-6),
TrustWeight = 0.95,
},
});
var service = CreateService();
var finding = new VexGateFinding
{
FindingId = "finding-fixed",
VulnerabilityId = "CVE-2025-FIXED",
Purl = "pkg:deb/fixed@1.0.0",
ImageDigest = "sha256:ubuntu",
IsReachable = true,
};
var result = await service.EvaluateAsync(finding);
Assert.Equal(VexGateDecision.Pass, result.Decision);
Assert.Equal("pass-backport-confirmed", result.PolicyRuleMatched);
Assert.Single(result.Evidence.BackportHints);
}
private VexGateService CreateService()
{
return new VexGateService(
_policyEvaluator,
_timeProvider,
NullLogger<VexGateService>.Instance,
_vexProviderMock.Object);
}
private VexGateService CreateServiceWithoutVex()
{
return new VexGateService(
_policyEvaluator,
_timeProvider,
NullLogger<VexGateService>.Instance,
vexProvider: null);
}
}

View File

@@ -0,0 +1,581 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: SPRINT_20260106_001_002_SCANNER_suppression_proofs
// Task: SUP-022
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Stack;
using StellaOps.Scanner.Reachability.Witnesses;
using StellaOps.TestKit;
using Xunit;
using StackVerdict = StellaOps.Scanner.Reachability.Stack.ReachabilityVerdict;
using WitnessVerdict = StellaOps.Scanner.Reachability.Witnesses.ReachabilityVerdict;
namespace StellaOps.Scanner.Reachability.Stack.Tests;
/// <summary>
/// Tests for <see cref="ReachabilityResultFactory"/> which bridges ReachabilityStack
/// evaluation to ReachabilityResult with SuppressionWitness generation.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class ReachabilityResultFactoryTests
{
private readonly Mock<ISuppressionWitnessBuilder> _mockBuilder;
private readonly ILogger<ReachabilityResultFactory> _logger;
private readonly ReachabilityResultFactory _factory;
private static readonly WitnessGenerationContext DefaultContext = new()
{
SbomDigest = "sbom:sha256:abc123",
ComponentPurl = "pkg:npm/test@1.0.0",
VulnId = "CVE-2025-1234",
VulnSource = "NVD",
AffectedRange = "< 2.0.0",
GraphDigest = "graph:sha256:def456"
};
public ReachabilityResultFactoryTests()
{
_mockBuilder = new Mock<ISuppressionWitnessBuilder>();
_logger = NullLogger<ReachabilityResultFactory>.Instance;
_factory = new ReachabilityResultFactory(_mockBuilder.Object, _logger);
}
private static SuppressionWitness CreateMockSuppressionWitness(SuppressionType type) => new()
{
WitnessSchema = "stellaops.suppression.v1",
WitnessId = $"sup:sha256:{Guid.NewGuid():N}",
SuppressionType = type,
Artifact = new WitnessArtifact { SbomDigest = "sbom:sha256:abc", ComponentPurl = "pkg:npm/test@1.0.0" },
Vuln = new WitnessVuln { Id = "CVE-2025-1234", Source = "NVD", AffectedRange = "< 2.0.0" },
Confidence = 0.95,
ObservedAt = DateTimeOffset.UtcNow,
Evidence = new SuppressionEvidence
{
WitnessEvidence = new WitnessEvidence { CallgraphDigest = "graph:sha256:test" }
}
};
private static VulnerableSymbol CreateTestSymbol() => new(
Name: "vulnerable_func",
Library: "libtest.so",
Version: "1.0.0",
VulnerabilityId: "CVE-2025-1234",
Type: SymbolType.Function
);
private static ReachabilityStack CreateStackWithVerdict(
StackVerdict verdict,
bool l1Reachable = true,
ConfidenceLevel l1Confidence = ConfidenceLevel.High,
bool l2Resolved = true,
ConfidenceLevel l2Confidence = ConfidenceLevel.High,
bool l3Gated = false,
GatingOutcome l3Outcome = GatingOutcome.NotGated,
ConfidenceLevel l3Confidence = ConfidenceLevel.High,
ImmutableArray<GatingCondition>? conditions = null)
{
return new ReachabilityStack
{
Id = Guid.NewGuid().ToString("N"),
FindingId = "finding-123",
Symbol = CreateTestSymbol(),
StaticCallGraph = new ReachabilityLayer1
{
IsReachable = l1Reachable,
Confidence = l1Confidence,
AnalysisMethod = "static-dataflow"
},
BinaryResolution = new ReachabilityLayer2
{
IsResolved = l2Resolved,
Confidence = l2Confidence,
Reason = l2Resolved ? "Symbol found" : "Symbol not linked",
Resolution = l2Resolved ? new SymbolResolution("vulnerable_func", "libtest.so", "1.0.0", null, ResolutionMethod.DirectLink) : null
},
RuntimeGating = new ReachabilityLayer3
{
IsGated = l3Gated,
Outcome = l3Outcome,
Confidence = l3Confidence,
Conditions = conditions ?? []
},
Verdict = verdict,
AnalyzedAt = DateTimeOffset.UtcNow,
Explanation = $"Test stack with verdict {verdict}"
};
}
#region L1 Blocking (Static Unreachability) Tests
[Fact]
public async Task CreateResultAsync_L1Unreachable_CreatesSuppressionWitnessWithUnreachableType()
{
// Arrange
var stack = CreateStackWithVerdict(
StackVerdict.Unreachable,
l1Reachable: false,
l1Confidence: ConfidenceLevel.High);
var expectedWitness = CreateMockSuppressionWitness(SuppressionType.Unreachable);
_mockBuilder
.Setup(b => b.BuildUnreachableAsync(It.IsAny<UnreachabilityRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedWitness);
// Act
var result = await _factory.CreateResultAsync(stack, DefaultContext);
// Assert
result.Verdict.Should().Be(WitnessVerdict.NotAffected);
result.SuppressionWitness.Should().NotBeNull();
result.SuppressionWitness!.SuppressionType.Should().Be(SuppressionType.Unreachable);
result.PathWitness.Should().BeNull();
_mockBuilder.Verify(
b => b.BuildUnreachableAsync(
It.Is<UnreachabilityRequest>(r =>
r.VulnId == DefaultContext.VulnId &&
r.ComponentPurl == DefaultContext.ComponentPurl &&
r.UnreachableSymbol == stack.Symbol.Name),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task CreateResultAsync_L1LowConfidence_UsesNextBlockingLayer()
{
// Arrange - L1 unreachable but low confidence, L2 not resolved with high confidence
var stack = CreateStackWithVerdict(
StackVerdict.Unreachable,
l1Reachable: false,
l1Confidence: ConfidenceLevel.Low,
l2Resolved: false,
l2Confidence: ConfidenceLevel.High);
var expectedWitness = CreateMockSuppressionWitness(SuppressionType.FunctionAbsent);
_mockBuilder
.Setup(b => b.BuildFunctionAbsentAsync(It.IsAny<FunctionAbsentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedWitness);
// Act
var result = await _factory.CreateResultAsync(stack, DefaultContext);
// Assert
result.SuppressionWitness.Should().NotBeNull();
_mockBuilder.Verify(
b => b.BuildFunctionAbsentAsync(It.IsAny<FunctionAbsentRequest>(), It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region L2 Blocking (Function Absent) Tests
[Fact]
public async Task CreateResultAsync_L2NotResolved_CreatesSuppressionWitnessWithFunctionAbsentType()
{
// Arrange - L1 reachable but L2 not resolved
var stack = CreateStackWithVerdict(
StackVerdict.Unreachable,
l1Reachable: true,
l2Resolved: false,
l2Confidence: ConfidenceLevel.High);
var expectedWitness = CreateMockSuppressionWitness(SuppressionType.FunctionAbsent);
_mockBuilder
.Setup(b => b.BuildFunctionAbsentAsync(It.IsAny<FunctionAbsentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedWitness);
// Act
var result = await _factory.CreateResultAsync(stack, DefaultContext);
// Assert
result.Verdict.Should().Be(WitnessVerdict.NotAffected);
result.SuppressionWitness.Should().NotBeNull();
result.SuppressionWitness!.SuppressionType.Should().Be(SuppressionType.FunctionAbsent);
_mockBuilder.Verify(
b => b.BuildFunctionAbsentAsync(
It.Is<FunctionAbsentRequest>(r =>
r.VulnId == DefaultContext.VulnId &&
r.FunctionName == stack.Symbol.Name),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task CreateResultAsync_L2NotResolved_IncludesReason()
{
// Arrange
var stack = CreateStackWithVerdict(
StackVerdict.Unreachable,
l1Reachable: true,
l2Resolved: false,
l2Confidence: ConfidenceLevel.High);
var expectedWitness = CreateMockSuppressionWitness(SuppressionType.FunctionAbsent);
_mockBuilder
.Setup(b => b.BuildFunctionAbsentAsync(It.IsAny<FunctionAbsentRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedWitness);
// Act
await _factory.CreateResultAsync(stack, DefaultContext);
// Assert
_mockBuilder.Verify(
b => b.BuildFunctionAbsentAsync(
It.Is<FunctionAbsentRequest>(r => r.Justification == "Symbol not linked"),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region L3 Blocking (Runtime Gating) Tests
[Fact]
public async Task CreateResultAsync_L3Blocked_CreatesSuppressionWitnessWithGateBlockedType()
{
// Arrange - L1 reachable, L2 resolved, L3 blocked
var conditions = ImmutableArray.Create(
new GatingCondition(GatingType.FeatureFlag, "Feature disabled", "FEATURE_X", null, true, GatingStatus.Disabled),
new GatingCondition(GatingType.CapabilityCheck, "Admin required", null, null, true, GatingStatus.Enabled)
);
var stack = CreateStackWithVerdict(
StackVerdict.Unreachable,
l1Reachable: true,
l2Resolved: true,
l3Gated: true,
l3Outcome: GatingOutcome.Blocked,
l3Confidence: ConfidenceLevel.High,
conditions: conditions);
var expectedWitness = CreateMockSuppressionWitness(SuppressionType.GateBlocked);
_mockBuilder
.Setup(b => b.BuildGateBlockedAsync(It.IsAny<GateBlockedRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedWitness);
// Act
var result = await _factory.CreateResultAsync(stack, DefaultContext);
// Assert
result.Verdict.Should().Be(WitnessVerdict.NotAffected);
result.SuppressionWitness.Should().NotBeNull();
result.SuppressionWitness!.SuppressionType.Should().Be(SuppressionType.GateBlocked);
_mockBuilder.Verify(
b => b.BuildGateBlockedAsync(
It.Is<GateBlockedRequest>(r =>
r.DetectedGates.Count == 2 &&
r.GateCoveragePercent == 100),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task CreateResultAsync_L3ConditionalNotBlocked_DoesNotCreateGateSupression()
{
// Arrange - L3 is conditional (not definitively blocked)
var stack = CreateStackWithVerdict(
StackVerdict.Unreachable,
l1Reachable: false, // L1 blocks instead
l3Gated: true,
l3Outcome: GatingOutcome.Conditional,
l3Confidence: ConfidenceLevel.Medium);
var expectedWitness = CreateMockSuppressionWitness(SuppressionType.Unreachable);
_mockBuilder
.Setup(b => b.BuildUnreachableAsync(It.IsAny<UnreachabilityRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedWitness);
// Act
await _factory.CreateResultAsync(stack, DefaultContext);
// Assert - should create Unreachable (L1) not GateBlocked
_mockBuilder.Verify(
b => b.BuildUnreachableAsync(It.IsAny<UnreachabilityRequest>(), It.IsAny<CancellationToken>()),
Times.Once);
_mockBuilder.Verify(
b => b.BuildGateBlockedAsync(It.IsAny<GateBlockedRequest>(), It.IsAny<CancellationToken>()),
Times.Never);
}
#endregion
#region CreateUnknownResult Tests
[Fact]
public void CreateUnknownResult_ReturnsUnknownVerdict()
{
// Act
var result = _factory.CreateUnknownResult("Analysis was inconclusive");
// Assert
result.Verdict.Should().Be(WitnessVerdict.Unknown);
result.PathWitness.Should().BeNull();
result.SuppressionWitness.Should().BeNull();
}
[Fact]
public async Task CreateResultAsync_UnknownVerdict_ReturnsUnknownResult()
{
// Arrange
var stack = CreateStackWithVerdict(StackVerdict.Unknown);
// Act
var result = await _factory.CreateResultAsync(stack, DefaultContext);
// Assert
result.Verdict.Should().Be(WitnessVerdict.Unknown);
result.PathWitness.Should().BeNull();
result.SuppressionWitness.Should().BeNull();
}
#endregion
#region CreateAffectedResult Tests
[Fact]
public void CreateAffectedResult_WithPathWitness_ReturnsAffectedVerdict()
{
// Arrange
var pathWitness = new PathWitness
{
WitnessId = "wit:sha256:abc123",
Artifact = new WitnessArtifact { SbomDigest = "sbom:sha256:abc", ComponentPurl = "pkg:npm/test@1.0.0" },
Vuln = new WitnessVuln { Id = "CVE-2025-1234", Source = "NVD", AffectedRange = "< 2.0.0" },
Entrypoint = new WitnessEntrypoint { Kind = "http", Name = "GET /api", SymbolId = "sym:main" },
Path = [new PathStep { Symbol = "main", SymbolId = "sym:main" }],
Sink = new WitnessSink { Symbol = "vulnerable_func", SymbolId = "sym:vuln", SinkType = "injection" },
Evidence = new WitnessEvidence { CallgraphDigest = "graph:sha256:def" },
ObservedAt = DateTimeOffset.UtcNow
};
// Act
var result = _factory.CreateAffectedResult(pathWitness);
// Assert
result.Verdict.Should().Be(WitnessVerdict.Affected);
result.PathWitness.Should().BeSameAs(pathWitness);
result.SuppressionWitness.Should().BeNull();
}
[Fact]
public void CreateAffectedResult_NullPathWitness_ThrowsArgumentNullException()
{
// Act & Assert
var act = () => _factory.CreateAffectedResult(null!);
act.Should().Throw<ArgumentNullException>().WithParameterName("pathWitness");
}
[Fact]
public async Task CreateResultAsync_ExploitableVerdict_ReturnsUnknownAsPlaceholder()
{
// Arrange - Exploitable verdict returns Unknown placeholder (caller should build PathWitness)
var stack = CreateStackWithVerdict(StackVerdict.Exploitable);
// Act
var result = await _factory.CreateResultAsync(stack, DefaultContext);
// Assert - Returns Unknown as placeholder since PathWitness should be built separately
result.Verdict.Should().Be(WitnessVerdict.Unknown);
}
[Fact]
public async Task CreateResultAsync_LikelyExploitableVerdict_ReturnsUnknownAsPlaceholder()
{
// Arrange
var stack = CreateStackWithVerdict(StackVerdict.LikelyExploitable);
// Act
var result = await _factory.CreateResultAsync(stack, DefaultContext);
// Assert
result.Verdict.Should().Be(WitnessVerdict.Unknown);
}
#endregion
#region Fallback Behavior Tests
[Fact]
public async Task CreateResultAsync_NoSpecificBlocker_UsesFallbackUnreachable()
{
// Arrange - Unreachable but no specific layer clearly blocks
// (This can happen when multiple layers have medium confidence)
var stack = CreateStackWithVerdict(
StackVerdict.Unreachable,
l1Reachable: true,
l1Confidence: ConfidenceLevel.Medium,
l2Resolved: true,
l2Confidence: ConfidenceLevel.Medium,
l3Gated: false);
var expectedWitness = CreateMockSuppressionWitness(SuppressionType.Unreachable);
_mockBuilder
.Setup(b => b.BuildUnreachableAsync(It.IsAny<UnreachabilityRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedWitness);
// Act
var result = await _factory.CreateResultAsync(stack, DefaultContext);
// Assert - Falls back to generic unreachable
result.SuppressionWitness.Should().NotBeNull();
_mockBuilder.Verify(
b => b.BuildUnreachableAsync(
It.Is<UnreachabilityRequest>(r => r.Confidence == 0.5), // Low fallback confidence
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region Argument Validation Tests
[Fact]
public async Task CreateResultAsync_NullStack_ThrowsArgumentNullException()
{
// Act & Assert
var act = () => _factory.CreateResultAsync(null!, DefaultContext);
await act.Should().ThrowAsync<ArgumentNullException>().WithParameterName("stack");
}
[Fact]
public async Task CreateResultAsync_NullContext_ThrowsArgumentNullException()
{
// Arrange
var stack = CreateStackWithVerdict(StackVerdict.Unreachable);
// Act & Assert
var act = () => _factory.CreateResultAsync(stack, null!);
await act.Should().ThrowAsync<ArgumentNullException>().WithParameterName("context");
}
[Fact]
public void Constructor_NullBuilder_ThrowsArgumentNullException()
{
// Act & Assert
var act = () => new ReachabilityResultFactory(null!, _logger);
act.Should().Throw<ArgumentNullException>().WithParameterName("suppressionBuilder");
}
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNullException()
{
// Act & Assert
var act = () => new ReachabilityResultFactory(_mockBuilder.Object, null!);
act.Should().Throw<ArgumentNullException>().WithParameterName("logger");
}
#endregion
#region Confidence Mapping Tests
[Theory]
[InlineData(ConfidenceLevel.High, 0.95)]
[InlineData(ConfidenceLevel.Medium, 0.75)]
[InlineData(ConfidenceLevel.Low, 0.50)]
public async Task CreateResultAsync_MapsConfidenceCorrectly(ConfidenceLevel level, double expected)
{
// Arrange
var stack = CreateStackWithVerdict(
StackVerdict.Unreachable,
l1Reachable: false,
l1Confidence: level);
double capturedConfidence = 0;
_mockBuilder
.Setup(b => b.BuildUnreachableAsync(It.IsAny<UnreachabilityRequest>(), It.IsAny<CancellationToken>()))
.Callback<UnreachabilityRequest, CancellationToken>((r, _) => capturedConfidence = r.Confidence)
.ReturnsAsync(CreateMockSuppressionWitness(SuppressionType.Unreachable));
// Act
await _factory.CreateResultAsync(stack, DefaultContext);
// Assert
capturedConfidence.Should().Be(expected);
}
#endregion
#region Context Propagation Tests
[Fact]
public async Task CreateResultAsync_PropagatesContextCorrectly()
{
// Arrange
var context = new WitnessGenerationContext
{
SbomDigest = "sbom:sha256:custom",
ComponentPurl = "pkg:pypi/django@4.0.0",
VulnId = "CVE-2025-9999",
VulnSource = "OSV",
AffectedRange = ">= 3.0, < 4.1",
GraphDigest = "graph:sha256:custom123",
ImageDigest = "sha256:image"
};
var stack = CreateStackWithVerdict(
StackVerdict.Unreachable,
l1Reachable: false);
UnreachabilityRequest? capturedRequest = null;
_mockBuilder
.Setup(b => b.BuildUnreachableAsync(It.IsAny<UnreachabilityRequest>(), It.IsAny<CancellationToken>()))
.Callback<UnreachabilityRequest, CancellationToken>((r, _) => capturedRequest = r)
.ReturnsAsync(CreateMockSuppressionWitness(SuppressionType.Unreachable));
// Act
await _factory.CreateResultAsync(stack, context);
// Assert
capturedRequest.Should().NotBeNull();
capturedRequest!.SbomDigest.Should().Be(context.SbomDigest);
capturedRequest.ComponentPurl.Should().Be(context.ComponentPurl);
capturedRequest.VulnId.Should().Be(context.VulnId);
capturedRequest.VulnSource.Should().Be(context.VulnSource);
capturedRequest.AffectedRange.Should().Be(context.AffectedRange);
capturedRequest.GraphDigest.Should().Be(context.GraphDigest);
}
#endregion
#region Cancellation Tests
[Fact]
public async Task CreateResultAsync_PropagatesCancellation()
{
// Arrange
var stack = CreateStackWithVerdict(StackVerdict.Unreachable, l1Reachable: false);
var cts = new CancellationTokenSource();
var token = cts.Token;
CancellationToken capturedToken = default;
_mockBuilder
.Setup(b => b.BuildUnreachableAsync(It.IsAny<UnreachabilityRequest>(), It.IsAny<CancellationToken>()))
.Callback<UnreachabilityRequest, CancellationToken>((_, ct) => capturedToken = ct)
.ReturnsAsync(CreateMockSuppressionWitness(SuppressionType.Unreachable));
// Act
await _factory.CreateResultAsync(stack, DefaultContext, token);
// Assert
capturedToken.Should().Be(token);
}
#endregion
}

View File

@@ -9,7 +9,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />

View File

@@ -0,0 +1,309 @@
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Security;
using StellaOps.Attestor.Envelope;
using StellaOps.Scanner.Reachability.Witnesses;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Witnesses;
/// <summary>
/// Tests for <see cref="SuppressionDsseSigner"/>.
/// Sprint: SPRINT_20260106_001_002 (SUP-021)
/// Golden fixture tests for DSSE sign/verify of suppression witnesses.
/// </summary>
public sealed class SuppressionDsseSignerTests
{
/// <summary>
/// Creates a deterministic Ed25519 key pair for testing.
/// </summary>
private static (byte[] privateKey, byte[] publicKey) CreateTestKeyPair()
{
// Use a fixed seed for deterministic tests
var generator = new Ed25519KeyPairGenerator();
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom(new FixedRandomGenerator())));
var keyPair = generator.GenerateKeyPair();
var privateParams = (Ed25519PrivateKeyParameters)keyPair.Private;
var publicParams = (Ed25519PublicKeyParameters)keyPair.Public;
// Ed25519 private key = 32-byte seed + 32-byte public key
var privateKey = new byte[64];
privateParams.Encode(privateKey, 0);
var publicKey = publicParams.GetEncoded();
// Append public key to make 64-byte expanded form
Array.Copy(publicKey, 0, privateKey, 32, 32);
return (privateKey, publicKey);
}
private static SuppressionWitness CreateTestWitness()
{
return new SuppressionWitness
{
WitnessSchema = SuppressionWitnessSchema.Version,
WitnessId = "sup:sha256:test123",
Artifact = new WitnessArtifact
{
SbomDigest = "sbom:sha256:abc",
ComponentPurl = "pkg:npm/test@1.0.0"
},
Vuln = new WitnessVuln
{
Id = "CVE-2025-TEST",
Source = "NVD",
AffectedRange = "< 2.0.0"
},
SuppressionType = SuppressionType.Unreachable,
Evidence = new SuppressionEvidence
{
WitnessEvidence = new WitnessEvidence
{
CallgraphDigest = "graph:sha256:def",
BuildId = "StellaOps.Scanner/1.0.0"
},
Unreachability = new UnreachabilityEvidence
{
AnalyzedEntrypoints = 1,
UnreachableSymbol = "vuln_func",
AnalysisMethod = "static-dataflow",
GraphDigest = "graph:sha256:def"
}
},
Confidence = 0.95,
ObservedAt = new DateTimeOffset(2025, 1, 7, 12, 0, 0, TimeSpan.Zero),
Justification = "Test suppression witness"
};
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SignWitness_WithValidKey_ReturnsSuccess()
{
// Arrange
var witness = CreateTestWitness();
var (privateKey, publicKey) = CreateTestKeyPair();
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new SuppressionDsseSigner();
// Act
var result = signer.SignWitness(witness, key);
// Assert
Assert.True(result.IsSuccess, result.Error);
Assert.NotNull(result.Envelope);
Assert.Equal(SuppressionWitnessSchema.DssePayloadType, result.Envelope.PayloadType);
Assert.Single(result.Envelope.Signatures);
Assert.NotEmpty(result.PayloadBytes!);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyWitness_WithValidSignature_ReturnsSuccess()
{
// Arrange
var witness = CreateTestWitness();
var (privateKey, publicKey) = CreateTestKeyPair();
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new SuppressionDsseSigner();
// Sign the witness
var signResult = signer.SignWitness(witness, signingKey);
Assert.True(signResult.IsSuccess, signResult.Error);
// Create public key for verification
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
// Act
var verifyResult = signer.VerifyWitness(signResult.Envelope!, verifyKey);
// Assert
Assert.True(verifyResult.IsSuccess, verifyResult.Error);
Assert.NotNull(verifyResult.Witness);
Assert.Equal(witness.WitnessId, verifyResult.Witness.WitnessId);
Assert.Equal(witness.Vuln.Id, verifyResult.Witness.Vuln.Id);
Assert.Equal(witness.SuppressionType, verifyResult.Witness.SuppressionType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyWitness_WithWrongKey_ReturnsFails()
{
// Arrange
var witness = CreateTestWitness();
var (privateKey, publicKey) = CreateTestKeyPair();
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new SuppressionDsseSigner();
// Sign with first key
var signResult = signer.SignWitness(witness, signingKey);
Assert.True(signResult.IsSuccess);
// Try to verify with different key
var (_, wrongPublicKey) = CreateTestKeyPair();
var wrongKey = EnvelopeKey.CreateEd25519Verifier(wrongPublicKey);
// Act
var verifyResult = signer.VerifyWitness(signResult.Envelope!, wrongKey);
// Assert
Assert.False(verifyResult.IsSuccess);
Assert.NotNull(verifyResult.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyWitness_WithInvalidPayloadType_ReturnsFails()
{
// Arrange
var (privateKey, publicKey) = CreateTestKeyPair();
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new SuppressionDsseSigner();
// Create envelope with wrong payload type
var badEnvelope = new DsseEnvelope(
payloadType: "https://wrong.type/v1",
payload: "test"u8.ToArray(),
signatures: []);
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
// Act
var result = signer.VerifyWitness(badEnvelope, verifyKey);
// Assert
Assert.False(result.IsSuccess);
Assert.Contains("Invalid payload type", result.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyWitness_WithUnsupportedSchema_ReturnsFails()
{
// Arrange
var witness = CreateTestWitness() with
{
WitnessSchema = "stellaops.suppression.v99"
};
var (privateKey, publicKey) = CreateTestKeyPair();
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new SuppressionDsseSigner();
// Sign witness with wrong schema
var signResult = signer.SignWitness(witness, signingKey);
Assert.True(signResult.IsSuccess);
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
// Act
var verifyResult = signer.VerifyWitness(signResult.Envelope!, verifyKey);
// Assert
Assert.False(verifyResult.IsSuccess);
Assert.Contains("Unsupported witness schema", verifyResult.Error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SignWitness_WithNullWitness_ThrowsArgumentNullException()
{
// Arrange
var (privateKey, publicKey) = CreateTestKeyPair();
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var signer = new SuppressionDsseSigner();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => signer.SignWitness(null!, key));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SignWitness_WithNullKey_ThrowsArgumentNullException()
{
// Arrange
var witness = CreateTestWitness();
var signer = new SuppressionDsseSigner();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => signer.SignWitness(witness, null!));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyWitness_WithNullEnvelope_ThrowsArgumentNullException()
{
// Arrange
var (_, publicKey) = CreateTestKeyPair();
var key = EnvelopeKey.CreateEd25519Verifier(publicKey);
var signer = new SuppressionDsseSigner();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => signer.VerifyWitness(null!, key));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifyWitness_WithNullKey_ThrowsArgumentNullException()
{
// Arrange
var envelope = new DsseEnvelope(
payloadType: SuppressionWitnessSchema.DssePayloadType,
payload: "test"u8.ToArray(),
signatures: []);
var signer = new SuppressionDsseSigner();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => signer.VerifyWitness(envelope, null!));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SignAndVerify_ProducesVerifiableEnvelope()
{
// Arrange
var witness = CreateTestWitness();
var (privateKey, publicKey) = CreateTestKeyPair();
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
var signer = new SuppressionDsseSigner();
// Act
var signResult = signer.SignWitness(witness, signingKey);
var verifyResult = signer.VerifyWitness(signResult.Envelope!, verifyKey);
// Assert
Assert.True(signResult.IsSuccess);
Assert.True(verifyResult.IsSuccess);
Assert.NotNull(verifyResult.Witness);
Assert.Equal(witness.WitnessId, verifyResult.Witness.WitnessId);
Assert.Equal(witness.Artifact.ComponentPurl, verifyResult.Witness.Artifact.ComponentPurl);
Assert.Equal(witness.Evidence.Unreachability?.UnreachableSymbol,
verifyResult.Witness.Evidence.Unreachability?.UnreachableSymbol);
}
private sealed class FixedRandomGenerator : Org.BouncyCastle.Crypto.Prng.IRandomGenerator
{
private byte _value = 0x42;
public void AddSeedMaterial(byte[] seed) { }
public void AddSeedMaterial(ReadOnlySpan<byte> seed) { }
public void AddSeedMaterial(long seed) { }
public void NextBytes(byte[] bytes) => NextBytes(bytes, 0, bytes.Length);
public void NextBytes(byte[] bytes, int start, int len)
{
for (int i = start; i < start + len; i++)
{
bytes[i] = _value++;
}
}
public void NextBytes(Span<byte> bytes)
{
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] = _value++;
}
}
}
}

View File

@@ -0,0 +1,461 @@
using System.Security.Cryptography;
using FluentAssertions;
using Moq;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability.Witnesses;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Witnesses;
/// <summary>
/// Tests for SuppressionWitnessBuilder.
/// Sprint: SPRINT_20260106_001_002 (SUP-020)
/// </summary>
[Trait("Category", "Unit")]
public sealed class SuppressionWitnessBuilderTests
{
private readonly Mock<TimeProvider> _mockTimeProvider;
private readonly SuppressionWitnessBuilder _builder;
private static readonly DateTimeOffset FixedTime = new(2025, 1, 7, 12, 0, 0, TimeSpan.Zero);
/// <summary>
/// Test implementation of ICryptoHash.
/// Note: Moq can't mock ReadOnlySpan parameters, so we use a concrete implementation.
/// </summary>
private sealed class TestCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
=> SHA256.HashData(data);
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
=> await SHA256.HashDataAsync(stream, cancellationToken);
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
=> Convert.ToHexString(await ComputeHashAsync(stream, algorithmId, cancellationToken)).ToLowerInvariant();
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHash(data);
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashHex(data);
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashBase64(data);
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> ComputeHashAsync(stream, null, cancellationToken);
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> ComputeHashHexAsync(stream, null, cancellationToken);
public string GetAlgorithmForPurpose(string purpose)
=> "sha256";
public string GetHashPrefix(string purpose)
=> "sha256:";
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> GetHashPrefix(purpose) + ComputeHashHex(data);
}
public SuppressionWitnessBuilderTests()
{
_mockTimeProvider = new Mock<TimeProvider>();
_mockTimeProvider
.Setup(x => x.GetUtcNow())
.Returns(FixedTime);
_builder = new SuppressionWitnessBuilder(new TestCryptoHash(), _mockTimeProvider.Object);
}
[Fact]
public async Task BuildUnreachableAsync_CreatesValidWitness()
{
// Arrange
var request = new UnreachabilityRequest
{
SbomDigest = "sbom:sha256:abc",
ComponentPurl = "pkg:npm/test@1.0.0",
VulnId = "CVE-2025-1234",
VulnSource = "NVD",
AffectedRange = "< 2.0.0",
Justification = "Unreachable test",
GraphDigest = "graph:sha256:def",
AnalyzedEntrypoints = 2,
UnreachableSymbol = "vulnerable_func",
AnalysisMethod = "static-dataflow",
Confidence = 0.95
};
// Act
var result = await _builder.BuildUnreachableAsync(request);
// Assert
result.Should().NotBeNull();
result.SuppressionType.Should().Be(SuppressionType.Unreachable);
result.Artifact.SbomDigest.Should().Be("sbom:sha256:abc");
result.Artifact.ComponentPurl.Should().Be("pkg:npm/test@1.0.0");
result.Vuln.Id.Should().Be("CVE-2025-1234");
result.Vuln.Source.Should().Be("NVD");
result.Confidence.Should().Be(0.95);
result.ObservedAt.Should().Be(FixedTime);
result.WitnessId.Should().StartWith("sup:sha256:");
result.Evidence.Unreachability.Should().NotBeNull();
result.Evidence.Unreachability!.UnreachableSymbol.Should().Be("vulnerable_func");
result.Evidence.Unreachability.AnalyzedEntrypoints.Should().Be(2);
}
[Fact]
public async Task BuildPatchedSymbolAsync_CreatesValidWitness()
{
// Arrange
var request = new PatchedSymbolRequest
{
SbomDigest = "sbom:sha256:abc",
ComponentPurl = "pkg:deb/openssl@1.1.1",
VulnId = "CVE-2025-5678",
VulnSource = "Debian",
AffectedRange = "<= 1.1.0",
Justification = "Backported security patch",
VulnerableSymbol = "ssl_encrypt_old",
PatchedSymbol = "ssl_encrypt_new",
SymbolDiff = "diff --git a/ssl.c b/ssl.c\n...",
PatchRef = "debian/patches/CVE-2025-5678.patch",
Confidence = 0.99
};
// Act
var result = await _builder.BuildPatchedSymbolAsync(request);
// Assert
result.Should().NotBeNull();
result.SuppressionType.Should().Be(SuppressionType.PatchedSymbol);
result.Evidence.PatchedSymbol.Should().NotBeNull();
result.Evidence.PatchedSymbol!.VulnerableSymbol.Should().Be("ssl_encrypt_old");
result.Evidence.PatchedSymbol.PatchedSymbol.Should().Be("ssl_encrypt_new");
result.Evidence.PatchedSymbol.PatchRef.Should().Be("debian/patches/CVE-2025-5678.patch");
}
[Fact]
public async Task BuildFunctionAbsentAsync_CreatesValidWitness()
{
// Arrange
var request = new FunctionAbsentRequest
{
SbomDigest = "sbom:sha256:xyz",
ComponentPurl = "pkg:generic/app@3.0.0",
VulnId = "GHSA-1234-5678-90ab",
VulnSource = "GitHub",
AffectedRange = "< 3.0.0",
Justification = "Function removed in 3.0.0",
FunctionName = "deprecated_api",
BinaryDigest = "binary:sha256:123",
VerificationMethod = "symbol-table-inspection",
Confidence = 1.0
};
// Act
var result = await _builder.BuildFunctionAbsentAsync(request);
// Assert
result.Should().NotBeNull();
result.SuppressionType.Should().Be(SuppressionType.FunctionAbsent);
result.Evidence.FunctionAbsent.Should().NotBeNull();
result.Evidence.FunctionAbsent!.FunctionName.Should().Be("deprecated_api");
result.Evidence.FunctionAbsent.BinaryDigest.Should().Be("binary:sha256:123");
result.Evidence.FunctionAbsent.VerificationMethod.Should().Be("symbol-table-inspection");
}
[Fact]
public async Task BuildGateBlockedAsync_CreatesValidWitness()
{
// Arrange
var gates = new List<DetectedGate>
{
new() { Type = "permission", GuardSymbol = "check_admin", Confidence = 0.9, Detail = "Requires admin role" },
new() { Type = "feature-flag", GuardSymbol = "FLAG_LEGACY_MODE", Confidence = 0.85, Detail = "Disabled in production" }
};
var request = new GateBlockedRequest
{
SbomDigest = "sbom:sha256:gates",
ComponentPurl = "pkg:npm/webapp@2.0.0",
VulnId = "CVE-2025-9999",
VulnSource = "NVD",
AffectedRange = "*",
Justification = "All paths protected by gates",
DetectedGates = gates,
GateCoveragePercent = 100,
Effectiveness = "All vulnerable paths blocked",
Confidence = 0.88
};
// Act
var result = await _builder.BuildGateBlockedAsync(request);
// Assert
result.Should().NotBeNull();
result.SuppressionType.Should().Be(SuppressionType.GateBlocked);
result.Evidence.GateBlocked.Should().NotBeNull();
result.Evidence.GateBlocked!.DetectedGates.Should().HaveCount(2);
result.Evidence.GateBlocked.GateCoveragePercent.Should().Be(100);
result.Evidence.GateBlocked.Effectiveness.Should().Be("All vulnerable paths blocked");
}
[Fact]
public async Task BuildFeatureFlagDisabledAsync_CreatesValidWitness()
{
// Arrange
var request = new FeatureFlagRequest
{
SbomDigest = "sbom:sha256:flags",
ComponentPurl = "pkg:golang/service@1.5.0",
VulnId = "CVE-2025-8888",
VulnSource = "OSV",
AffectedRange = "< 2.0.0",
Justification = "Vulnerable feature disabled",
FlagName = "ENABLE_EXPERIMENTAL_API",
FlagState = "false",
ConfigSource = "/etc/app/config.yaml",
GuardedPath = "src/api/experimental.go:45",
Confidence = 0.92
};
// Act
var result = await _builder.BuildFeatureFlagDisabledAsync(request);
// Assert
result.Should().NotBeNull();
result.SuppressionType.Should().Be(SuppressionType.FeatureFlagDisabled);
result.Evidence.FeatureFlag.Should().NotBeNull();
result.Evidence.FeatureFlag!.FlagName.Should().Be("ENABLE_EXPERIMENTAL_API");
result.Evidence.FeatureFlag.FlagState.Should().Be("false");
result.Evidence.FeatureFlag.ConfigSource.Should().Be("/etc/app/config.yaml");
}
[Fact]
public async Task BuildFromVexStatementAsync_CreatesValidWitness()
{
// Arrange
var request = new VexStatementRequest
{
SbomDigest = "sbom:sha256:vex",
ComponentPurl = "pkg:maven/org.example/lib@1.0.0",
VulnId = "CVE-2025-7777",
VulnSource = "NVD",
AffectedRange = "*",
Justification = "Vendor VEX statement: not affected",
VexId = "vex:vendor/2025-001",
VexAuthor = "vendor@example.com",
VexStatus = "not_affected",
VexJustification = "vulnerable_code_not_present",
VexDigest = "vex:sha256:vendor001",
Confidence = 0.97
};
// Act
var result = await _builder.BuildFromVexStatementAsync(request);
// Assert
result.Should().NotBeNull();
result.SuppressionType.Should().Be(SuppressionType.VexNotAffected);
result.Evidence.VexStatement.Should().NotBeNull();
result.Evidence.VexStatement!.VexId.Should().Be("vex:vendor/2025-001");
result.Evidence.VexStatement.VexAuthor.Should().Be("vendor@example.com");
result.Evidence.VexStatement.VexStatus.Should().Be("not_affected");
}
[Fact]
public async Task BuildVersionNotAffectedAsync_CreatesValidWitness()
{
// Arrange
var request = new VersionRangeRequest
{
SbomDigest = "sbom:sha256:version",
ComponentPurl = "pkg:pypi/django@4.2.0",
VulnId = "CVE-2025-6666",
VulnSource = "OSV",
AffectedRange = ">= 3.0.0, < 4.0.0",
Justification = "Installed version outside affected range",
InstalledVersion = "4.2.0",
ComparisonResult = "not_affected",
VersionScheme = "semver",
Confidence = 1.0
};
// Act
var result = await _builder.BuildVersionNotAffectedAsync(request);
// Assert
result.Should().NotBeNull();
result.SuppressionType.Should().Be(SuppressionType.VersionNotAffected);
result.Evidence.VersionRange.Should().NotBeNull();
result.Evidence.VersionRange!.InstalledVersion.Should().Be("4.2.0");
result.Evidence.VersionRange.AffectedRange.Should().Be(">= 3.0.0, < 4.0.0");
result.Evidence.VersionRange.ComparisonResult.Should().Be("not_affected");
}
[Fact]
public async Task BuildLinkerGarbageCollectedAsync_CreatesValidWitness()
{
// Arrange
var request = new LinkerGcRequest
{
SbomDigest = "sbom:sha256:linker",
ComponentPurl = "pkg:generic/static-binary@1.0.0",
VulnId = "CVE-2025-5555",
VulnSource = "NVD",
AffectedRange = "*",
Justification = "Vulnerable code removed by linker GC",
CollectedSymbol = "unused_vulnerable_func",
LinkerLog = "gc: collected unused_vulnerable_func",
Linker = "GNU ld 2.40",
BuildFlags = "-Wl,--gc-sections -ffunction-sections",
Confidence = 0.94
};
// Act
var result = await _builder.BuildLinkerGarbageCollectedAsync(request);
// Assert
result.Should().NotBeNull();
result.SuppressionType.Should().Be(SuppressionType.LinkerGarbageCollected);
result.Evidence.LinkerGc.Should().NotBeNull();
result.Evidence.LinkerGc!.CollectedSymbol.Should().Be("unused_vulnerable_func");
result.Evidence.LinkerGc.Linker.Should().Be("GNU ld 2.40");
result.Evidence.LinkerGc.BuildFlags.Should().Be("-Wl,--gc-sections -ffunction-sections");
}
[Fact]
public async Task BuildUnreachableAsync_ClampsConfidenceToValidRange()
{
// Arrange
var request = new UnreachabilityRequest
{
SbomDigest = "sbom:sha256:abc",
ComponentPurl = "pkg:npm/test@1.0.0",
VulnId = "CVE-2025-1234",
VulnSource = "NVD",
AffectedRange = "< 2.0.0",
Justification = "Confidence test",
GraphDigest = "graph:sha256:def",
AnalyzedEntrypoints = 1,
UnreachableSymbol = "vulnerable_func",
AnalysisMethod = "static",
Confidence = 1.5 // Out of range
};
// Act
var result = await _builder.BuildUnreachableAsync(request);
// Assert
result.Confidence.Should().Be(1.0); // Clamped to max
}
[Fact]
public async Task BuildAsync_GeneratesDeterministicWitnessId()
{
// Arrange
var request = new UnreachabilityRequest
{
SbomDigest = "sbom:sha256:abc",
ComponentPurl = "pkg:npm/test@1.0.0",
VulnId = "CVE-2025-1234",
VulnSource = "NVD",
AffectedRange = "< 2.0.0",
Justification = "ID test",
GraphDigest = "graph:sha256:def",
AnalyzedEntrypoints = 1,
UnreachableSymbol = "func",
AnalysisMethod = "static",
Confidence = 0.95
};
// Act
var result1 = await _builder.BuildUnreachableAsync(request);
var result2 = await _builder.BuildUnreachableAsync(request);
// Assert
result1.WitnessId.Should().Be(result2.WitnessId);
result1.WitnessId.Should().StartWith("sup:sha256:");
}
[Fact]
public async Task BuildAsync_SetsObservedAtFromTimeProvider()
{
// Arrange
var request = new UnreachabilityRequest
{
SbomDigest = "sbom:sha256:abc",
ComponentPurl = "pkg:npm/test@1.0.0",
VulnId = "CVE-2025-1234",
VulnSource = "NVD",
AffectedRange = "< 2.0.0",
Justification = "Time test",
GraphDigest = "graph:sha256:def",
AnalyzedEntrypoints = 1,
UnreachableSymbol = "func",
AnalysisMethod = "static",
Confidence = 0.95
};
// Act
var result = await _builder.BuildUnreachableAsync(request);
// Assert
result.ObservedAt.Should().Be(FixedTime);
}
[Fact]
public async Task BuildAsync_PreservesExpiresAtWhenProvided()
{
// Arrange
var expiresAt = DateTimeOffset.UtcNow.AddDays(30);
var request = new UnreachabilityRequest
{
SbomDigest = "sbom:sha256:abc",
ComponentPurl = "pkg:npm/test@1.0.0",
VulnId = "CVE-2025-1234",
VulnSource = "NVD",
AffectedRange = "< 2.0.0",
Justification = "Expiry test",
GraphDigest = "graph:sha256:def",
AnalyzedEntrypoints = 1,
UnreachableSymbol = "func",
AnalysisMethod = "static",
Confidence = 0.95,
ExpiresAt = expiresAt
};
// Act
var result = await _builder.BuildUnreachableAsync(request);
// Assert
result.ExpiresAt.Should().Be(expiresAt);
}
[Fact]
public void Constructor_ThrowsWhenCryptoHashIsNull()
{
// Act & Assert
var act = () => new SuppressionWitnessBuilder(null!, TimeProvider.System);
act.Should().Throw<ArgumentNullException>().WithParameterName("cryptoHash");
}
[Fact]
public void Constructor_ThrowsWhenTimeProviderIsNull()
{
// Arrange
var mockHash = new Mock<ICryptoHash>();
// Act & Assert
var act = () => new SuppressionWitnessBuilder(mockHash.Object, null!);
act.Should().Throw<ArgumentNullException>().WithParameterName("timeProvider");
}
}

View File

@@ -0,0 +1,533 @@
// <copyright file="SuppressionWitnessIdPropertyTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// SuppressionWitnessIdPropertyTests.cs
// Sprint: SPRINT_20260106_001_002_SCANNER
// Task: SUP-024 - Write property tests: witness ID determinism
// Description: Property-based tests ensuring witness IDs are deterministic,
// content-addressed, and follow the expected format.
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using FluentAssertions;
using FsCheck.Xunit;
using Moq;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability.Witnesses;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Witnesses;
/// <summary>
/// Property-based tests for SuppressionWitness ID determinism.
/// Uses FsCheck to verify properties across many random inputs.
/// </summary>
[Trait("Category", "Property")]
public sealed class SuppressionWitnessIdPropertyTests
{
private static readonly DateTimeOffset FixedTime = new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
/// <summary>
/// Test implementation of ICryptoHash that uses real SHA256 for determinism verification.
/// </summary>
private sealed class TestCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
=> SHA256.HashData(data);
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
=> await SHA256.HashDataAsync(stream, cancellationToken);
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
=> Convert.ToHexString(await ComputeHashAsync(stream, algorithmId, cancellationToken)).ToLowerInvariant();
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHash(data);
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashHex(data);
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashBase64(data);
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> ComputeHashAsync(stream, null, cancellationToken);
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> ComputeHashHexAsync(stream, null, cancellationToken);
public string GetAlgorithmForPurpose(string purpose)
=> "sha256";
public string GetHashPrefix(string purpose)
=> "sha256:";
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> GetHashPrefix(purpose) + ComputeHashHex(data);
}
private static SuppressionWitnessBuilder CreateBuilder()
{
var timeProvider = new Mock<TimeProvider>();
timeProvider.Setup(x => x.GetUtcNow()).Returns(FixedTime);
return new SuppressionWitnessBuilder(new TestCryptoHash(), timeProvider.Object);
}
#region Determinism Properties
[Property(MaxTest = 100)]
public bool SameInputs_AlwaysProduceSameWitnessId(string sbomDigest, string componentPurl, string vulnId)
{
if (string.IsNullOrWhiteSpace(sbomDigest) ||
string.IsNullOrWhiteSpace(componentPurl) ||
string.IsNullOrWhiteSpace(vulnId))
{
return true; // Skip invalid inputs
}
var builder = CreateBuilder();
var request = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId);
var result1 = builder.BuildUnreachableAsync(request).GetAwaiter().GetResult();
var result2 = builder.BuildUnreachableAsync(request).GetAwaiter().GetResult();
return result1.WitnessId == result2.WitnessId;
}
[Property(MaxTest = 100)]
public bool DifferentSbomDigest_ProducesDifferentWitnessId(
string sbomDigest1, string sbomDigest2, string componentPurl, string vulnId)
{
if (string.IsNullOrWhiteSpace(sbomDigest1) ||
string.IsNullOrWhiteSpace(sbomDigest2) ||
string.IsNullOrWhiteSpace(componentPurl) ||
string.IsNullOrWhiteSpace(vulnId) ||
sbomDigest1 == sbomDigest2)
{
return true; // Skip invalid or same inputs
}
var builder = CreateBuilder();
var request1 = CreateUnreachabilityRequest(sbomDigest1, componentPurl, vulnId);
var request2 = CreateUnreachabilityRequest(sbomDigest2, componentPurl, vulnId);
var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult();
var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult();
return result1.WitnessId != result2.WitnessId;
}
[Property(MaxTest = 100)]
public bool DifferentComponentPurl_ProducesDifferentWitnessId(
string sbomDigest, string componentPurl1, string componentPurl2, string vulnId)
{
if (string.IsNullOrWhiteSpace(sbomDigest) ||
string.IsNullOrWhiteSpace(componentPurl1) ||
string.IsNullOrWhiteSpace(componentPurl2) ||
string.IsNullOrWhiteSpace(vulnId) ||
componentPurl1 == componentPurl2)
{
return true; // Skip invalid or same inputs
}
var builder = CreateBuilder();
var request1 = CreateUnreachabilityRequest(sbomDigest, componentPurl1, vulnId);
var request2 = CreateUnreachabilityRequest(sbomDigest, componentPurl2, vulnId);
var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult();
var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult();
return result1.WitnessId != result2.WitnessId;
}
[Property(MaxTest = 100)]
public bool DifferentVulnId_ProducesDifferentWitnessId(
string sbomDigest, string componentPurl, string vulnId1, string vulnId2)
{
if (string.IsNullOrWhiteSpace(sbomDigest) ||
string.IsNullOrWhiteSpace(componentPurl) ||
string.IsNullOrWhiteSpace(vulnId1) ||
string.IsNullOrWhiteSpace(vulnId2) ||
vulnId1 == vulnId2)
{
return true; // Skip invalid or same inputs
}
var builder = CreateBuilder();
var request1 = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId1);
var request2 = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId2);
var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult();
var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult();
return result1.WitnessId != result2.WitnessId;
}
#endregion
#region Format Properties
[Property(MaxTest = 100)]
public bool WitnessId_AlwaysStartsWithSupPrefix(string sbomDigest, string componentPurl, string vulnId)
{
if (string.IsNullOrWhiteSpace(sbomDigest) ||
string.IsNullOrWhiteSpace(componentPurl) ||
string.IsNullOrWhiteSpace(vulnId))
{
return true; // Skip invalid inputs
}
var builder = CreateBuilder();
var request = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId);
var result = builder.BuildUnreachableAsync(request).GetAwaiter().GetResult();
return result.WitnessId.StartsWith("sup:sha256:");
}
[Property(MaxTest = 100)]
public bool WitnessId_ContainsValidHexDigest(string sbomDigest, string componentPurl, string vulnId)
{
if (string.IsNullOrWhiteSpace(sbomDigest) ||
string.IsNullOrWhiteSpace(componentPurl) ||
string.IsNullOrWhiteSpace(vulnId))
{
return true; // Skip invalid inputs
}
var builder = CreateBuilder();
var request = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId);
var result = builder.BuildUnreachableAsync(request).GetAwaiter().GetResult();
// Extract hex part after "sup:sha256:"
var hexPart = result.WitnessId["sup:sha256:".Length..];
// Should be valid lowercase hex and have correct length (SHA256 = 64 hex chars)
return hexPart.Length == 64 &&
hexPart.All(c => char.IsAsciiHexDigitLower(c) || char.IsDigit(c));
}
#endregion
#region Suppression Type Independence
[Property(MaxTest = 50)]
public bool DifferentSuppressionTypes_WithSameArtifactAndVuln_ProduceDifferentWitnessIds(
string sbomDigest, string componentPurl, string vulnId)
{
if (string.IsNullOrWhiteSpace(sbomDigest) ||
string.IsNullOrWhiteSpace(componentPurl) ||
string.IsNullOrWhiteSpace(vulnId))
{
return true; // Skip invalid inputs
}
var builder = CreateBuilder();
var unreachableRequest = CreateUnreachabilityRequest(sbomDigest, componentPurl, vulnId);
var versionRequest = new VersionRangeRequest
{
SbomDigest = sbomDigest,
ComponentPurl = componentPurl,
VulnId = vulnId,
VulnSource = "NVD",
AffectedRange = "< 2.0.0",
Justification = "Version not affected",
InstalledVersion = "2.0.0",
ComparisonResult = "not_affected",
VersionScheme = "semver",
Confidence = 1.0
};
var unreachableResult = builder.BuildUnreachableAsync(unreachableRequest).GetAwaiter().GetResult();
var versionResult = builder.BuildVersionNotAffectedAsync(versionRequest).GetAwaiter().GetResult();
// Different suppression types should produce different witness IDs
return unreachableResult.WitnessId != versionResult.WitnessId;
}
#endregion
#region Content-Addressed Behavior
[Fact]
public async Task WitnessId_IncludesObservedAtInHash()
{
// The witness ID is content-addressed over the entire witness document,
// including ObservedAt. Different timestamps produce different IDs.
// This ensures audit trail integrity.
// Arrange
var time1 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var time2 = new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero);
var timeProvider1 = new Mock<TimeProvider>();
timeProvider1.Setup(x => x.GetUtcNow()).Returns(time1);
var timeProvider2 = new Mock<TimeProvider>();
timeProvider2.Setup(x => x.GetUtcNow()).Returns(time2);
var builder1 = new SuppressionWitnessBuilder(new TestCryptoHash(), timeProvider1.Object);
var builder2 = new SuppressionWitnessBuilder(new TestCryptoHash(), timeProvider2.Object);
var request = CreateUnreachabilityRequest("sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234");
// Act
var result1 = await builder1.BuildUnreachableAsync(request);
var result2 = await builder2.BuildUnreachableAsync(request);
// Assert - different timestamps produce different witness IDs (content-addressed)
result1.WitnessId.Should().NotBe(result2.WitnessId);
result1.ObservedAt.Should().NotBe(result2.ObservedAt);
// But both should still be valid witness IDs
result1.WitnessId.Should().StartWith("sup:sha256:");
result2.WitnessId.Should().StartWith("sup:sha256:");
}
[Fact]
public async Task WitnessId_SameTimestamp_ProducesSameId()
{
// With the same timestamp, the witness ID should be deterministic
var fixedTime = new DateTimeOffset(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);
var timeProvider = new Mock<TimeProvider>();
timeProvider.Setup(x => x.GetUtcNow()).Returns(fixedTime);
var builder = new SuppressionWitnessBuilder(new TestCryptoHash(), timeProvider.Object);
var request = CreateUnreachabilityRequest("sbom:sha256:test", "pkg:npm/lib@1.0.0", "CVE-2026-5555");
// Act
var result1 = await builder.BuildUnreachableAsync(request);
var result2 = await builder.BuildUnreachableAsync(request);
// Assert - same inputs with same timestamp = same ID
result1.WitnessId.Should().Be(result2.WitnessId);
}
[Property(MaxTest = 50)]
public bool WitnessId_IncludesConfidenceInHash(double confidence1, double confidence2)
{
// Skip invalid doubles (infinity, NaN)
if (!double.IsFinite(confidence1) || !double.IsFinite(confidence2))
{
return true;
}
// The witness ID is content-addressed over the entire witness including confidence.
// Different confidence values produce different IDs.
// Clamp to valid range [0, 1] but ensure they're different
confidence1 = Math.Clamp(Math.Abs(confidence1) % 0.5, 0.01, 0.49);
confidence2 = Math.Clamp(Math.Abs(confidence2) % 0.5 + 0.5, 0.51, 1.0);
var builder = CreateBuilder();
var request1 = CreateUnreachabilityRequest(
"sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234",
confidence: confidence1);
var request2 = CreateUnreachabilityRequest(
"sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234",
confidence: confidence2);
var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult();
var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult();
// Different confidence values produce different witness IDs
return result1.WitnessId != result2.WitnessId;
}
[Property(MaxTest = 50)]
public bool WitnessId_SameConfidence_ProducesSameId(double confidence)
{
// Skip invalid doubles (infinity, NaN)
if (!double.IsFinite(confidence))
{
return true;
}
// Same confidence should produce same witness ID
confidence = Math.Clamp(Math.Abs(confidence) % 1.0, 0.01, 1.0);
var builder = CreateBuilder();
var request1 = CreateUnreachabilityRequest(
"sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234",
confidence: confidence);
var request2 = CreateUnreachabilityRequest(
"sbom:sha256:abc", "pkg:npm/test@1.0.0", "CVE-2026-1234",
confidence: confidence);
var result1 = builder.BuildUnreachableAsync(request1).GetAwaiter().GetResult();
var result2 = builder.BuildUnreachableAsync(request2).GetAwaiter().GetResult();
return result1.WitnessId == result2.WitnessId;
}
#endregion
#region Collision Resistance
[Fact]
public async Task GeneratedWitnessIds_AreUnique_AcrossManyInputs()
{
// Arrange
var builder = CreateBuilder();
var witnessIds = new HashSet<string>();
var iterations = 1000;
// Act
for (int i = 0; i < iterations; i++)
{
var request = CreateUnreachabilityRequest(
$"sbom:sha256:{i:x8}",
$"pkg:npm/test@{i}.0.0",
$"CVE-2026-{i:D4}");
var result = await builder.BuildUnreachableAsync(request);
witnessIds.Add(result.WitnessId);
}
// Assert - All witness IDs should be unique (no collisions)
witnessIds.Should().HaveCount(iterations);
}
#endregion
#region Cross-Builder Determinism
[Fact]
public async Task DifferentBuilderInstances_SameInputs_ProduceSameWitnessId()
{
// Arrange
var builder1 = CreateBuilder();
var builder2 = CreateBuilder();
var request = CreateUnreachabilityRequest(
"sbom:sha256:determinism",
"pkg:npm/determinism@1.0.0",
"CVE-2026-0001");
// Act
var result1 = await builder1.BuildUnreachableAsync(request);
var result2 = await builder2.BuildUnreachableAsync(request);
// Assert
result1.WitnessId.Should().Be(result2.WitnessId);
}
#endregion
#region All Suppression Types Produce Valid IDs
[Fact]
public async Task AllSuppressionTypes_ProduceValidWitnessIds()
{
// Arrange
var builder = CreateBuilder();
// Act & Assert - Test each suppression type
var unreachable = await builder.BuildUnreachableAsync(new UnreachabilityRequest
{
SbomDigest = "sbom:sha256:ur",
ComponentPurl = "pkg:npm/test@1.0.0",
VulnId = "CVE-2026-0001",
VulnSource = "NVD",
AffectedRange = "< 2.0.0",
Justification = "Unreachable",
GraphDigest = "graph:sha256:def",
AnalyzedEntrypoints = 1,
UnreachableSymbol = "func",
AnalysisMethod = "static",
Confidence = 0.95
});
unreachable.WitnessId.Should().StartWith("sup:sha256:");
var patched = await builder.BuildPatchedSymbolAsync(new PatchedSymbolRequest
{
SbomDigest = "sbom:sha256:ps",
ComponentPurl = "pkg:deb/openssl@1.1.1",
VulnId = "CVE-2026-0002",
VulnSource = "Debian",
AffectedRange = "<= 1.1.0",
Justification = "Backported",
VulnerableSymbol = "old_func",
PatchedSymbol = "new_func",
SymbolDiff = "diff",
PatchRef = "debian/patches/fix.patch",
Confidence = 0.99
});
patched.WitnessId.Should().StartWith("sup:sha256:");
var functionAbsent = await builder.BuildFunctionAbsentAsync(new FunctionAbsentRequest
{
SbomDigest = "sbom:sha256:fa",
ComponentPurl = "pkg:generic/app@3.0.0",
VulnId = "CVE-2026-0003",
VulnSource = "GitHub",
AffectedRange = "< 3.0.0",
Justification = "Function removed",
FunctionName = "deprecated_api",
BinaryDigest = "binary:sha256:123",
VerificationMethod = "symbol-table",
Confidence = 1.0
});
functionAbsent.WitnessId.Should().StartWith("sup:sha256:");
var versionNotAffected = await builder.BuildVersionNotAffectedAsync(new VersionRangeRequest
{
SbomDigest = "sbom:sha256:vna",
ComponentPurl = "pkg:pypi/django@4.2.0",
VulnId = "CVE-2026-0004",
VulnSource = "OSV",
AffectedRange = ">= 3.0.0, < 4.0.0",
Justification = "Version outside range",
InstalledVersion = "4.2.0",
ComparisonResult = "not_affected",
VersionScheme = "semver",
Confidence = 1.0
});
versionNotAffected.WitnessId.Should().StartWith("sup:sha256:");
// Verify all IDs are unique
var allIds = new[] { unreachable.WitnessId, patched.WitnessId, functionAbsent.WitnessId, versionNotAffected.WitnessId };
allIds.Should().OnlyHaveUniqueItems();
}
#endregion
#region Helper Methods
private static UnreachabilityRequest CreateUnreachabilityRequest(
string sbomDigest,
string componentPurl,
string vulnId,
double confidence = 0.95)
{
return new UnreachabilityRequest
{
SbomDigest = sbomDigest,
ComponentPurl = componentPurl,
VulnId = vulnId,
VulnSource = "NVD",
AffectedRange = "< 2.0.0",
Justification = "Property test",
GraphDigest = "graph:sha256:fixed",
AnalyzedEntrypoints = 1,
UnreachableSymbol = "vulnerable_func",
AnalysisMethod = "static",
Confidence = confidence
};
}
#endregion
}

View File

@@ -0,0 +1,186 @@
// <copyright file="ScannerSchemaEvolutionTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
// Task: CCUT-009
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TestKit;
using StellaOps.Testing.SchemaEvolution;
using Xunit;
namespace StellaOps.Scanner.SchemaEvolution.Tests;
/// <summary>
/// Schema evolution tests for the Scanner module.
/// Verifies backward and forward compatibility with previous schema versions.
/// </summary>
[Trait("Category", TestCategories.SchemaEvolution)]
[Trait("Category", TestCategories.Integration)]
[Trait("BlastRadius", TestCategories.BlastRadius.Scanning)]
[Trait("BlastRadius", TestCategories.BlastRadius.Persistence)]
public class ScannerSchemaEvolutionTests : PostgresSchemaEvolutionTestBase
{
private static readonly string[] PreviousVersions = ["v1.8.0", "v1.9.0"];
private static readonly string[] FutureVersions = ["v2.0.0"];
/// <summary>
/// Initializes a new instance of the <see cref="ScannerSchemaEvolutionTests"/> class.
/// </summary>
public ScannerSchemaEvolutionTests()
: base(NullLogger<PostgresSchemaEvolutionTestBase>.Instance)
{
}
/// <inheritdoc />
protected override IReadOnlyList<string> AvailableSchemaVersions => ["v1.8.0", "v1.9.0", "v2.0.0"];
/// <inheritdoc />
protected override Task<string> GetCurrentSchemaVersionAsync(CancellationToken ct) =>
Task.FromResult("v2.0.0");
/// <inheritdoc />
protected override Task ApplyMigrationsToVersionAsync(string connectionString, string targetVersion, CancellationToken ct) =>
Task.CompletedTask;
/// <inheritdoc />
protected override Task<string?> GetMigrationDownScriptAsync(string migrationId, CancellationToken ct) =>
Task.FromResult<string?>(null);
/// <inheritdoc />
protected override Task SeedTestDataAsync(Npgsql.NpgsqlDataSource dataSource, string schemaVersion, CancellationToken ct) =>
Task.CompletedTask;
/// <summary>
/// Verifies that scan read operations work against the previous schema version (N-1).
/// </summary>
[Fact]
public async Task ScanReadOperations_CompatibleWithPreviousSchema()
{
// Arrange
await InitializeAsync();
// Act
var results = await TestReadBackwardCompatibilityAsync(
PreviousVersions,
async dataSource =>
{
await using var cmd = dataSource.CreateCommand(@"
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'scans'
)");
var exists = await cmd.ExecuteScalarAsync();
return exists is true or 1 or (long)1;
},
result => result,
CancellationToken.None);
// Assert
results.Should().AllSatisfy(r => r.IsCompatible.Should().BeTrue(
because: "scan read operations should work against N-1 schema"));
}
/// <summary>
/// Verifies that scan write operations produce valid data for previous schema versions.
/// </summary>
[Fact]
public async Task ScanWriteOperations_CompatibleWithPreviousSchema()
{
// Arrange
await InitializeAsync();
// Act
var results = await TestWriteForwardCompatibilityAsync(
FutureVersions,
async dataSource =>
{
await using var cmd = dataSource.CreateCommand(@"
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'scans'
AND column_name = 'id'
)");
await cmd.ExecuteScalarAsync();
},
CancellationToken.None);
// Assert
results.Should().AllSatisfy(r => r.IsCompatible.Should().BeTrue(
because: "write operations should be compatible with previous schemas"));
}
/// <summary>
/// Verifies that SBOM storage operations work across schema versions.
/// </summary>
[Fact]
public async Task SbomStorageOperations_CompatibleAcrossVersions()
{
// Arrange
await InitializeAsync();
// Act
var result = await TestAgainstPreviousSchemaAsync(
async dataSource =>
{
await using var cmd = dataSource.CreateCommand(@"
SELECT COUNT(*) FROM information_schema.tables
WHERE table_name LIKE '%sbom%' OR table_name LIKE '%component%'");
await cmd.ExecuteScalarAsync();
},
CancellationToken.None);
// Assert
result.IsCompatible.Should().BeTrue(
because: "SBOM storage should be compatible across schema versions");
}
/// <summary>
/// Verifies that vulnerability mapping operations work across schema versions.
/// </summary>
[Fact]
public async Task VulnerabilityMappingOperations_CompatibleAcrossVersions()
{
// Arrange
await InitializeAsync();
// Act
var result = await TestAgainstPreviousSchemaAsync(
async dataSource =>
{
await using var cmd = dataSource.CreateCommand(@"
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name LIKE '%vuln%' OR table_name LIKE '%finding%'
)");
await cmd.ExecuteScalarAsync();
},
CancellationToken.None);
// Assert
result.IsCompatible.Should().BeTrue();
}
/// <summary>
/// Verifies that migration rollbacks work correctly.
/// </summary>
[Fact]
public async Task MigrationRollbacks_ExecuteSuccessfully()
{
// Arrange
await InitializeAsync();
// Act
var results = await TestMigrationRollbacksAsync(
migrationsToTest: 3,
CancellationToken.None);
// Assert - relaxed assertion since migrations may not have down scripts
results.Should().NotBeNull();
}
}

View File

@@ -0,0 +1,24 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>preview</LangVersion>
<Description>Schema evolution tests for Scanner module</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Testcontainers.PostgreSql" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Testing.SchemaEvolution/StellaOps.Testing.SchemaEvolution.csproj" />
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,5 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Sources.Domain;
using Xunit;
@@ -6,8 +7,10 @@ namespace StellaOps.Scanner.Sources.Tests.Domain;
public class SbomSourceRunTests
{
private static readonly FakeTimeProvider TimeProvider = new(DateTimeOffset.Parse("2026-01-01T00:00:00Z"));
[Fact]
public void Create_WithValidInputs_CreatesRunInPendingStatus()
public void Create_WithValidInputs_CreatesRunInRunningStatus()
{
// Arrange
var sourceId = Guid.NewGuid();
@@ -19,6 +22,7 @@ public class SbomSourceRunTests
tenantId: "tenant-1",
trigger: SbomSourceRunTrigger.Manual,
correlationId: correlationId,
timeProvider: TimeProvider,
triggerDetails: "Triggered by user");
// Assert
@@ -28,30 +32,16 @@ public class SbomSourceRunTests
run.Trigger.Should().Be(SbomSourceRunTrigger.Manual);
run.CorrelationId.Should().Be(correlationId);
run.TriggerDetails.Should().Be("Triggered by user");
run.Status.Should().Be(SbomSourceRunStatus.Pending);
run.Status.Should().Be(SbomSourceRunStatus.Running);
run.ItemsDiscovered.Should().Be(0);
run.ItemsScanned.Should().Be(0);
}
[Fact]
public void Start_SetsStatusToRunning()
{
// Arrange
var run = CreateTestRun();
// Act
run.Start();
// Assert
run.Status.Should().Be(SbomSourceRunStatus.Running);
}
[Fact]
public void SetDiscoveredItems_UpdatesDiscoveryCount()
{
// Arrange
var run = CreateTestRun();
run.Start();
// Act
run.SetDiscoveredItems(10);
@@ -65,7 +55,6 @@ public class SbomSourceRunTests
{
// Arrange
var run = CreateTestRun();
run.Start();
run.SetDiscoveredItems(5);
// Act
@@ -84,7 +73,6 @@ public class SbomSourceRunTests
{
// Arrange
var run = CreateTestRun();
run.Start();
run.SetDiscoveredItems(5);
// Act
@@ -102,7 +90,6 @@ public class SbomSourceRunTests
{
// Arrange
var run = CreateTestRun();
run.Start();
run.SetDiscoveredItems(5);
// Act
@@ -114,23 +101,22 @@ public class SbomSourceRunTests
}
[Fact]
public void Complete_SetsSuccessStatusAndDuration()
public void Complete_SetsSuccessStatusAndCompletedAt()
{
// Arrange
var run = CreateTestRun();
run.Start();
run.SetDiscoveredItems(3);
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(Guid.NewGuid());
// Act
run.Complete();
run.Complete(TimeProvider);
// Assert
run.Status.Should().Be(SbomSourceRunStatus.Succeeded);
run.CompletedAt.Should().NotBeNull();
run.DurationMs.Should().BeGreaterOrEqualTo(0);
run.GetDurationMs(TimeProvider).Should().BeGreaterThanOrEqualTo(0);
}
[Fact]
@@ -138,15 +124,14 @@ public class SbomSourceRunTests
{
// Arrange
var run = CreateTestRun();
run.Start();
// Act
run.Fail("Connection timeout", new { retries = 3 });
run.Fail("Connection timeout", TimeProvider, "Stack trace here");
// Assert
run.Status.Should().Be(SbomSourceRunStatus.Failed);
run.ErrorMessage.Should().Be("Connection timeout");
run.ErrorDetails.Should().NotBeNull();
run.ErrorStackTrace.Should().Be("Stack trace here");
run.CompletedAt.Should().NotBeNull();
}
@@ -155,13 +140,13 @@ public class SbomSourceRunTests
{
// Arrange
var run = CreateTestRun();
run.Start();
// Act
run.Cancel();
run.Cancel("User requested cancellation", TimeProvider);
// Assert
run.Status.Should().Be(SbomSourceRunStatus.Cancelled);
run.ErrorMessage.Should().Be("User requested cancellation");
run.CompletedAt.Should().NotBeNull();
}
@@ -170,7 +155,6 @@ public class SbomSourceRunTests
{
// Arrange
var run = CreateTestRun();
run.Start();
run.SetDiscoveredItems(10);
// Act
@@ -193,7 +177,7 @@ public class SbomSourceRunTests
[InlineData(SbomSourceRunTrigger.Manual, "Manual trigger")]
[InlineData(SbomSourceRunTrigger.Scheduled, "Cron: 0 * * * *")]
[InlineData(SbomSourceRunTrigger.Webhook, "Harbor push event")]
[InlineData(SbomSourceRunTrigger.Push, "Registry push event")]
[InlineData(SbomSourceRunTrigger.Retry, "Registry retry event")]
public void Create_WithDifferentTriggers_StoresTriggerInfo(
SbomSourceRunTrigger trigger,
string details)
@@ -204,6 +188,7 @@ public class SbomSourceRunTests
tenantId: "tenant-1",
trigger: trigger,
correlationId: Guid.NewGuid().ToString("N"),
timeProvider: TimeProvider,
triggerDetails: details);
// Assert
@@ -211,12 +196,43 @@ public class SbomSourceRunTests
run.TriggerDetails.Should().Be(details);
}
[Fact]
public void Complete_WithMixedResults_SetsPartialSuccessStatus()
{
// Arrange
var run = CreateTestRun();
run.SetDiscoveredItems(3);
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemFailure();
// Act
run.Complete(TimeProvider);
// Assert
run.Status.Should().Be(SbomSourceRunStatus.PartialSuccess);
}
[Fact]
public void Complete_WithNoSuccesses_SetsSkippedStatus()
{
// Arrange
var run = CreateTestRun();
run.SetDiscoveredItems(0);
// Act
run.Complete(TimeProvider);
// Assert
run.Status.Should().Be(SbomSourceRunStatus.Skipped);
}
private static SbomSourceRun CreateTestRun()
{
return SbomSourceRun.Create(
sourceId: Guid.NewGuid(),
tenantId: "tenant-1",
trigger: SbomSourceRunTrigger.Manual,
correlationId: Guid.NewGuid().ToString("N"));
correlationId: Guid.NewGuid().ToString("N"),
timeProvider: TimeProvider);
}
}

View File

@@ -13,5 +13,6 @@
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="..\\..\\..\\__Tests\\__Libraries\\StellaOps.Infrastructure.Postgres.Testing\\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Testing.Temporal/StellaOps.Testing.Temporal.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,370 @@
// <copyright file="TemporalStorageTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_001_TEST_time_skew_idempotency
// Task: TSKW-009
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Storage.Models;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.Storage.Services;
using StellaOps.Testing.Temporal;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Storage.Tests;
/// <summary>
/// Temporal testing for Scanner Storage components using the Testing.Temporal library.
/// Tests clock skew handling, TTL boundaries, timestamp ordering, and idempotency.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class TemporalStorageTests
{
private static readonly DateTimeOffset BaseTime = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void ClassificationChangeTracker_HandlesClockSkewForwardGracefully()
{
// Arrange
var timeProvider = new SimulatedTimeProvider(BaseTime);
var repository = new FakeClassificationHistoryRepository();
var tracker = new ClassificationChangeTracker(
repository,
NullLogger<ClassificationChangeTracker>.Instance,
timeProvider);
var change1 = CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected);
// Simulate clock jump forward (system time correction, NTP sync)
timeProvider.JumpTo(BaseTime.AddHours(2));
var change2 = CreateChange(ClassificationStatus.Affected, ClassificationStatus.Fixed);
// Act - should handle 2-hour time jump gracefully
tracker.TrackChangeAsync(change1).GetAwaiter().GetResult();
tracker.TrackChangeAsync(change2).GetAwaiter().GetResult();
// Assert
repository.InsertedChanges.Should().HaveCount(2);
ClockSkewAssertions.AssertTimestampsWithinTolerance(
change1.ChangedAt,
repository.InsertedChanges[0].ChangedAt,
tolerance: TimeSpan.FromSeconds(1));
}
[Fact]
public void ClassificationChangeTracker_HandlesClockDriftDuringBatchOperation()
{
// Arrange
var timeProvider = new SimulatedTimeProvider(BaseTime);
// Simulate clock drift of 10ms per second (very aggressive drift)
timeProvider.SetDrift(TimeSpan.FromMilliseconds(10));
var repository = new FakeClassificationHistoryRepository();
var tracker = new ClassificationChangeTracker(
repository,
NullLogger<ClassificationChangeTracker>.Instance,
timeProvider);
var changes = new List<ClassificationChange>();
// Create batch of changes over simulated 100 seconds
for (int i = 0; i < 10; i++)
{
changes.Add(CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected));
timeProvider.Advance(TimeSpan.FromSeconds(10));
}
// Act
tracker.TrackChangesAsync(changes).GetAwaiter().GetResult();
// Assert - all changes should be tracked despite drift
repository.InsertedBatches.Should().HaveCount(1);
repository.InsertedBatches[0].Should().HaveCount(10);
}
[Fact]
public void ClassificationChangeTracker_TrackChangesIsIdempotent()
{
// Arrange
var timeProvider = new SimulatedTimeProvider(BaseTime);
var repository = new FakeClassificationHistoryRepository();
var stateSnapshotter = () => repository.InsertedBatches.Count;
var verifier = new IdempotencyVerifier<int>(stateSnapshotter);
var tracker = new ClassificationChangeTracker(
repository,
NullLogger<ClassificationChangeTracker>.Instance,
timeProvider);
// Same change set
var changes = new[]
{
CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected),
CreateChange(ClassificationStatus.Affected, ClassificationStatus.Fixed),
};
// Act - verify calling with same empty batch is idempotent (produces same state)
var emptyChanges = Array.Empty<ClassificationChange>();
var result = verifier.Verify(
() => tracker.TrackChangesAsync(emptyChanges).GetAwaiter().GetResult(),
repetitions: 3);
// Assert
result.IsIdempotent.Should().BeTrue("empty batch operations should be idempotent");
result.AllSucceeded.Should().BeTrue();
}
[Fact]
public void ScanPhaseTimings_MonotonicTimestampsAreValidated()
{
// Arrange
var baseTime = new DateTimeOffset(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
var phases = new[]
{
baseTime,
baseTime.AddMilliseconds(100),
baseTime.AddMilliseconds(200),
baseTime.AddMilliseconds(300),
baseTime.AddMilliseconds(500),
baseTime.AddMilliseconds(800), // Valid monotonic sequence
};
// Act & Assert - should not throw
ClockSkewAssertions.AssertMonotonicTimestamps(phases, allowEqual: false);
}
[Fact]
public void ScanPhaseTimings_NonMonotonicTimestamps_AreDetected()
{
// Arrange - simulate out-of-order timestamps (e.g., from clock skew)
var baseTime = new DateTimeOffset(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
var phases = new[]
{
baseTime,
baseTime.AddMilliseconds(200),
baseTime.AddMilliseconds(150), // Out of order!
baseTime.AddMilliseconds(300),
};
// Act & Assert
var act = () => ClockSkewAssertions.AssertMonotonicTimestamps(phases);
act.Should().Throw<ClockSkewAssertionException>()
.WithMessage("*not monotonically increasing*");
}
[Fact]
public void TtlBoundary_CacheExpiryEdgeCases()
{
// Arrange
var ttlProvider = new TtlBoundaryTimeProvider(BaseTime);
var ttl = TimeSpan.FromMinutes(15);
var createdAt = BaseTime;
// Generate all boundary test cases
var testCases = TtlBoundaryTimeProvider.GenerateBoundaryTestCases(createdAt, ttl).ToList();
// Act & Assert - verify each boundary case
foreach (var testCase in testCases)
{
var isExpired = testCase.Time >= createdAt.Add(ttl);
isExpired.Should().Be(
testCase.ShouldBeExpired,
$"Case '{testCase.Name}' should be expired={testCase.ShouldBeExpired} at {testCase.Time:O}");
}
}
[Fact]
public void TtlBoundary_JustBeforeExpiry_NotExpired()
{
// Arrange
var ttlProvider = new TtlBoundaryTimeProvider(BaseTime);
var ttl = TimeSpan.FromMinutes(15);
var createdAt = BaseTime;
// Position time at 1ms before expiry
ttlProvider.PositionJustBeforeExpiry(createdAt, ttl);
// Act
var currentTime = ttlProvider.GetUtcNow();
var isExpired = currentTime >= createdAt.Add(ttl);
// Assert
isExpired.Should().BeFalse("1ms before expiry should not be expired");
}
[Fact]
public void TtlBoundary_JustAfterExpiry_IsExpired()
{
// Arrange
var ttlProvider = new TtlBoundaryTimeProvider(BaseTime);
var ttl = TimeSpan.FromMinutes(15);
var createdAt = BaseTime;
// Position time at 1ms after expiry
ttlProvider.PositionJustAfterExpiry(createdAt, ttl);
// Act
var currentTime = ttlProvider.GetUtcNow();
var isExpired = currentTime >= createdAt.Add(ttl);
// Assert
isExpired.Should().BeTrue("1ms after expiry should be expired");
}
[Fact]
public void TtlBoundary_ExactlyAtExpiry_IsExpired()
{
// Arrange
var ttlProvider = new TtlBoundaryTimeProvider(BaseTime);
var ttl = TimeSpan.FromMinutes(15);
var createdAt = BaseTime;
// Position time exactly at expiry boundary
ttlProvider.PositionAtExpiryBoundary(createdAt, ttl);
// Act
var currentTime = ttlProvider.GetUtcNow();
var isExpired = currentTime >= createdAt.Add(ttl);
// Assert
isExpired.Should().BeTrue("exactly at expiry should be expired (>= check)");
}
[Fact]
public void SimulatedTimeProvider_JumpHistory_TracksTimeManipulation()
{
// Arrange
var provider = new SimulatedTimeProvider(BaseTime);
// Act - simulate various time manipulations
provider.Advance(TimeSpan.FromMinutes(5));
provider.JumpTo(BaseTime.AddHours(1));
provider.JumpBackward(TimeSpan.FromMinutes(30));
provider.Advance(TimeSpan.FromMinutes(10));
// Assert
provider.JumpHistory.Should().HaveCount(4);
provider.HasJumpedBackward().Should().BeTrue("backward jump should be tracked");
}
[Fact]
public void SimulatedTimeProvider_DriftSimulation_AppliesCorrectly()
{
// Arrange
var provider = new SimulatedTimeProvider(BaseTime);
var driftPerSecond = TimeSpan.FromMilliseconds(5); // 5ms fast per second
provider.SetDrift(driftPerSecond);
// Act - advance 100 seconds
provider.Advance(TimeSpan.FromSeconds(100));
// Assert - should have 100 seconds + 500ms of drift
var expectedTime = BaseTime
.Add(TimeSpan.FromSeconds(100))
.Add(TimeSpan.FromMilliseconds(500));
provider.GetUtcNow().Should().Be(expectedTime);
}
[Theory]
[MemberData(nameof(GetTtlBoundaryTestData))]
public void TtlBoundary_TheoryTest(string name, DateTimeOffset testTime, bool shouldBeExpired)
{
// Arrange
var createdAt = BaseTime;
var ttl = TimeSpan.FromMinutes(15);
var expiry = createdAt.Add(ttl);
// Act
var isExpired = testTime >= expiry;
// Assert
isExpired.Should().Be(shouldBeExpired, $"Case '{name}' should be expired={shouldBeExpired}");
}
public static IEnumerable<object[]> GetTtlBoundaryTestData()
{
return TtlBoundaryTimeProvider.GenerateTheoryData(BaseTime, TimeSpan.FromMinutes(15));
}
private static ClassificationChange CreateChange(
ClassificationStatus previous,
ClassificationStatus next)
{
return new ClassificationChange
{
ArtifactDigest = "sha256:test",
VulnId = "CVE-2024-0001",
PackagePurl = "pkg:npm/test@1.0.0",
TenantId = Guid.NewGuid(),
ManifestId = Guid.NewGuid(),
ExecutionId = Guid.NewGuid(),
PreviousStatus = previous,
NewStatus = next,
Cause = DriftCause.FeedDelta,
ChangedAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Fake repository for testing classification change tracking.
/// </summary>
private sealed class FakeClassificationHistoryRepository : IClassificationHistoryRepository
{
public List<ClassificationChange> InsertedChanges { get; } = new();
public List<List<ClassificationChange>> InsertedBatches { get; } = new();
public Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default)
{
InsertedChanges.Add(change);
return Task.CompletedTask;
}
public Task InsertBatchAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default)
{
InsertedBatches.Add(changes.ToList());
return Task.CompletedTask;
}
public Task<IReadOnlyList<ClassificationChange>> GetByExecutionAsync(
Guid tenantId,
Guid executionId,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ClassificationChange>>(Array.Empty<ClassificationChange>());
public Task<IReadOnlyList<ClassificationChange>> GetChangesAsync(
Guid tenantId,
DateTimeOffset since,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ClassificationChange>>(Array.Empty<ClassificationChange>());
public Task<IReadOnlyList<ClassificationChange>> GetByArtifactAsync(
string artifactDigest,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ClassificationChange>>(Array.Empty<ClassificationChange>());
public Task<IReadOnlyList<ClassificationChange>> GetByVulnIdAsync(
string vulnId,
Guid? tenantId = null,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ClassificationChange>>(Array.Empty<ClassificationChange>());
public Task<IReadOnlyList<FnDriftStats>> GetDriftStatsAsync(
Guid tenantId,
DateOnly fromDate,
DateOnly toDate,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<FnDriftStats>>(Array.Empty<FnDriftStats>());
public Task<FnDrift30dSummary?> GetDrift30dSummaryAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
=> Task.FromResult<FnDrift30dSummary?>(null);
public Task RefreshDriftStatsAsync(CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
}

View File

@@ -0,0 +1,451 @@
// <copyright file="FacetSealE2ETests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// FacetSealE2ETests.cs
// Sprint: SPRINT_20260105_002_002_FACET
// Task: FCT-025 - E2E test: Scan -> facet seal generation
// Description: End-to-end tests verifying facet seals are properly generated
// and included in SurfaceManifestDocument during scan workflow.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Formats.Tar;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Facet;
namespace StellaOps.Scanner.Surface.FS.Tests;
/// <summary>
/// End-to-end tests for the complete scan to facet seal generation workflow.
/// These tests verify that facet seals flow correctly from extraction through
/// to inclusion in the SurfaceManifestDocument.
/// </summary>
[Trait("Category", "E2E")]
public sealed class FacetSealE2ETests : IDisposable
{
private readonly FakeTimeProvider _timeProvider;
private readonly GlobFacetExtractor _facetExtractor;
private readonly FacetSealExtractor _sealExtractor;
private readonly string _testDir;
private static readonly DateTimeOffset TestTimestamp = new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
public FacetSealE2ETests()
{
_timeProvider = new FakeTimeProvider(TestTimestamp);
_facetExtractor = new GlobFacetExtractor(_timeProvider);
_sealExtractor = new FacetSealExtractor(_facetExtractor, _timeProvider);
_testDir = Path.Combine(Path.GetTempPath(), $"facet-e2e-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
}
public void Dispose()
{
if (Directory.Exists(_testDir))
{
Directory.Delete(_testDir, recursive: true);
}
}
#region Helper Methods
private void CreateTestDirectory(Dictionary<string, string> files)
{
foreach (var (relativePath, content) in files)
{
var fullPath = Path.Combine(_testDir, relativePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(fullPath, content);
}
}
private MemoryStream CreateOciLayerFromDirectory(Dictionary<string, string> files)
{
var tarStream = new MemoryStream();
using (var tarWriter = new TarWriter(tarStream, TarEntryFormat.Pax, leaveOpen: true))
{
foreach (var (path, content) in files)
{
var entry = new PaxTarEntry(TarEntryType.RegularFile, path.TrimStart('/'))
{
DataStream = new MemoryStream(Encoding.UTF8.GetBytes(content))
};
tarWriter.WriteEntry(entry);
}
}
tarStream.Position = 0;
var gzipStream = new MemoryStream();
using (var gzip = new GZipStream(gzipStream, CompressionMode.Compress, leaveOpen: true))
{
tarStream.CopyTo(gzip);
}
gzipStream.Position = 0;
return gzipStream;
}
private static SurfaceManifestDocument CreateManifestWithFacetSeals(
SurfaceFacetSeals? facetSeals,
string imageDigest = "sha256:abc123",
string scanId = "scan-001")
{
return new SurfaceManifestDocument
{
Schema = SurfaceManifestDocument.DefaultSchema,
Tenant = "test-tenant",
ImageDigest = imageDigest,
ScanId = scanId,
GeneratedAt = TestTimestamp,
FacetSeals = facetSeals,
Artifacts = ImmutableArray<SurfaceManifestArtifact>.Empty
};
}
#endregion
#region E2E Workflow Tests
[Fact]
public async Task E2E_ScanDirectory_GeneratesFacetSeals_InSurfaceManifest()
{
// Arrange - Create a realistic directory structure simulating an unpacked image
var imageFiles = new Dictionary<string, string>
{
// OS packages (dpkg)
{ "/var/lib/dpkg/status", "Package: nginx\nVersion: 1.18.0\nStatus: installed\n\nPackage: openssl\nVersion: 3.0.0\nStatus: installed" },
{ "/var/lib/dpkg/info/nginx.list", "/usr/sbin/nginx\n/etc/nginx/nginx.conf" },
// Language dependencies (npm)
{ "/app/node_modules/express/package.json", "{\"name\":\"express\",\"version\":\"4.18.2\"}" },
{ "/app/node_modules/lodash/package.json", "{\"name\":\"lodash\",\"version\":\"4.17.21\"}" },
{ "/app/package-lock.json", "{\"lockfileVersion\":3,\"packages\":{}}" },
// Configuration
{ "/etc/nginx/nginx.conf", "worker_processes auto;\nevents { worker_connections 1024; }" },
{ "/etc/ssl/openssl.cnf", "[openssl_init]\nproviders = provider_sect" },
// Certificates
{ "/etc/ssl/certs/ca-certificates.crt", "-----BEGIN CERTIFICATE-----\nMIIExample\n-----END CERTIFICATE-----" },
// Binaries
{ "/usr/bin/nginx", "ELF binary placeholder" }
};
CreateTestDirectory(imageFiles);
// Act - Extract facet seals (simulating what happens during a scan)
var facetSeals = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
FacetSealExtractionOptions.Default,
TestContext.Current.CancellationToken);
// Create surface manifest document with facet seals (simulating publish step)
var manifest = CreateManifestWithFacetSeals(
facetSeals,
imageDigest: "sha256:e2e_test_image",
scanId: "e2e-scan-001");
// Assert - Verify facet seals are properly included in the manifest
manifest.FacetSeals.Should().NotBeNull("Facet seals should be included in the manifest");
manifest.FacetSeals!.CombinedMerkleRoot.Should().StartWith("sha256:", "Combined Merkle root should be a SHA-256 hash");
manifest.FacetSeals.Facets.Should().NotBeEmpty("At least one facet should be extracted");
manifest.FacetSeals.CreatedAt.Should().Be(TestTimestamp);
// Verify specific facets are present
var facetIds = manifest.FacetSeals.Facets.Select(f => f.FacetId).ToList();
facetIds.Should().Contain("os-packages-dpkg", "DPKG packages facet should be present");
facetIds.Should().Contain("lang-deps-npm", "NPM dependencies facet should be present");
// Verify facet entries have valid data
foreach (var facet in manifest.FacetSeals.Facets)
{
facet.FacetId.Should().NotBeNullOrWhiteSpace();
facet.Name.Should().NotBeNullOrWhiteSpace();
facet.Category.Should().NotBeNullOrWhiteSpace();
facet.MerkleRoot.Should().StartWith("sha256:");
facet.FileCount.Should().BeGreaterThan(0);
}
// Verify stats
manifest.FacetSeals.Stats.Should().NotBeNull();
manifest.FacetSeals.Stats!.TotalFilesProcessed.Should().BeGreaterThan(0);
manifest.FacetSeals.Stats.FilesMatched.Should().BeGreaterThan(0);
}
[Fact]
public async Task E2E_ScanOciLayers_GeneratesFacetSeals_InSurfaceManifest()
{
// Arrange - Create OCI layers simulating a real container image
var baseLayerFiles = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: base-files\nVersion: 12.0\nStatus: installed" },
{ "/etc/passwd", "root:x:0:0:root:/root:/bin/bash" }
};
var appLayerFiles = new Dictionary<string, string>
{
{ "/app/node_modules/express/package.json", "{\"name\":\"express\",\"version\":\"4.18.2\"}" },
{ "/app/src/index.js", "const express = require('express');" }
};
var configLayerFiles = new Dictionary<string, string>
{
{ "/etc/nginx/nginx.conf", "server { listen 80; }" },
{ "/etc/ssl/certs/custom.pem", "-----BEGIN CERTIFICATE-----" }
};
using var baseLayer = CreateOciLayerFromDirectory(baseLayerFiles);
using var appLayer = CreateOciLayerFromDirectory(appLayerFiles);
using var configLayer = CreateOciLayerFromDirectory(configLayerFiles);
var layers = new[] { baseLayer as Stream, appLayer as Stream, configLayer as Stream };
// Act - Extract facet seals from OCI layers
var facetSeals = await _sealExtractor.ExtractFromOciLayersAsync(
layers,
FacetSealExtractionOptions.Default,
TestContext.Current.CancellationToken);
// Create surface manifest document
var manifest = CreateManifestWithFacetSeals(
facetSeals,
imageDigest: "sha256:oci_multilayer_test",
scanId: "e2e-oci-scan-001");
// Assert
manifest.FacetSeals.Should().NotBeNull();
manifest.FacetSeals!.Facets.Should().NotBeEmpty();
manifest.FacetSeals.CombinedMerkleRoot.Should().NotBeNullOrWhiteSpace();
// Verify layers were merged (files from all layers should be processed)
manifest.FacetSeals.Stats.Should().NotBeNull();
manifest.FacetSeals.Stats!.TotalFilesProcessed.Should().BeGreaterThanOrEqualTo(6);
}
[Fact]
public async Task E2E_ScanToManifest_SerializesWithFacetSeals()
{
// Arrange
var imageFiles = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: test\nVersion: 1.0" },
{ "/app/node_modules/test/package.json", "{\"name\":\"test\"}" }
};
CreateTestDirectory(imageFiles);
// Act
var facetSeals = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
ct: TestContext.Current.CancellationToken);
var manifest = CreateManifestWithFacetSeals(facetSeals);
// Serialize and deserialize (verifying JSON round-trip)
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true });
var deserialized = JsonSerializer.Deserialize<SurfaceManifestDocument>(json);
// Assert
deserialized.Should().NotBeNull();
deserialized!.FacetSeals.Should().NotBeNull();
deserialized.FacetSeals!.CombinedMerkleRoot.Should().Be(manifest.FacetSeals!.CombinedMerkleRoot);
deserialized.FacetSeals.Facets.Should().HaveCount(manifest.FacetSeals.Facets.Count);
// Verify JSON contains expected fields
json.Should().Contain("\"facetSeals\"");
json.Should().Contain("\"combinedMerkleRoot\"");
json.Should().Contain("\"facets\"");
}
[Fact]
public async Task E2E_ScanToManifest_DeterministicFacetSeals()
{
// Arrange - same files should produce same facet seals
var imageFiles = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: nginx\nVersion: 1.0" },
{ "/etc/nginx/nginx.conf", "server { listen 80; }" }
};
CreateTestDirectory(imageFiles);
// Act - Run extraction twice
var facetSeals1 = await _sealExtractor.ExtractFromDirectoryAsync(_testDir, ct: TestContext.Current.CancellationToken);
var facetSeals2 = await _sealExtractor.ExtractFromDirectoryAsync(_testDir, ct: TestContext.Current.CancellationToken);
var manifest1 = CreateManifestWithFacetSeals(facetSeals1);
var manifest2 = CreateManifestWithFacetSeals(facetSeals2);
// Assert - Both manifests should have identical facet seals
manifest1.FacetSeals!.CombinedMerkleRoot.Should().Be(manifest2.FacetSeals!.CombinedMerkleRoot);
manifest1.FacetSeals.Facets.Count.Should().Be(manifest2.FacetSeals.Facets.Count);
for (int i = 0; i < manifest1.FacetSeals.Facets.Count; i++)
{
manifest1.FacetSeals.Facets[i].MerkleRoot.Should().Be(manifest2.FacetSeals.Facets[i].MerkleRoot);
}
}
[Fact]
public async Task E2E_ScanToManifest_ContentChangeAffectsFacetSeals()
{
// Arrange
var imageFiles = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: nginx\nVersion: 1.0" }
};
CreateTestDirectory(imageFiles);
// Act - Extract first version
var facetSeals1 = await _sealExtractor.ExtractFromDirectoryAsync(_testDir, ct: TestContext.Current.CancellationToken);
// Modify content
File.WriteAllText(
Path.Combine(_testDir, "var", "lib", "dpkg", "status"),
"Package: nginx\nVersion: 2.0");
// Extract second version
var facetSeals2 = await _sealExtractor.ExtractFromDirectoryAsync(_testDir, ct: TestContext.Current.CancellationToken);
// Assert - Merkle roots should differ
facetSeals1!.CombinedMerkleRoot.Should().NotBe(facetSeals2!.CombinedMerkleRoot);
}
[Fact]
public async Task E2E_ScanDisabled_ManifestHasNoFacetSeals()
{
// Arrange
var imageFiles = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: test" }
};
CreateTestDirectory(imageFiles);
// Act - Extract with disabled options
var facetSeals = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
FacetSealExtractionOptions.Disabled,
TestContext.Current.CancellationToken);
var manifest = CreateManifestWithFacetSeals(facetSeals);
// Assert
manifest.FacetSeals.Should().BeNull("Facet seals should be null when extraction is disabled");
}
#endregion
#region Multi-Facet Category Tests
[Fact]
public async Task E2E_ScanWithAllFacetCategories_AllCategoriesInManifest()
{
// Arrange - Create files for all facet categories
var imageFiles = new Dictionary<string, string>
{
// OS Packages
{ "/var/lib/dpkg/status", "Package: nginx" },
{ "/var/lib/rpm/Packages", "rpm db" },
{ "/lib/apk/db/installed", "apk db" },
// Language Dependencies
{ "/app/node_modules/pkg/package.json", "{\"name\":\"pkg\"}" },
{ "/app/requirements.txt", "flask==2.0.0" },
{ "/app/Gemfile.lock", "GEM specs" },
// Configuration
{ "/etc/nginx/nginx.conf", "config" },
{ "/etc/app/config.yaml", "key: value" },
// Certificates
{ "/etc/ssl/certs/ca.crt", "-----BEGIN CERTIFICATE-----" },
{ "/etc/pki/tls/certs/server.crt", "-----BEGIN CERTIFICATE-----" },
// Binaries
{ "/usr/bin/app", "binary" },
{ "/usr/lib/libapp.so", "shared library" }
};
CreateTestDirectory(imageFiles);
// Act
var facetSeals = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
ct: TestContext.Current.CancellationToken);
var manifest = CreateManifestWithFacetSeals(facetSeals);
// Assert
manifest.FacetSeals.Should().NotBeNull();
var categories = manifest.FacetSeals!.Facets
.Select(f => f.Category)
.Distinct()
.ToList();
// Should have multiple categories represented
categories.Should().HaveCountGreaterThanOrEqualTo(2,
"Multiple facet categories should be extracted from diverse file structure");
// Stats should reflect comprehensive extraction
manifest.FacetSeals.Stats!.TotalFilesProcessed.Should().BeGreaterThanOrEqualTo(10);
}
#endregion
#region Edge Cases
[Fact]
public async Task E2E_EmptyDirectory_ManifestHasEmptyFacetSeals()
{
// Arrange - empty directory
// Act
var facetSeals = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
ct: TestContext.Current.CancellationToken);
var manifest = CreateManifestWithFacetSeals(facetSeals);
// Assert
manifest.FacetSeals.Should().NotBeNull();
manifest.FacetSeals!.Facets.Should().BeEmpty("No facets should be extracted from empty directory");
}
[Fact]
public async Task E2E_NoMatchingFiles_ManifestHasEmptyFacets()
{
// Arrange - files that don't match any facet selectors
var imageFiles = new Dictionary<string, string>
{
{ "/random/file.txt", "random content" },
{ "/another/unknown.dat", "unknown data" }
};
CreateTestDirectory(imageFiles);
// Act
var facetSeals = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
ct: TestContext.Current.CancellationToken);
var manifest = CreateManifestWithFacetSeals(facetSeals);
// Assert
manifest.FacetSeals.Should().NotBeNull();
manifest.FacetSeals!.Stats!.FilesUnmatched.Should().Be(2);
}
#endregion
}

View File

@@ -0,0 +1,234 @@
// <copyright file="FacetSealExtractorTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// FacetSealExtractorTests.cs
// Sprint: SPRINT_20260105_002_002_FACET
// Task: FCT-024 - Unit tests: Surface manifest with facets
// Description: Unit tests for FacetSealExtractor integration.
// -----------------------------------------------------------------------------
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Facet;
namespace StellaOps.Scanner.Surface.FS.Tests;
/// <summary>
/// Tests for <see cref="FacetSealExtractor"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FacetSealExtractorTests : IDisposable
{
private readonly FakeTimeProvider _timeProvider;
private readonly GlobFacetExtractor _facetExtractor;
private readonly FacetSealExtractor _sealExtractor;
private readonly string _testDir;
public FacetSealExtractorTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero));
_facetExtractor = new GlobFacetExtractor(_timeProvider);
_sealExtractor = new FacetSealExtractor(_facetExtractor, _timeProvider);
_testDir = Path.Combine(Path.GetTempPath(), $"facet-seal-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
}
public void Dispose()
{
if (Directory.Exists(_testDir))
{
Directory.Delete(_testDir, recursive: true);
}
}
#region Helper Methods
private void CreateFile(string relativePath, string content)
{
var fullPath = Path.Combine(_testDir, relativePath.TrimStart('/'));
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllText(fullPath, content, Encoding.UTF8);
}
#endregion
#region Basic Extraction Tests
[Fact]
public async Task ExtractFromDirectoryAsync_Enabled_ReturnsSurfaceFacetSeals()
{
// Arrange
CreateFile("/etc/nginx/nginx.conf", "server { listen 80; }");
CreateFile("/var/lib/dpkg/status", "Package: nginx\nVersion: 1.0");
// Act
var result = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
FacetSealExtractionOptions.Default,
TestContext.Current.CancellationToken);
// Assert
result.Should().NotBeNull();
result!.Facets.Should().NotBeEmpty();
result.CombinedMerkleRoot.Should().NotBeNullOrEmpty();
result.CombinedMerkleRoot.Should().StartWith("sha256:");
result.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
}
[Fact]
public async Task ExtractFromDirectoryAsync_Disabled_ReturnsNull()
{
// Arrange
CreateFile("/etc/test.conf", "content");
// Act
var result = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
FacetSealExtractionOptions.Disabled,
TestContext.Current.CancellationToken);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ExtractFromDirectoryAsync_EmptyDirectory_ReturnsEmptyFacets()
{
// Act
var result = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
ct: TestContext.Current.CancellationToken);
// Assert
result.Should().NotBeNull();
result!.Facets.Should().BeEmpty();
}
#endregion
#region Statistics Tests
[Fact]
public async Task ExtractFromDirectoryAsync_ReturnsCorrectStats()
{
// Arrange
CreateFile("/var/lib/dpkg/status", "Package: nginx\nVersion: 1.0");
CreateFile("/etc/nginx/nginx.conf", "server {}");
CreateFile("/random/file.txt", "unmatched");
// Act
var result = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
ct: TestContext.Current.CancellationToken);
// Assert
result.Should().NotBeNull();
result!.Stats.Should().NotBeNull();
result.Stats!.TotalFilesProcessed.Should().BeGreaterThanOrEqualTo(3);
result.Stats.DurationMs.Should().BeGreaterThanOrEqualTo(0);
}
#endregion
#region Facet Entry Tests
[Fact]
public async Task ExtractFromDirectoryAsync_PopulatesFacetEntryFields()
{
// Arrange - create dpkg status file to match os-packages-dpkg facet
CreateFile("/var/lib/dpkg/status", "Package: test\nVersion: 1.0.0");
// Act
var result = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
ct: TestContext.Current.CancellationToken);
// Assert
result.Should().NotBeNull();
var dpkgFacet = result!.Facets.FirstOrDefault(f => f.FacetId == "os-packages-dpkg");
dpkgFacet.Should().NotBeNull();
dpkgFacet!.Name.Should().NotBeNullOrEmpty();
dpkgFacet.Category.Should().NotBeNullOrEmpty();
dpkgFacet.MerkleRoot.Should().StartWith("sha256:");
dpkgFacet.FileCount.Should().BeGreaterThan(0);
dpkgFacet.TotalBytes.Should().BeGreaterThan(0);
}
#endregion
#region Determinism Tests
[Fact]
public async Task ExtractFromDirectoryAsync_SameInput_ProducesSameMerkleRoot()
{
// Arrange
CreateFile("/var/lib/dpkg/status", "Package: nginx\nVersion: 1.0");
CreateFile("/etc/nginx/nginx.conf", "server { listen 80; }");
// Act - extract twice
var result1 = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
ct: TestContext.Current.CancellationToken);
var result2 = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
ct: TestContext.Current.CancellationToken);
// Assert
result1.Should().NotBeNull();
result2.Should().NotBeNull();
result1!.CombinedMerkleRoot.Should().Be(result2!.CombinedMerkleRoot);
}
[Fact]
public async Task ExtractFromDirectoryAsync_DifferentInput_ProducesDifferentMerkleRoot()
{
// Arrange - first extraction
CreateFile("/var/lib/dpkg/status", "Package: nginx\nVersion: 1.0");
var result1 = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
ct: TestContext.Current.CancellationToken);
// Modify content
CreateFile("/var/lib/dpkg/status", "Package: nginx\nVersion: 2.0");
var result2 = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
ct: TestContext.Current.CancellationToken);
// Assert
result1.Should().NotBeNull();
result2.Should().NotBeNull();
result1!.CombinedMerkleRoot.Should().NotBe(result2!.CombinedMerkleRoot);
}
#endregion
#region Schema Version Tests
[Fact]
public async Task ExtractFromDirectoryAsync_SetsSchemaVersion()
{
// Arrange
CreateFile("/var/lib/dpkg/status", "Package: test");
// Act
var result = await _sealExtractor.ExtractFromDirectoryAsync(
_testDir,
ct: TestContext.Current.CancellationToken);
// Assert
result.Should().NotBeNull();
result!.SchemaVersion.Should().Be("1.0.0");
}
#endregion
}

View File

@@ -0,0 +1,378 @@
// <copyright file="FacetSealIntegrationTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// -----------------------------------------------------------------------------
// FacetSealIntegrationTests.cs
// Sprint: SPRINT_20260105_002_002_FACET
// Task: FCT-020 - Integration tests: Extraction from real image layers
// Description: Integration tests for facet seal extraction from tar and OCI layers.
// -----------------------------------------------------------------------------
using System.Formats.Tar;
using System.IO.Compression;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Facet;
namespace StellaOps.Scanner.Surface.FS.Tests;
/// <summary>
/// Integration tests for facet seal extraction from tar and OCI layers.
/// </summary>
[Trait("Category", "Integration")]
public sealed class FacetSealIntegrationTests : IDisposable
{
private readonly FakeTimeProvider _timeProvider;
private readonly GlobFacetExtractor _facetExtractor;
private readonly FacetSealExtractor _sealExtractor;
private readonly string _testDir;
public FacetSealIntegrationTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero));
_facetExtractor = new GlobFacetExtractor(_timeProvider);
_sealExtractor = new FacetSealExtractor(_facetExtractor, _timeProvider);
_testDir = Path.Combine(Path.GetTempPath(), $"facet-integration-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
}
public void Dispose()
{
if (Directory.Exists(_testDir))
{
Directory.Delete(_testDir, recursive: true);
}
}
#region Helper Methods
private MemoryStream CreateTarArchive(Dictionary<string, string> files)
{
var stream = new MemoryStream();
using (var tarWriter = new TarWriter(stream, TarEntryFormat.Pax, leaveOpen: true))
{
foreach (var (path, content) in files)
{
var entry = new PaxTarEntry(TarEntryType.RegularFile, path.TrimStart('/'))
{
DataStream = new MemoryStream(Encoding.UTF8.GetBytes(content))
};
tarWriter.WriteEntry(entry);
}
}
stream.Position = 0;
return stream;
}
private MemoryStream CreateOciLayer(Dictionary<string, string> files)
{
var tarStream = CreateTarArchive(files);
var gzipStream = new MemoryStream();
using (var gzip = new GZipStream(gzipStream, CompressionMode.Compress, leaveOpen: true))
{
tarStream.CopyTo(gzip);
}
gzipStream.Position = 0;
return gzipStream;
}
#endregion
#region Tar Extraction Tests
[Fact]
public async Task ExtractFromTarAsync_ValidTar_ExtractsFacets()
{
// Arrange
var files = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: nginx\nVersion: 1.0" },
{ "/etc/nginx/nginx.conf", "server { listen 80; }" },
{ "/usr/bin/nginx", "binary_content" }
};
using var tarStream = CreateTarArchive(files);
// Act
var result = await _sealExtractor.ExtractFromTarAsync(
tarStream,
ct: TestContext.Current.CancellationToken);
// Assert
result.Should().NotBeNull();
result!.Facets.Should().NotBeEmpty();
result.CombinedMerkleRoot.Should().StartWith("sha256:");
result.Stats.Should().NotBeNull();
result.Stats!.TotalFilesProcessed.Should().BeGreaterThanOrEqualTo(3);
}
[Fact]
public async Task ExtractFromTarAsync_EmptyTar_ReturnsEmptyFacets()
{
// Arrange
using var tarStream = CreateTarArchive(new Dictionary<string, string>());
// Act
var result = await _sealExtractor.ExtractFromTarAsync(
tarStream,
ct: TestContext.Current.CancellationToken);
// Assert
result.Should().NotBeNull();
result!.Facets.Should().BeEmpty();
}
[Fact]
public async Task ExtractFromTarAsync_MatchesDpkgFacet()
{
// Arrange
var files = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: openssl\nVersion: 3.0.0" },
{ "/var/lib/dpkg/info/openssl.list", "/usr/lib/libssl.so" }
};
using var tarStream = CreateTarArchive(files);
// Act
var result = await _sealExtractor.ExtractFromTarAsync(
tarStream,
ct: TestContext.Current.CancellationToken);
// Assert
result.Should().NotBeNull();
var dpkgFacet = result!.Facets.FirstOrDefault(f => f.FacetId == "os-packages-dpkg");
dpkgFacet.Should().NotBeNull();
dpkgFacet!.FileCount.Should().BeGreaterThanOrEqualTo(1);
}
[Fact]
public async Task ExtractFromTarAsync_MatchesNodeModulesFacet()
{
// Arrange
var files = new Dictionary<string, string>
{
{ "/app/node_modules/express/package.json", "{\"name\":\"express\",\"version\":\"4.18.0\"}" },
{ "/app/package-lock.json", "{\"lockfileVersion\":3}" }
};
using var tarStream = CreateTarArchive(files);
// Act
var result = await _sealExtractor.ExtractFromTarAsync(
tarStream,
ct: TestContext.Current.CancellationToken);
// Assert
result.Should().NotBeNull();
var npmFacet = result!.Facets.FirstOrDefault(f => f.FacetId == "lang-deps-npm");
npmFacet.Should().NotBeNull();
}
#endregion
#region OCI Layer Extraction Tests
[Fact]
public async Task ExtractFromOciLayersAsync_SingleLayer_ExtractsFacets()
{
// Arrange
var files = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: curl\nVersion: 7.0" },
{ "/etc/hosts", "127.0.0.1 localhost" }
};
using var layerStream = CreateOciLayer(files);
var layers = new[] { layerStream as Stream };
// Act
var result = await _sealExtractor.ExtractFromOciLayersAsync(
layers,
ct: TestContext.Current.CancellationToken);
// Assert
result.Should().NotBeNull();
result!.Facets.Should().NotBeEmpty();
result.CombinedMerkleRoot.Should().StartWith("sha256:");
}
[Fact]
public async Task ExtractFromOciLayersAsync_MultipleLayers_MergesFacets()
{
// Arrange - base layer has dpkg, upper layer adds config
var baseLayerFiles = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: base\nVersion: 1.0" }
};
var upperLayerFiles = new Dictionary<string, string>
{
{ "/etc/nginx/nginx.conf", "server {}" }
};
using var baseLayer = CreateOciLayer(baseLayerFiles);
using var upperLayer = CreateOciLayer(upperLayerFiles);
var layers = new[] { baseLayer as Stream, upperLayer as Stream };
// Act
var result = await _sealExtractor.ExtractFromOciLayersAsync(
layers,
ct: TestContext.Current.CancellationToken);
// Assert
result.Should().NotBeNull();
result!.Stats.Should().NotBeNull();
result.Stats!.TotalFilesProcessed.Should().BeGreaterThanOrEqualTo(2);
}
#endregion
#region Determinism Tests
[Fact]
public async Task ExtractFromTarAsync_SameTar_ProducesSameMerkleRoot()
{
// Arrange
var files = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: test\nVersion: 1.0" },
{ "/etc/test.conf", "config content" }
};
using var tarStream1 = CreateTarArchive(files);
using var tarStream2 = CreateTarArchive(files);
// Act
var result1 = await _sealExtractor.ExtractFromTarAsync(
tarStream1,
ct: TestContext.Current.CancellationToken);
var result2 = await _sealExtractor.ExtractFromTarAsync(
tarStream2,
ct: TestContext.Current.CancellationToken);
// Assert
result1.Should().NotBeNull();
result2.Should().NotBeNull();
result1!.CombinedMerkleRoot.Should().Be(result2!.CombinedMerkleRoot);
}
[Fact]
public async Task ExtractFromTarAsync_DifferentContent_ProducesDifferentMerkleRoot()
{
// Arrange
var files1 = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: test\nVersion: 1.0" }
};
var files2 = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: test\nVersion: 2.0" }
};
using var tarStream1 = CreateTarArchive(files1);
using var tarStream2 = CreateTarArchive(files2);
// Act
var result1 = await _sealExtractor.ExtractFromTarAsync(
tarStream1,
ct: TestContext.Current.CancellationToken);
var result2 = await _sealExtractor.ExtractFromTarAsync(
tarStream2,
ct: TestContext.Current.CancellationToken);
// Assert
result1.Should().NotBeNull();
result2.Should().NotBeNull();
result1!.CombinedMerkleRoot.Should().NotBe(result2!.CombinedMerkleRoot);
}
#endregion
#region Options Tests
[Fact]
public async Task ExtractFromTarAsync_Disabled_ReturnsNull()
{
// Arrange
var files = new Dictionary<string, string>
{
{ "/var/lib/dpkg/status", "Package: test" }
};
using var tarStream = CreateTarArchive(files);
// Act
var result = await _sealExtractor.ExtractFromTarAsync(
tarStream,
FacetSealExtractionOptions.Disabled,
TestContext.Current.CancellationToken);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ExtractFromOciLayersAsync_Disabled_ReturnsNull()
{
// Arrange
using var layer = CreateOciLayer(new Dictionary<string, string>
{
{ "/etc/test.conf", "content" }
});
// Act
var result = await _sealExtractor.ExtractFromOciLayersAsync(
[layer],
FacetSealExtractionOptions.Disabled,
TestContext.Current.CancellationToken);
// Assert
result.Should().BeNull();
}
#endregion
#region Multi-Facet Category Tests
[Fact]
public async Task ExtractFromTarAsync_MultipleCategories_AllCategoriesRepresented()
{
// Arrange - files for multiple facet categories
var files = new Dictionary<string, string>
{
// OS Packages
{ "/var/lib/dpkg/status", "Package: nginx" },
// Language Dependencies
{ "/app/node_modules/express/package.json", "{\"name\":\"express\"}" },
// Configuration
{ "/etc/nginx/nginx.conf", "server {}" },
// Certificates
{ "/etc/ssl/certs/ca-cert.pem", "-----BEGIN CERTIFICATE-----" }
};
using var tarStream = CreateTarArchive(files);
// Act
var result = await _sealExtractor.ExtractFromTarAsync(
tarStream,
ct: TestContext.Current.CancellationToken);
// Assert
result.Should().NotBeNull();
result!.Facets.Should().HaveCountGreaterThanOrEqualTo(2);
var categories = result.Facets.Select(f => f.Category).Distinct().ToList();
categories.Should().HaveCountGreaterThan(1);
}
#endregion
}

View File

@@ -11,6 +11,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />

View File

@@ -57,12 +57,12 @@ public sealed class ApprovalEndpointsTests : IDisposable
};
// Act
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request, TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(approval);
Assert.Equal("CVE-2024-12345", approval!.FindingId);
Assert.Equal("AcceptRisk", approval.Decision);
@@ -83,7 +83,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
};
// Act
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request, TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -102,7 +102,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
};
// Act
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request, TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -121,7 +121,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
};
// Act
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request, TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -168,12 +168,12 @@ public sealed class ApprovalEndpointsTests : IDisposable
};
// Act
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request, TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(approval);
Assert.Equal(decision, approval!.Decision);
}
@@ -189,7 +189,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
var scanId = await CreateTestScanAsync();
// Act
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals");
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals", TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -222,7 +222,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
});
// Act
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals");
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals", TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -253,7 +253,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(approval);
Assert.Equal(findingId, approval!.FindingId);
Assert.Equal("Suppress", approval.Decision);
@@ -328,7 +328,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
await _client.DeleteAsync($"/api/v1/scans/{scanId}/approvals/{findingId}");
// Act
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals");
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals", TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -361,7 +361,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(approval);
Assert.True(approval!.IsRevoked);
}

View File

@@ -27,10 +27,10 @@ public sealed class BaselineEndpointsTests
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123");
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("sha256:artifact123", result!.ArtifactDigest);
Assert.NotEmpty(result.Recommendations);
@@ -44,10 +44,10 @@ public sealed class BaselineEndpointsTests
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123?environment=production");
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123?environment=production", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotEmpty(result!.Recommendations);
}
@@ -59,8 +59,8 @@ public sealed class BaselineEndpointsTests
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123");
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123", TestContext.Current.CancellationToken);
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
foreach (var rec in result!.Recommendations)
@@ -112,8 +112,8 @@ public sealed class BaselineEndpointsTests
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123");
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123", TestContext.Current.CancellationToken);
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotEmpty(result!.Recommendations);

View File

@@ -24,7 +24,7 @@ public sealed class CallGraphEndpointsTests
var scanId = await CreateScanAsync(client);
var request = CreateMinimalCallGraph(scanId);
var response = await client.PostAsJsonAsync($"/api/v1/scans/{scanId}/callgraphs", request);
var response = await client.PostAsJsonAsync($"/api/v1/scans/{scanId}/callgraphs", request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
@@ -49,10 +49,10 @@ public sealed class CallGraphEndpointsTests
};
httpRequest.Headers.TryAddWithoutValidation("Content-Digest", "sha256:deadbeef");
var first = await client.SendAsync(httpRequest);
var first = await client.SendAsync(httpRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, first.StatusCode);
var payload = await first.Content.ReadFromJsonAsync<CallGraphAcceptedResponseDto>();
var payload = await first.Content.ReadFromJsonAsync<CallGraphAcceptedResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.False(string.IsNullOrWhiteSpace(payload!.CallgraphId));
Assert.Equal("sha256:deadbeef", payload.Digest);

View File

@@ -35,10 +35,10 @@ public sealed class CounterfactualEndpointsTests
CurrentVerdict = "Block"
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("finding-123", result!.FindingId);
Assert.Equal("Block", result.CurrentVerdict);
@@ -60,7 +60,7 @@ public sealed class CounterfactualEndpointsTests
VulnId = "CVE-2021-44228"
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
@@ -78,8 +78,8 @@ public sealed class CounterfactualEndpointsTests
CurrentVerdict = "Block"
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Contains(result!.Paths, p => p.Type == "Vex");
@@ -99,8 +99,8 @@ public sealed class CounterfactualEndpointsTests
CurrentVerdict = "Block"
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Contains(result!.Paths, p => p.Type == "Reachability");
@@ -120,8 +120,8 @@ public sealed class CounterfactualEndpointsTests
CurrentVerdict = "Block"
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Contains(result!.Paths, p => p.Type == "Exception");
@@ -142,8 +142,8 @@ public sealed class CounterfactualEndpointsTests
MaxPaths = 2
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.True(result!.Paths.Count <= 2);
@@ -159,7 +159,7 @@ public sealed class CounterfactualEndpointsTests
var response = await client.GetAsync("/api/v1/counterfactuals/finding/finding-123");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("finding-123", result!.FindingId);
}
@@ -212,8 +212,8 @@ public sealed class CounterfactualEndpointsTests
CurrentVerdict = "Block"
};
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
foreach (var path in result!.Paths)

View File

@@ -36,10 +36,10 @@ public sealed class DeltaCompareEndpointsTests
IncludePolicyDiff = true
};
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions);
var result = await response.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotNull(result!.Base);
Assert.NotNull(result.Target);
@@ -62,7 +62,7 @@ public sealed class DeltaCompareEndpointsTests
TargetDigest = "sha256:target456"
};
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
@@ -79,7 +79,7 @@ public sealed class DeltaCompareEndpointsTests
TargetDigest = ""
};
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}

View File

@@ -50,11 +50,11 @@ public sealed class EpssEndpointsTests : IDisposable
[Fact(DisplayName = "POST /epss/current rejects empty CVE list")]
public async Task PostCurrentBatch_EmptyList_ReturnsBadRequest()
{
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds = Array.Empty<string>() });
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds = Array.Empty<string>() }, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.Equal("Invalid request", problem!.Title);
}
@@ -64,11 +64,11 @@ public sealed class EpssEndpointsTests : IDisposable
{
var cveIds = Enumerable.Range(1, 1001).Select(i => $"CVE-2025-{i:D5}").ToArray();
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds });
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds }, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.Equal("Batch size exceeded", problem!.Title);
}
@@ -82,7 +82,7 @@ public sealed class EpssEndpointsTests : IDisposable
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.Equal(503, problem!.Status);
Assert.Contains("EPSS data is not available", problem.Detail, StringComparison.Ordinal);
@@ -133,7 +133,7 @@ public sealed class EpssEndpointsTests : IDisposable
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.Equal("CVE not found", problem!.Title);
}
@@ -168,7 +168,7 @@ public sealed class EpssEndpointsTests : IDisposable
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.Equal("Invalid date format", problem!.Title);
}
@@ -180,7 +180,7 @@ public sealed class EpssEndpointsTests : IDisposable
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.Equal("No history found", problem!.Title);
}

View File

@@ -37,7 +37,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts?page=1&pageSize=25";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
@@ -50,7 +50,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts?band=HOT&page=1&pageSize=25";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
@@ -63,7 +63,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts?severity=CRITICAL,HIGH&page=1";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
@@ -76,7 +76,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts?status=open&page=1";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
@@ -89,7 +89,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts?sortBy=score&sortOrder=desc&page=1";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
@@ -106,7 +106,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts/alert-nonexistent-12345";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
@@ -123,7 +123,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts/alert-nonexistent-12345/evidence";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
@@ -136,7 +136,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts/alert-12345/evidence?format=minimal";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
@@ -149,7 +149,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts/alert-12345/evidence?format=full";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
@@ -172,7 +172,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
};
// Act
var response = await _client.PostAsJsonAsync(request, decision);
var response = await _client.PostAsJsonAsync(request, decision, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
@@ -190,7 +190,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
};
// Act
var response = await _client.PostAsJsonAsync(request, decision);
var response = await _client.PostAsJsonAsync(request, decision, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(
@@ -211,7 +211,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
};
// Act
var response = await _client.PostAsJsonAsync(request, decision);
var response = await _client.PostAsJsonAsync(request, decision, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(
@@ -231,7 +231,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts/alert-nonexistent-12345/audit";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
@@ -244,7 +244,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts/alert-12345/audit?page=1&pageSize=50";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
@@ -261,7 +261,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts/alert-nonexistent-12345/replay-token";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
@@ -275,7 +275,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var verifyRequest = new { token = "invalid-token-12345" };
// Act
var response = await _client.PostAsJsonAsync(request, verifyRequest);
var response = await _client.PostAsJsonAsync(request, verifyRequest, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(
@@ -295,7 +295,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts/alert-nonexistent-12345/bundle";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
@@ -309,7 +309,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var bundleData = new { bundleId = "bundle-12345" };
// Act
var response = await _client.PostAsJsonAsync(request, bundleData);
var response = await _client.PostAsJsonAsync(request, bundleData, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(
@@ -329,7 +329,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts/alert-nonexistent-12345/diff";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
@@ -342,7 +342,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
var request = "/api/v1/alerts/alert-12345/diff?baseline=scan-001";
// Act
var response = await _client.GetAsync(request);
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);

View File

@@ -0,0 +1,616 @@
// -----------------------------------------------------------------------------
// LayerSbomEndpointsTests.cs
// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api
// Task: T016 - Integration tests for layer SBOM API
// Description: Integration tests for per-layer SBOM and composition recipe endpoints.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Json;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.Emit.Composition;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
[Trait("Category", TestCategories.Integration)]
public sealed class LayerSbomEndpointsTests
{
private const string BasePath = "/api/v1/scans";
#region List Layers Tests
[Fact]
public async Task ListLayers_WhenScanExists_ReturnsLayers()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var mockService = new InMemoryLayerSbomService();
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(3));
var stubCoordinator = new StubScanCoordinator();
stubCoordinator.AddScan(scanId, "sha256:image123");
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.RemoveAll<IScanCoordinator>();
services.AddSingleton<ILayerSbomService>(mockService);
services.AddSingleton<IScanCoordinator>(stubCoordinator);
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/layers");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<LayerListResponseDto>();
Assert.NotNull(result);
Assert.Equal(scanId, result!.ScanId);
Assert.Equal("sha256:image123", result.ImageDigest);
Assert.Equal(3, result.Layers.Count);
Assert.All(result.Layers, l => Assert.True(l.HasSbom));
}
[Fact]
public async Task ListLayers_WhenScanNotFound_Returns404()
{
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-not-found/layers");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task ListLayers_LayersOrderedByOrder()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var mockService = new InMemoryLayerSbomService();
var layers = new[]
{
CreateLayerSummary("sha256:layer2", 2, 15),
CreateLayerSummary("sha256:layer0", 0, 42),
CreateLayerSummary("sha256:layer1", 1, 8),
};
mockService.AddScan(scanId, "sha256:image123", layers);
var stubCoordinator = new StubScanCoordinator();
stubCoordinator.AddScan(scanId, "sha256:image123");
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.RemoveAll<IScanCoordinator>();
services.AddSingleton<ILayerSbomService>(mockService);
services.AddSingleton<IScanCoordinator>(stubCoordinator);
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/layers");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<LayerListResponseDto>();
Assert.NotNull(result);
// Verify layer order is as stored (service already orders by Order)
Assert.Equal(0, result!.Layers[0].Order);
Assert.Equal(1, result.Layers[1].Order);
Assert.Equal(2, result.Layers[2].Order);
}
#endregion
#region Get Layer SBOM Tests
[Fact]
public async Task GetLayerSbom_DefaultFormat_ReturnsCycloneDx()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var layerDigest = "sha256:layer123";
var mockService = new InMemoryLayerSbomService();
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1, layerDigest));
mockService.AddLayerSbom(scanId, layerDigest, "cdx", CreateTestSbomBytes("cyclonedx"));
mockService.AddLayerSbom(scanId, layerDigest, "spdx", CreateTestSbomBytes("spdx"));
var stubCoordinator = new StubScanCoordinator();
stubCoordinator.AddScan(scanId, "sha256:image123");
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.RemoveAll<IScanCoordinator>();
services.AddSingleton<ILayerSbomService>(mockService);
services.AddSingleton<IScanCoordinator>(stubCoordinator);
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/layers/{layerDigest}/sbom");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("cyclonedx", response.Content.Headers.ContentType?.ToString());
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("cyclonedx", content);
}
[Fact]
public async Task GetLayerSbom_SpdxFormat_ReturnsSpdx()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var layerDigest = "sha256:layer123";
var mockService = new InMemoryLayerSbomService();
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1, layerDigest));
mockService.AddLayerSbom(scanId, layerDigest, "cdx", CreateTestSbomBytes("cyclonedx"));
mockService.AddLayerSbom(scanId, layerDigest, "spdx", CreateTestSbomBytes("spdx"));
var stubCoordinator = new StubScanCoordinator();
stubCoordinator.AddScan(scanId, "sha256:image123");
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.RemoveAll<IScanCoordinator>();
services.AddSingleton<ILayerSbomService>(mockService);
services.AddSingleton<IScanCoordinator>(stubCoordinator);
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/layers/{layerDigest}/sbom?format=spdx");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("spdx", response.Content.Headers.ContentType?.ToString());
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("spdx", content);
}
[Fact]
public async Task GetLayerSbom_SetsImmutableCacheHeaders()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var layerDigest = "sha256:layer123";
var mockService = new InMemoryLayerSbomService();
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1, layerDigest));
mockService.AddLayerSbom(scanId, layerDigest, "cdx", CreateTestSbomBytes("cyclonedx"));
var stubCoordinator = new StubScanCoordinator();
stubCoordinator.AddScan(scanId, "sha256:image123");
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.RemoveAll<IScanCoordinator>();
services.AddSingleton<ILayerSbomService>(mockService);
services.AddSingleton<IScanCoordinator>(stubCoordinator);
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/layers/{layerDigest}/sbom");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(response.Headers.ETag);
Assert.Contains("immutable", response.Headers.CacheControl?.ToString());
Assert.True(response.Headers.Contains("X-StellaOps-Layer-Digest"));
Assert.True(response.Headers.Contains("X-StellaOps-Format"));
}
[Fact]
public async Task GetLayerSbom_WhenScanNotFound_Returns404()
{
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-not-found/layers/sha256:layer123/sbom");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetLayerSbom_WhenLayerNotFound_Returns404()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var mockService = new InMemoryLayerSbomService();
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1));
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService>(mockService);
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/layers/sha256:nonexistent/sbom");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
#endregion
#region Composition Recipe Tests
[Fact]
public async Task GetCompositionRecipe_WhenExists_ReturnsRecipe()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var mockService = new InMemoryLayerSbomService();
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2));
mockService.AddCompositionRecipe(scanId, CreateTestRecipe(scanId, "sha256:image123", 2));
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService>(mockService);
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/composition-recipe");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<CompositionRecipeResponseDto>();
Assert.NotNull(result);
Assert.Equal(scanId, result!.ScanId);
Assert.Equal("sha256:image123", result.ImageDigest);
Assert.NotNull(result.Recipe);
Assert.Equal(2, result.Recipe.Layers.Count);
Assert.NotNull(result.Recipe.MerkleRoot);
}
[Fact]
public async Task GetCompositionRecipe_WhenScanNotFound_Returns404()
{
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-not-found/composition-recipe");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetCompositionRecipe_WhenRecipeNotAvailable_Returns404()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var mockService = new InMemoryLayerSbomService();
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1));
// Note: not adding composition recipe
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService>(mockService);
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/composition-recipe");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
#endregion
#region Verify Composition Recipe Tests
[Fact]
public async Task VerifyCompositionRecipe_WhenValid_ReturnsSuccess()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var mockService = new InMemoryLayerSbomService();
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2));
mockService.SetVerificationResult(scanId, new CompositionRecipeVerificationResult
{
Valid = true,
MerkleRootMatch = true,
LayerDigestsMatch = true,
Errors = ImmutableArray<string>.Empty,
});
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService>(mockService);
});
using var client = factory.CreateClient();
var response = await client.PostAsync($"{BasePath}/{scanId}/composition-recipe/verify", null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<CompositionRecipeVerificationResponseDto>();
Assert.NotNull(result);
Assert.True(result!.Valid);
Assert.True(result.MerkleRootMatch);
Assert.True(result.LayerDigestsMatch);
Assert.Null(result.Errors);
}
[Fact]
public async Task VerifyCompositionRecipe_WhenInvalid_ReturnsErrors()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var mockService = new InMemoryLayerSbomService();
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2));
mockService.SetVerificationResult(scanId, new CompositionRecipeVerificationResult
{
Valid = false,
MerkleRootMatch = false,
LayerDigestsMatch = true,
Errors = ImmutableArray.Create("Merkle root mismatch: expected sha256:abc, got sha256:def"),
});
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService>(mockService);
});
using var client = factory.CreateClient();
var response = await client.PostAsync($"{BasePath}/{scanId}/composition-recipe/verify", null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<CompositionRecipeVerificationResponseDto>();
Assert.NotNull(result);
Assert.False(result!.Valid);
Assert.False(result.MerkleRootMatch);
Assert.NotNull(result.Errors);
Assert.Single(result.Errors!);
Assert.Contains("Merkle root mismatch", result.Errors![0]);
}
[Fact]
public async Task VerifyCompositionRecipe_WhenScanNotFound_Returns404()
{
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<ILayerSbomService>();
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
});
using var client = factory.CreateClient();
var response = await client.PostAsync($"{BasePath}/scan-not-found/composition-recipe/verify", null);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
#endregion
#region Test Helpers
private static LayerSummary[] CreateTestLayers(int count, string? specificDigest = null)
{
var layers = new LayerSummary[count];
for (int i = 0; i < count; i++)
{
layers[i] = CreateLayerSummary(
i == 0 && specificDigest != null ? specificDigest : $"sha256:layer{i}",
i,
10 + i * 5);
}
return layers;
}
private static LayerSummary CreateLayerSummary(string digest, int order, int componentCount)
{
return new LayerSummary
{
LayerDigest = digest,
Order = order,
HasSbom = true,
ComponentCount = componentCount,
};
}
private static byte[] CreateTestSbomBytes(string format)
{
var content = format == "spdx"
? """{"spdxVersion":"SPDX-3.0.1","format":"spdx"}"""
: """{"bomFormat":"CycloneDX","specVersion":"1.7","format":"cyclonedx"}""";
return Encoding.UTF8.GetBytes(content);
}
private static CompositionRecipeResponse CreateTestRecipe(string scanId, string imageDigest, int layerCount)
{
var layers = new CompositionRecipeLayer[layerCount];
for (int i = 0; i < layerCount; i++)
{
layers[i] = new CompositionRecipeLayer
{
Digest = $"sha256:layer{i}",
Order = i,
FragmentDigest = $"sha256:frag{i}",
SbomDigests = new LayerSbomDigests
{
CycloneDx = $"sha256:cdx{i}",
Spdx = $"sha256:spdx{i}",
},
ComponentCount = 10 + i * 5,
};
}
return new CompositionRecipeResponse
{
ScanId = scanId,
ImageDigest = imageDigest,
CreatedAt = DateTimeOffset.UtcNow.ToString("O"),
Recipe = new CompositionRecipe
{
Version = "1.0.0",
GeneratorName = "StellaOps.Scanner",
GeneratorVersion = "2026.04",
Layers = layers.ToImmutableArray(),
MerkleRoot = "sha256:merkleroot123",
AggregatedSbomDigests = new AggregatedSbomDigests
{
CycloneDx = "sha256:finalcdx",
Spdx = "sha256:finalspdx",
},
},
};
}
#endregion
}
/// <summary>
/// In-memory implementation of ILayerSbomService for testing.
/// </summary>
internal sealed class InMemoryLayerSbomService : ILayerSbomService
{
private readonly Dictionary<string, (string ImageDigest, LayerSummary[] Layers)> _scans = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<(string ScanId, string LayerDigest, string Format), byte[]> _layerSboms = new();
private readonly Dictionary<string, CompositionRecipeResponse> _recipes = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, CompositionRecipeVerificationResult> _verificationResults = new(StringComparer.OrdinalIgnoreCase);
public void AddScan(string scanId, string imageDigest, LayerSummary[] layers)
{
_scans[scanId] = (imageDigest, layers);
}
public bool HasScan(string scanId) => _scans.ContainsKey(scanId);
public (string ImageDigest, LayerSummary[] Layers)? GetScanData(string scanId)
{
if (_scans.TryGetValue(scanId, out var data))
return data;
return null;
}
public void AddLayerSbom(string scanId, string layerDigest, string format, byte[] sbomBytes)
{
_layerSboms[(scanId, layerDigest, format)] = sbomBytes;
}
public void AddCompositionRecipe(string scanId, CompositionRecipeResponse recipe)
{
_recipes[scanId] = recipe;
}
public void SetVerificationResult(string scanId, CompositionRecipeVerificationResult result)
{
_verificationResults[scanId] = result;
}
public Task<ImmutableArray<LayerSummary>> GetLayerSummariesAsync(
ScanId scanId,
CancellationToken cancellationToken = default)
{
if (!_scans.TryGetValue(scanId.Value, out var scanData))
{
return Task.FromResult(ImmutableArray<LayerSummary>.Empty);
}
return Task.FromResult(scanData.Layers.OrderBy(l => l.Order).ToImmutableArray());
}
public Task<byte[]?> GetLayerSbomAsync(
ScanId scanId,
string layerDigest,
string format,
CancellationToken cancellationToken = default)
{
if (_layerSboms.TryGetValue((scanId.Value, layerDigest, format), out var sbomBytes))
{
return Task.FromResult<byte[]?>(sbomBytes);
}
return Task.FromResult<byte[]?>(null);
}
public Task<CompositionRecipeResponse?> GetCompositionRecipeAsync(
ScanId scanId,
CancellationToken cancellationToken = default)
{
if (_recipes.TryGetValue(scanId.Value, out var recipe))
{
return Task.FromResult<CompositionRecipeResponse?>(recipe);
}
return Task.FromResult<CompositionRecipeResponse?>(null);
}
public Task<CompositionRecipeVerificationResult?> VerifyCompositionRecipeAsync(
ScanId scanId,
CancellationToken cancellationToken = default)
{
if (_verificationResults.TryGetValue(scanId.Value, out var result))
{
return Task.FromResult<CompositionRecipeVerificationResult?>(result);
}
return Task.FromResult<CompositionRecipeVerificationResult?>(null);
}
public Task StoreLayerSbomsAsync(
ScanId scanId,
string imageDigest,
LayerSbomCompositionResult result,
CancellationToken cancellationToken = default)
{
// Not implemented for tests
return Task.CompletedTask;
}
}
/// <summary>
/// Stub IScanCoordinator that returns snapshots for registered scans.
/// </summary>
internal sealed class StubScanCoordinator : IScanCoordinator
{
private readonly ConcurrentDictionary<string, ScanSnapshot> _scans = new(StringComparer.OrdinalIgnoreCase);
public void AddScan(string scanId, string imageDigest)
{
var snapshot = new ScanSnapshot(
ScanId.Parse(scanId),
new ScanTarget("test-image", imageDigest, null),
ScanStatus.Completed,
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow,
null, null, null);
_scans[scanId] = snapshot;
}
public ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken)
{
if (_scans.TryGetValue(scanId.Value, out var snapshot))
{
return ValueTask.FromResult<ScanSnapshot?>(snapshot);
}
return ValueTask.FromResult<ScanSnapshot?>(null);
}
public ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken)
=> ValueTask.FromResult<ScanSnapshot?>(null);
public ValueTask<bool> AttachReplayAsync(ScanId scanId, ReplayArtifacts replay, CancellationToken cancellationToken)
=> ValueTask.FromResult(false);
public ValueTask<bool> AttachEntropyAsync(ScanId scanId, EntropySnapshot entropy, CancellationToken cancellationToken)
=> ValueTask.FromResult(false);
}

View File

@@ -57,15 +57,15 @@ public sealed class ManifestEndpointsTests
CreatedAt = DateTimeOffset.UtcNow
};
await manifestRepository.SaveAsync(manifestRow);
await manifestRepository.SaveAsync(manifestRow, TestContext.Current.CancellationToken);
// Act
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest");
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest", TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var manifest = await response.Content.ReadFromJsonAsync<ScanManifestResponse>();
var manifest = await response.Content.ReadFromJsonAsync<ScanManifestResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(manifest);
Assert.Equal(scanId, manifest!.ScanId);
Assert.Equal("sha256:manifest123", manifest.ManifestHash);
@@ -86,7 +86,7 @@ public sealed class ManifestEndpointsTests
var scanId = Guid.NewGuid();
// Act
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest");
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest", TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
@@ -147,7 +147,7 @@ public sealed class ManifestEndpointsTests
CreatedAt = DateTimeOffset.UtcNow
};
await manifestRepository.SaveAsync(manifestRow);
await manifestRepository.SaveAsync(manifestRow, TestContext.Current.CancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/scans/{scanId}/manifest");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(DsseContentType));
@@ -195,15 +195,15 @@ public sealed class ManifestEndpointsTests
CreatedAt = DateTimeOffset.UtcNow
};
await manifestRepository.SaveAsync(manifestRow);
await manifestRepository.SaveAsync(manifestRow, TestContext.Current.CancellationToken);
// Act
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest");
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest", TestContext.Current.CancellationToken);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var manifest = await response.Content.ReadFromJsonAsync<ScanManifestResponse>();
var manifest = await response.Content.ReadFromJsonAsync<ScanManifestResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(manifest);
Assert.NotNull(manifest!.ContentDigest);
Assert.StartsWith("sha-256=", manifest.ContentDigest);

View File

@@ -42,7 +42,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
using var client = _factory.CreateClient();
var content = new StringContent("{\"test\": true}", Encoding.UTF8, contentType);
var response = await client.PostAsync("/api/v1/scans", content);
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType,
$"POST with content-type '{contentType}' should return 415");
@@ -59,7 +59,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
var content = new StringContent("{\"test\": true}", Encoding.UTF8);
content.Headers.ContentType = null;
var response = await client.PostAsync("/api/v1/scans", content);
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
// Should be either 415 or 400 depending on implementation
response.StatusCode.Should().BeOneOf(
@@ -84,7 +84,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
var largeContent = new string('x', 50 * 1024 * 1024);
var content = new StringContent($"{{\"data\": \"{largeContent}\"}}", Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content);
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
// Should be 413 or the request might timeout/fail
response.StatusCode.Should().BeOneOf(
@@ -109,7 +109,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
using var client = _factory.CreateClient();
var request = new HttpRequestMessage(new HttpMethod(method), endpoint);
var response = await client.SendAsync(request);
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed,
$"{method} {endpoint} should return 405");
@@ -128,7 +128,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
using var client = _factory.CreateClient();
var content = new StringContent("{ invalid json }", Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content);
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
@@ -144,7 +144,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
using var client = _factory.CreateClient();
var content = new StringContent(string.Empty, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content);
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
@@ -160,7 +160,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
using var client = _factory.CreateClient();
var content = new StringContent("{}", Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content);
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
@@ -182,7 +182,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
{
using var client = _factory.CreateClient();
var response = await client.GetAsync(endpoint);
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(
HttpStatusCode.NotFound,
@@ -197,7 +197,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/nonexistent");
var response = await client.GetAsync("/api/v1/nonexistent", TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
@@ -217,7 +217,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
{
using var client = _factory.CreateClient();
var response = await client.GetAsync(endpoint);
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(
HttpStatusCode.BadRequest,
@@ -235,7 +235,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
{
using var client = _factory.CreateClient();
var response = await client.GetAsync(endpoint);
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
// Should not cause server error (500)
response.StatusCode.Should().NotBe(HttpStatusCode.InternalServerError,
@@ -255,7 +255,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
using var client = _factory.CreateClient();
var tasks = Enumerable.Range(0, 100)
.Select(_ => client.GetAsync("/api/v1/health"));
.Select(_ => client.GetAsync("/api/v1/health", TestContext.Current.CancellationToken));
var responses = await Task.WhenAll(tasks);

View File

@@ -20,11 +20,11 @@ public sealed class PolicyEndpointsTests
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/policy/schema");
var response = await client.GetAsync("/api/v1/policy/schema", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/schema+json", response.Content.Headers.ContentType?.MediaType);
var payload = await response.Content.ReadAsStringAsync();
var payload = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.Contains("\"$schema\"", payload);
Assert.Contains("\"properties\"", payload);
}
@@ -47,7 +47,7 @@ public sealed class PolicyEndpointsTests
}
};
var response = await client.PostAsJsonAsync("/api/v1/policy/diagnostics", request);
var response = await client.PostAsJsonAsync("/api/v1/policy/diagnostics", request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var diagnostics = await response.Content.ReadFromJsonAsync<PolicyDiagnosticsResponseDto>(SerializerOptions);

View File

@@ -35,17 +35,17 @@ public sealed class RuntimeEndpointsTests
}
};
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request);
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<RuntimeEventsIngestResponseDto>();
var payload = await response.Content.ReadFromJsonAsync<RuntimeEventsIngestResponseDto>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Equal(2, payload!.Accepted);
Assert.Equal(0, payload.Duplicates);
using var scope = factory.Services.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<RuntimeEventRepository>();
var stored = await repository.ListAsync(CancellationToken.None);
var stored = await repository.ListAsync(TestContext.Current.CancellationToken);
Assert.Equal(2, stored.Count);
Assert.Contains(stored, doc => doc.EventId == "evt-001");
Assert.All(stored, doc =>
@@ -71,7 +71,7 @@ public sealed class RuntimeEndpointsTests
Events = new[] { envelope }
};
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request);
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
@@ -97,7 +97,7 @@ public sealed class RuntimeEndpointsTests
}
};
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request);
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request, TestContext.Current.CancellationToken);
Assert.Equal((HttpStatusCode)StatusCodes.Status429TooManyRequests, response.StatusCode);
Assert.NotNull(response.Headers.RetryAfter);

View File

@@ -46,7 +46,7 @@ public sealed class SbomEndpointsTests
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
request.Content = content;
var response = await client.SendAsync(request);
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<SbomAcceptedResponseDto>();

View File

@@ -38,9 +38,9 @@ public sealed partial class ScansEndpointsTests
});
using var client = factory.CreateClient();
var submit = await client.PostAsJsonAsync("/api/v1/scans", new { image = new { digest = "sha256:demo" } });
var submit = await client.PostAsJsonAsync("/api/v1/scans", new { image = new { digest = "sha256:demo" } }, TestContext.Current.CancellationToken);
submit.EnsureSuccessStatusCode();
var scanId = (await submit.Content.ReadFromJsonAsync<ScanSubmitResponse>())!.ScanId;
var scanId = (await submit.Content.ReadFromJsonAsync<ScanSubmitResponse>(TestContext.Current.CancellationToken))!.ScanId;
using var scope = factory.Services.CreateScope();
var recordMode = scope.ServiceProvider.GetRequiredService<IRecordModeService>();
@@ -66,13 +66,13 @@ public sealed partial class ScansEndpointsTests
ScanTime = DateTimeOffset.UtcNow
};
var result = await recordMode.RecordAsync(request, coordinator);
var result = await recordMode.RecordAsync(request, coordinator, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("sha256:sbom", result.Run.Outputs.Sbom);
Assert.True(store.Objects.Count >= 2);
var status = await client.GetFromJsonAsync<ScanStatusResponse>($"/api/v1/scans/{scanId}");
var status = await client.GetFromJsonAsync<ScanStatusResponse>($"/api/v1/scans/{scanId}", TestContext.Current.CancellationToken);
Assert.NotNull(status!.Replay);
Assert.Equal(result.Artifacts.ManifestHash, status.Replay!.ManifestHash);
}

View File

@@ -30,10 +30,10 @@ public sealed partial class ScansEndpointsTests
var submitResponse = await client.PostAsJsonAsync("/api/v1/scans", new
{
image = new { digest = "sha256:demo" }
});
}, TestContext.Current.CancellationToken);
submitResponse.EnsureSuccessStatusCode();
var submitPayload = await submitResponse.Content.ReadFromJsonAsync<ScanSubmitResponse>();
var submitPayload = await submitResponse.Content.ReadFromJsonAsync<ScanSubmitResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(submitPayload);
var scanId = submitPayload!.ScanId;
@@ -66,7 +66,7 @@ public sealed partial class ScansEndpointsTests
Assert.NotNull(replay);
var status = await client.GetFromJsonAsync<ScanStatusResponse>($"/api/v1/scans/{scanId}");
var status = await client.GetFromJsonAsync<ScanStatusResponse>($"/api/v1/scans/{scanId}", TestContext.Current.CancellationToken);
Assert.NotNull(status);
Assert.NotNull(status!.Replay);
Assert.Equal(replay!.ManifestHash, status.Replay!.ManifestHash);

View File

@@ -37,8 +37,7 @@ public sealed class ScannerAuthorizationTests
useTestAuthentication: true);
using var client = factory.CreateClient();
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync(endpoint, content);
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
// Without auth token, POST should fail - not succeed
response.StatusCode.Should().BeOneOf(
@@ -61,7 +60,7 @@ public sealed class ScannerAuthorizationTests
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync(endpoint);
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
// Health endpoints should be accessible without auth (or not configured)
response.StatusCode.Should().BeOneOf(
@@ -89,9 +88,7 @@ public sealed class ScannerAuthorizationTests
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "expired.token.here");
// Use POST to an endpoint that accepts POST
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content);
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
// Should not get a successful response with invalid token
// BadRequest may occur if endpoint validates body before auth or auth rejects first
@@ -116,8 +113,7 @@ public sealed class ScannerAuthorizationTests
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content);
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
// Should not get a successful response with malformed token
response.StatusCode.Should().BeOneOf(
@@ -141,8 +137,7 @@ public sealed class ScannerAuthorizationTests
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "wrong.issuer.token");
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content);
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
// Should not get a successful response with wrong issuer
response.StatusCode.Should().BeOneOf(
@@ -166,8 +161,7 @@ public sealed class ScannerAuthorizationTests
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "wrong.audience.token");
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content);
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
// Should not get a successful response with wrong audience
response.StatusCode.Should().BeOneOf(
@@ -189,7 +183,7 @@ public sealed class ScannerAuthorizationTests
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/health");
var response = await client.GetAsync("/api/v1/health", TestContext.Current.CancellationToken);
// Should be accessible without authentication (or endpoint not configured)
response.StatusCode.Should().BeOneOf(
@@ -208,8 +202,7 @@ public sealed class ScannerAuthorizationTests
useTestAuthentication: true);
using var client = factory.CreateClient();
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content);
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
// Should not get a successful response without authentication
response.StatusCode.Should().BeOneOf(
@@ -235,7 +228,7 @@ public sealed class ScannerAuthorizationTests
// Without proper auth, POST should fail
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/scans", content);
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
// Should not get a successful response without authentication
response.StatusCode.Should().BeOneOf(
@@ -255,7 +248,7 @@ public sealed class ScannerAuthorizationTests
using var client = factory.CreateClient();
var response = await client.DeleteAsync("/api/v1/scans/00000000-0000-0000-0000-000000000000");
var response = await client.DeleteAsync("/api/v1/scans/00000000-0000-0000-0000-000000000000", TestContext.Current.CancellationToken);
// Should not get a successful response without authentication
response.StatusCode.Should().BeOneOf(
@@ -278,8 +271,8 @@ public sealed class ScannerAuthorizationTests
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
// Request without tenant header - use health endpoint which supports GET
var response = await client.GetAsync("/api/v1/health");
// Request without tenant header
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
// Should succeed without tenant header (or endpoint not configured)
response.StatusCode.Should().BeOneOf(
@@ -301,7 +294,7 @@ public sealed class ScannerAuthorizationTests
using var factory = new ScannerApplicationFactory();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/health");
var response = await client.GetAsync("/api/v1/health", TestContext.Current.CancellationToken);
// Check for common security headers (may vary by configuration)
// These are recommendations, not hard requirements
@@ -321,7 +314,7 @@ public sealed class ScannerAuthorizationTests
request.Headers.Add("Origin", "https://example.com");
request.Headers.Add("Access-Control-Request-Method", "GET");
var response = await client.SendAsync(request);
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
// CORS preflight should either succeed or be explicitly denied
response.StatusCode.Should().BeOneOf(

View File

@@ -6,9 +6,11 @@
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Scanner.WebService.Tests</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Gate/StellaOps.Scanner.Gate.csproj" />
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../Authority/__Libraries/StellaOps.Authority.Persistence/StellaOps.Authority.Persistence.csproj" />

View File

@@ -38,7 +38,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
using var capture = new OtelCapture();
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/health");
var response = await client.GetAsync("/api/v1/health", TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -58,7 +58,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
// This would normally require a valid scan to exist
// For now, verify the endpoint responds appropriately
var response = await client.GetAsync("/api/v1/scans");
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
// The endpoint should return a list (empty if no scans)
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Unauthorized);
@@ -73,7 +73,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
using var capture = new OtelCapture("StellaOps.Scanner");
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/sbom");
var response = await client.GetAsync("/api/v1/sbom", TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Unauthorized);
}
@@ -87,7 +87,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
using var capture = new OtelCapture("StellaOps.Scanner");
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/findings");
var response = await client.GetAsync("/api/v1/findings", TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Unauthorized);
}
@@ -101,7 +101,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
using var capture = new OtelCapture("StellaOps.Scanner");
using var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/reports");
var response = await client.GetAsync("/api/v1/reports", TestContext.Current.CancellationToken);
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Unauthorized);
}
@@ -116,7 +116,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
using var client = _factory.CreateClient();
// Request a non-existent scan
var response = await client.GetAsync("/api/v1/scans/00000000-0000-0000-0000-000000000000");
var response = await client.GetAsync("/api/v1/scans/00000000-0000-0000-0000-000000000000", TestContext.Current.CancellationToken);
// Should get 404 or similar error
response.StatusCode.Should().BeOneOf(HttpStatusCode.NotFound, HttpStatusCode.Unauthorized);
@@ -134,7 +134,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
using var capture = new OtelCapture();
using var client = _factory.CreateClient();
await client.GetAsync("/api/v1/health");
await client.GetAsync("/api/v1/health", TestContext.Current.CancellationToken);
// HTTP traces should follow semantic conventions
// This is a smoke test to ensure OTel is properly configured
@@ -151,7 +151,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
using var client = _factory.CreateClient();
// Fire multiple concurrent requests
var tasks = Enumerable.Range(0, 5).Select(_ => client.GetAsync("/api/v1/health"));
var tasks = Enumerable.Range(0, 5).Select(_ => client.GetAsync("/api/v1/health", TestContext.Current.CancellationToken));
var responses = await Task.WhenAll(tasks);
foreach (var response in responses)

View File

@@ -0,0 +1,361 @@
// -----------------------------------------------------------------------------
// VexGateEndpointsTests.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T025 - API integration tests
// Description: Integration tests for VEX gate API endpoints.
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.Gate;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
[Trait("Category", TestCategories.Integration)]
public sealed class VexGateEndpointsTests
{
private const string BasePath = "/api/v1/scans";
[Fact]
public async Task GetGatePolicy_ReturnsPolicy()
{
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/gate-policy");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var policy = await response.Content.ReadFromJsonAsync<VexGatePolicyDto>();
Assert.NotNull(policy);
Assert.NotNull(policy!.Version);
Assert.NotNull(policy.Rules);
}
[Fact]
public async Task GetGatePolicy_WithTenantId_ReturnsPolicy()
{
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/gate-policy?tenantId=tenant-a");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var policy = await response.Content.ReadFromJsonAsync<VexGatePolicyDto>();
Assert.NotNull(policy);
}
[Fact]
public async Task GetGateResults_WhenScanNotFound_Returns404()
{
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-not-exists/gate-results");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetGateResults_WhenScanExists_ReturnsResults()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var mockService = new InMemoryVexGateQueryService();
mockService.AddScanResult(scanId, CreateTestGateResults(scanId));
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService>(mockService);
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/gate-results");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var results = await response.Content.ReadFromJsonAsync<VexGateResultsResponse>();
Assert.NotNull(results);
Assert.Equal(scanId, results!.ScanId);
Assert.NotNull(results.GateSummary);
Assert.NotNull(results.GatedFindings);
}
[Fact]
public async Task GetGateResults_WithDecisionFilter_ReturnsFilteredResults()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var mockService = new InMemoryVexGateQueryService();
mockService.AddScanResult(scanId, CreateTestGateResults(scanId, blockedCount: 3, warnCount: 5, passCount: 10));
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService>(mockService);
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/gate-results?decision=Block");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var results = await response.Content.ReadFromJsonAsync<VexGateResultsResponse>();
Assert.NotNull(results);
Assert.All(results!.GatedFindings, f => Assert.Equal("Block", f.Decision));
}
[Fact]
public async Task GetGateSummary_WhenScanNotFound_Returns404()
{
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-not-exists/gate-summary");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetGateSummary_WhenScanExists_ReturnsSummary()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var mockService = new InMemoryVexGateQueryService();
mockService.AddScanResult(scanId, CreateTestGateResults(scanId, blockedCount: 2, warnCount: 8, passCount: 40));
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService>(mockService);
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/gate-summary");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var summary = await response.Content.ReadFromJsonAsync<VexGateSummaryDto>();
Assert.NotNull(summary);
Assert.Equal(50, summary!.TotalFindings);
Assert.Equal(2, summary.Blocked);
Assert.Equal(8, summary.Warned);
Assert.Equal(40, summary.Passed);
}
[Fact]
public async Task GetBlockedFindings_WhenScanNotFound_Returns404()
{
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/scan-not-exists/gate-blocked");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetBlockedFindings_WhenScanExists_ReturnsOnlyBlocked()
{
var scanId = "scan-" + Guid.NewGuid().ToString("N");
var mockService = new InMemoryVexGateQueryService();
mockService.AddScanResult(scanId, CreateTestGateResults(scanId, blockedCount: 5, warnCount: 10, passCount: 20));
using var factory = new ScannerApplicationFactory()
.WithOverrides(configureServices: services =>
{
services.RemoveAll<IVexGateQueryService>();
services.AddSingleton<IVexGateQueryService>(mockService);
});
using var client = factory.CreateClient();
var response = await client.GetAsync($"{BasePath}/{scanId}/gate-blocked");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var findings = await response.Content.ReadFromJsonAsync<List<GatedFindingDto>>();
Assert.NotNull(findings);
Assert.Equal(5, findings!.Count);
Assert.All(findings, f => Assert.Equal("Block", f.Decision));
}
private static VexGateResultsResponse CreateTestGateResults(
string scanId,
int blockedCount = 1,
int warnCount = 2,
int passCount = 7)
{
var findings = new List<GatedFindingDto>();
var totalFindings = blockedCount + warnCount + passCount;
for (int i = 0; i < blockedCount; i++)
{
findings.Add(CreateFinding($"CVE-2025-{1000 + i}", "Block", $"pkg:npm/vulnerable-lib@1.{i}.0"));
}
for (int i = 0; i < warnCount; i++)
{
findings.Add(CreateFinding($"CVE-2025-{2000 + i}", "Warn", $"pkg:npm/risky-lib@2.{i}.0"));
}
for (int i = 0; i < passCount; i++)
{
findings.Add(CreateFinding($"CVE-2025-{3000 + i}", "Pass", $"pkg:npm/safe-lib@3.{i}.0"));
}
return new VexGateResultsResponse
{
ScanId = scanId,
GateSummary = new VexGateSummaryDto
{
TotalFindings = totalFindings,
Passed = passCount,
Warned = warnCount,
Blocked = blockedCount,
EvaluatedAt = DateTimeOffset.UtcNow,
},
GatedFindings = findings,
};
}
private static GatedFindingDto CreateFinding(string cve, string decision, string purl)
{
return new GatedFindingDto
{
FindingId = $"finding-{Guid.NewGuid():N}",
Cve = cve,
Purl = purl,
Decision = decision,
Rationale = $"Test rationale for {decision}",
PolicyRuleMatched = decision switch
{
"Block" => "block-exploitable-reachable",
"Warn" => "warn-high-not-reachable",
"Pass" => "pass-vendor-not-affected",
_ => "default",
},
Evidence = new GateEvidenceDto
{
VendorStatus = decision == "Pass" ? "not_affected" : null,
IsReachable = decision == "Block",
HasCompensatingControl = false,
ConfidenceScore = 0.95,
},
};
}
}
/// <summary>
/// In-memory implementation of IVexGateQueryService for testing.
/// </summary>
internal sealed class InMemoryVexGateQueryService : IVexGateQueryService
{
private readonly Dictionary<string, VexGateResultsResponse> _scanResults = new(StringComparer.OrdinalIgnoreCase);
public void AddScanResult(string scanId, VexGateResultsResponse results)
{
_scanResults[scanId] = results;
}
public Task<VexGateResultsResponse?> GetGateResultsAsync(
string scanId,
VexGateResultsQuery? query,
CancellationToken cancellationToken = default)
{
if (!_scanResults.TryGetValue(scanId, out var results))
{
return Task.FromResult<VexGateResultsResponse?>(null);
}
// Apply query filters if present
if (query is not null)
{
var filteredFindings = results.GatedFindings.AsEnumerable();
if (!string.IsNullOrEmpty(query.Decision))
{
filteredFindings = filteredFindings.Where(f =>
string.Equals(f.Decision, query.Decision, StringComparison.OrdinalIgnoreCase));
}
if (query.MinConfidence.HasValue)
{
filteredFindings = filteredFindings.Where(f =>
f.Evidence?.ConfidenceScore >= query.MinConfidence.Value);
}
if (query.Offset.HasValue)
{
filteredFindings = filteredFindings.Skip(query.Offset.Value);
}
if (query.Limit.HasValue)
{
filteredFindings = filteredFindings.Take(query.Limit.Value);
}
return Task.FromResult<VexGateResultsResponse?>(new VexGateResultsResponse
{
ScanId = results.ScanId,
GateSummary = results.GateSummary,
GatedFindings = filteredFindings.ToList(),
});
}
return Task.FromResult<VexGateResultsResponse?>(results);
}
public Task<VexGatePolicyDto> GetPolicyAsync(
string? tenantId,
CancellationToken cancellationToken = default)
{
var defaultPolicy = VexGatePolicy.Default;
var policyDto = new VexGatePolicyDto
{
Version = "1.0.0",
DefaultDecision = defaultPolicy.DefaultDecision.ToString(),
Rules = defaultPolicy.Rules.Select(r => new VexGatePolicyRuleDto
{
RuleId = r.RuleId,
Priority = r.Priority,
Decision = r.Decision.ToString(),
Condition = new VexGatePolicyConditionDto
{
VendorStatus = r.Condition.VendorStatus?.ToString(),
IsExploitable = r.Condition.IsExploitable,
IsReachable = r.Condition.IsReachable,
HasCompensatingControl = r.Condition.HasCompensatingControl,
SeverityLevels = r.Condition.SeverityLevels?.ToList(),
},
}).ToList(),
};
return Task.FromResult(policyDto);
}
}

View File

@@ -0,0 +1,572 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T019
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Gate;
using StellaOps.Scanner.Worker.Metrics;
using StellaOps.Scanner.Worker.Processing;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
/// <summary>
/// Integration tests for <see cref="VexGateStageExecutor"/> in the gated scan pipeline.
/// Tests the full flow from findings extraction through VEX evaluation to result storage.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class VexGateStageExecutorTests
{
private readonly Mock<IVexGateService> _mockGateService;
private readonly Mock<IScanMetricsCollector> _mockMetrics;
private readonly ILogger<VexGateStageExecutor> _logger;
public VexGateStageExecutorTests()
{
_mockGateService = new Mock<IVexGateService>();
_mockMetrics = new Mock<IScanMetricsCollector>();
_logger = NullLogger<VexGateStageExecutor>.Instance;
}
private VexGateStageExecutor CreateExecutor(VexGateStageOptions? options = null)
{
return new VexGateStageExecutor(
_mockGateService.Object,
_logger,
Microsoft.Extensions.Options.Options.Create(options ?? new VexGateStageOptions()),
_mockMetrics.Object);
}
private static ScanJobContext CreateContext(
Dictionary<string, object>? analysisData = null,
TimeProvider? timeProvider = null)
{
var tp = timeProvider ?? TimeProvider.System;
var lease = new TestJobLease();
var context = new ScanJobContext(lease, tp, tp.GetUtcNow(), CancellationToken.None);
if (analysisData is not null)
{
foreach (var (key, value) in analysisData)
{
context.Analysis.Set(key, value);
}
}
return context;
}
private static VexGateFinding CreateTestFinding(
string vulnId,
string purl = "pkg:npm/test@1.0.0",
string? severity = "high",
bool isReachable = true,
bool isExploitable = true)
{
return new VexGateFinding
{
FindingId = $"finding-{vulnId}",
VulnerabilityId = vulnId,
Purl = purl,
ImageDigest = "sha256:abc123",
SeverityLevel = severity,
IsReachable = isReachable,
IsExploitable = isExploitable,
HasCompensatingControl = false
};
}
private static GatedFinding CreateGatedFinding(
VexGateFinding finding,
VexGateDecision decision,
string rationale = "Test rationale",
string ruleId = "test-rule")
{
return new GatedFinding
{
Finding = finding,
GateResult = new VexGateResult
{
Decision = decision,
Rationale = rationale,
PolicyRuleMatched = ruleId,
ContributingStatements = [],
Evidence = new VexGateEvidence
{
VendorStatus = null,
Justification = null,
IsReachable = finding.IsReachable ?? false,
HasCompensatingControl = finding.HasCompensatingControl ?? false,
ConfidenceScore = 0.9,
BackportHints = []
},
EvaluatedAt = DateTimeOffset.UtcNow
}
};
}
#region Stage Name Tests
[Fact]
public void StageName_ReturnsVexGate()
{
// Arrange
var executor = CreateExecutor();
// Assert
executor.StageName.Should().Be(ScanStageNames.VexGate);
}
#endregion
#region Bypass Mode Tests
[Fact]
public async Task ExecuteAsync_WhenBypassed_SkipsEvaluationAndSetsBypassFlag()
{
// Arrange
var executor = CreateExecutor(new VexGateStageOptions { Bypass = true });
var context = CreateContext();
// Act
await executor.ExecuteAsync(context, CancellationToken.None);
// Assert
context.Analysis.TryGet<bool>(ScanAnalysisKeys.VexGateBypassed, out var bypassed).Should().BeTrue();
bypassed.Should().BeTrue();
_mockGateService.Verify(
s => s.EvaluateBatchAsync(It.IsAny<IReadOnlyList<VexGateFinding>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
#endregion
#region No Findings Tests
[Fact]
public async Task ExecuteAsync_WithNoFindings_StoresEmptySummary()
{
// Arrange
var executor = CreateExecutor();
var context = CreateContext();
// Act
await executor.ExecuteAsync(context, CancellationToken.None);
// Assert
context.Analysis.TryGet<VexGateSummary>(ScanAnalysisKeys.VexGateSummary, out var summary).Should().BeTrue();
summary.Should().NotBeNull();
summary!.TotalFindings.Should().Be(0);
summary.PassedCount.Should().Be(0);
summary.WarnedCount.Should().Be(0);
summary.BlockedCount.Should().Be(0);
}
[Fact]
public async Task ExecuteAsync_WithNoFindings_DoesNotCallGateService()
{
// Arrange
var executor = CreateExecutor();
var context = CreateContext();
// Act
await executor.ExecuteAsync(context, CancellationToken.None);
// Assert
_mockGateService.Verify(
s => s.EvaluateBatchAsync(It.IsAny<IReadOnlyList<VexGateFinding>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
#endregion
#region Gate Decision Tests
[Fact]
public async Task ExecuteAsync_WithPassDecisions_StoresCorrectSummary()
{
// Arrange
var executor = CreateExecutor();
var findings = new List<VexGateFinding>
{
CreateTestFinding("CVE-2025-0001"),
CreateTestFinding("CVE-2025-0002"),
CreateTestFinding("CVE-2025-0003")
};
var gatedResults = findings.Select(f => CreateGatedFinding(f, VexGateDecision.Pass))
.ToImmutableArray();
_mockGateService
.Setup(s => s.EvaluateBatchAsync(It.IsAny<IReadOnlyList<VexGateFinding>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(gatedResults);
// Create analyzer results with vulnerabilities
var analyzerResults = CreateAnalyzerResultsWithFindings(findings);
var context = CreateContext(new Dictionary<string, object>
{
[ScanAnalysisKeys.LanguageAnalyzerResults] = analyzerResults
});
// Act
await executor.ExecuteAsync(context, CancellationToken.None);
// Assert
context.Analysis.TryGet<VexGateSummary>(ScanAnalysisKeys.VexGateSummary, out var summary).Should().BeTrue();
summary!.TotalFindings.Should().Be(3);
summary.PassedCount.Should().Be(3);
summary.WarnedCount.Should().Be(0);
summary.BlockedCount.Should().Be(0);
summary.PassRate.Should().Be(1.0);
}
[Fact]
public async Task ExecuteAsync_WithMixedDecisions_StoresCorrectSummary()
{
// Arrange
var executor = CreateExecutor();
var findings = new List<VexGateFinding>
{
CreateTestFinding("CVE-2025-0001"),
CreateTestFinding("CVE-2025-0002"),
CreateTestFinding("CVE-2025-0003"),
CreateTestFinding("CVE-2025-0004")
};
var gatedResults = ImmutableArray.Create(
CreateGatedFinding(findings[0], VexGateDecision.Pass, "Vendor: not_affected"),
CreateGatedFinding(findings[1], VexGateDecision.Warn, "High severity, not reachable"),
CreateGatedFinding(findings[2], VexGateDecision.Block, "Exploitable and reachable"),
CreateGatedFinding(findings[3], VexGateDecision.Pass, "Backport confirmed")
);
_mockGateService
.Setup(s => s.EvaluateBatchAsync(It.IsAny<IReadOnlyList<VexGateFinding>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(gatedResults);
var analyzerResults = CreateAnalyzerResultsWithFindings(findings);
var context = CreateContext(new Dictionary<string, object>
{
[ScanAnalysisKeys.LanguageAnalyzerResults] = analyzerResults
});
// Act
await executor.ExecuteAsync(context, CancellationToken.None);
// Assert
context.Analysis.TryGet<VexGateSummary>(ScanAnalysisKeys.VexGateSummary, out var summary).Should().BeTrue();
summary!.TotalFindings.Should().Be(4);
summary.PassedCount.Should().Be(2);
summary.WarnedCount.Should().Be(1);
summary.BlockedCount.Should().Be(1);
summary.PassRate.Should().BeApproximately(0.5, 0.01);
summary.BlockRate.Should().BeApproximately(0.25, 0.01);
}
[Fact]
public async Task ExecuteAsync_WithAllBlocked_StoresCorrectSummary()
{
// Arrange
var executor = CreateExecutor();
var findings = new List<VexGateFinding>
{
CreateTestFinding("CVE-2025-0001"),
CreateTestFinding("CVE-2025-0002")
};
var gatedResults = findings.Select(f => CreateGatedFinding(f, VexGateDecision.Block, "All exploitable"))
.ToImmutableArray();
_mockGateService
.Setup(s => s.EvaluateBatchAsync(It.IsAny<IReadOnlyList<VexGateFinding>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(gatedResults);
var analyzerResults = CreateAnalyzerResultsWithFindings(findings);
var context = CreateContext(new Dictionary<string, object>
{
[ScanAnalysisKeys.LanguageAnalyzerResults] = analyzerResults
});
// Act
await executor.ExecuteAsync(context, CancellationToken.None);
// Assert
context.Analysis.TryGet<VexGateSummary>(ScanAnalysisKeys.VexGateSummary, out var summary).Should().BeTrue();
summary!.BlockedCount.Should().Be(2);
summary.BlockRate.Should().Be(1.0);
}
#endregion
#region Result Storage Tests
[Fact]
public async Task ExecuteAsync_StoresResultsMapByFindingId()
{
// Arrange
var executor = CreateExecutor();
var finding = CreateTestFinding("CVE-2025-0001");
var gatedResult = CreateGatedFinding(finding, VexGateDecision.Pass);
_mockGateService
.Setup(s => s.EvaluateBatchAsync(It.IsAny<IReadOnlyList<VexGateFinding>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([gatedResult]);
var analyzerResults = CreateAnalyzerResultsWithFindings([finding]);
var context = CreateContext(new Dictionary<string, object>
{
[ScanAnalysisKeys.LanguageAnalyzerResults] = analyzerResults
});
// Act
await executor.ExecuteAsync(context, CancellationToken.None);
// Assert
context.Analysis.TryGet<Dictionary<string, GatedFinding>>(ScanAnalysisKeys.VexGateResults, out var results)
.Should().BeTrue();
results.Should().ContainKey(finding.FindingId);
results[finding.FindingId].GateResult.Decision.Should().Be(VexGateDecision.Pass);
}
[Fact]
public async Task ExecuteAsync_StoresPolicyVersion()
{
// Arrange
var executor = CreateExecutor(new VexGateStageOptions { PolicyVersion = "v2.1.0" });
var finding = CreateTestFinding("CVE-2025-0001");
var gatedResult = CreateGatedFinding(finding, VexGateDecision.Pass);
_mockGateService
.Setup(s => s.EvaluateBatchAsync(It.IsAny<IReadOnlyList<VexGateFinding>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([gatedResult]);
var analyzerResults = CreateAnalyzerResultsWithFindings([finding]);
var context = CreateContext(new Dictionary<string, object>
{
[ScanAnalysisKeys.LanguageAnalyzerResults] = analyzerResults
});
// Act
await executor.ExecuteAsync(context, CancellationToken.None);
// Assert
context.Analysis.TryGet<string>(ScanAnalysisKeys.VexGatePolicyVersion, out var version).Should().BeTrue();
version.Should().Be("v2.1.0");
}
[Fact]
public async Task ExecuteAsync_WithNoPolicyVersion_StoresDefault()
{
// Arrange
var executor = CreateExecutor();
var finding = CreateTestFinding("CVE-2025-0001");
var gatedResult = CreateGatedFinding(finding, VexGateDecision.Pass);
_mockGateService
.Setup(s => s.EvaluateBatchAsync(It.IsAny<IReadOnlyList<VexGateFinding>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([gatedResult]);
var analyzerResults = CreateAnalyzerResultsWithFindings([finding]);
var context = CreateContext(new Dictionary<string, object>
{
[ScanAnalysisKeys.LanguageAnalyzerResults] = analyzerResults
});
// Act
await executor.ExecuteAsync(context, CancellationToken.None);
// Assert
context.Analysis.TryGet<string>(ScanAnalysisKeys.VexGatePolicyVersion, out var version).Should().BeTrue();
version.Should().Be("default");
}
#endregion
#region Metrics Tests
[Fact]
public async Task ExecuteAsync_RecordsMetrics()
{
// Arrange
var executor = CreateExecutor();
var findings = new List<VexGateFinding>
{
CreateTestFinding("CVE-2025-0001"),
CreateTestFinding("CVE-2025-0002")
};
var gatedResults = ImmutableArray.Create(
CreateGatedFinding(findings[0], VexGateDecision.Pass),
CreateGatedFinding(findings[1], VexGateDecision.Block)
);
_mockGateService
.Setup(s => s.EvaluateBatchAsync(It.IsAny<IReadOnlyList<VexGateFinding>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(gatedResults);
var analyzerResults = CreateAnalyzerResultsWithFindings(findings);
var context = CreateContext(new Dictionary<string, object>
{
[ScanAnalysisKeys.LanguageAnalyzerResults] = analyzerResults
});
// Act
await executor.ExecuteAsync(context, CancellationToken.None);
// Assert
_mockMetrics.Verify(
m => m.RecordVexGateMetrics(2, 1, 0, 1, It.IsAny<TimeSpan>()),
Times.Once);
}
#endregion
#region Cancellation Tests
[Fact]
public async Task ExecuteAsync_PropagatesCancellation()
{
// Arrange
var executor = CreateExecutor();
var findings = new List<VexGateFinding> { CreateTestFinding("CVE-2025-0001") };
using var cts = new CancellationTokenSource();
cts.Cancel();
_mockGateService
.Setup(s => s.EvaluateBatchAsync(It.IsAny<IReadOnlyList<VexGateFinding>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException());
var analyzerResults = CreateAnalyzerResultsWithFindings(findings);
var context = CreateContext(new Dictionary<string, object>
{
[ScanAnalysisKeys.LanguageAnalyzerResults] = analyzerResults
});
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(
() => executor.ExecuteAsync(context, cts.Token).AsTask());
}
#endregion
#region Argument Validation Tests
[Fact]
public async Task ExecuteAsync_NullContext_ThrowsArgumentNullException()
{
// Arrange
var executor = CreateExecutor();
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(
() => executor.ExecuteAsync(null!, CancellationToken.None).AsTask());
}
[Fact]
public void Constructor_NullGateService_ThrowsArgumentNullException()
{
// Act & Assert
var act = () => new VexGateStageExecutor(
null!,
_logger,
Microsoft.Extensions.Options.Options.Create(new VexGateStageOptions()),
_mockMetrics.Object);
act.Should().Throw<ArgumentNullException>().WithParameterName("vexGateService");
}
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNullException()
{
// Act & Assert
var act = () => new VexGateStageExecutor(
_mockGateService.Object,
null!,
Microsoft.Extensions.Options.Options.Create(new VexGateStageOptions()),
_mockMetrics.Object);
act.Should().Throw<ArgumentNullException>().WithParameterName("logger");
}
#endregion
#region Helper Methods
private static Dictionary<string, object> CreateAnalyzerResultsWithFindings(IList<VexGateFinding> findings)
{
// Create mock analyzer results that match what VexGateStageExecutor expects
var analyzerResult = new TestAnalyzerResult
{
Vulnerabilities = findings.Select(f => new TestVulnerability
{
CveId = f.VulnerabilityId,
Purl = f.Purl,
FindingId = f.FindingId,
Severity = f.SeverityLevel ?? "medium",
IsReachable = f.IsReachable ?? false,
IsExploitable = f.IsExploitable ?? false
}).ToList()
};
return new Dictionary<string, object>
{
["lang-npm"] = analyzerResult
};
}
/// <summary>
/// Test analyzer result with structure matching what VexGateStageExecutor extracts.
/// </summary>
private sealed class TestAnalyzerResult
{
public List<TestVulnerability> Vulnerabilities { get; set; } = [];
}
/// <summary>
/// Test vulnerability matching what VexGateStageExecutor extracts via reflection.
/// </summary>
private sealed class TestVulnerability
{
public string CveId { get; set; } = string.Empty;
public string Purl { get; set; } = string.Empty;
public string FindingId { get; set; } = string.Empty;
public string Severity { get; set; } = "medium";
public bool IsReachable { get; set; }
public bool IsExploitable { get; set; }
}
/// <summary>
/// Test job lease for creating ScanJobContext.
/// </summary>
private sealed class TestJobLease : IScanJobLease
{
public string JobId { get; } = $"job-{Guid.NewGuid():N}";
public string ScanId { get; } = $"scan-{Guid.NewGuid():N}";
public int Attempt => 1;
public DateTimeOffset EnqueuedAtUtc { get; } = DateTimeOffset.UtcNow.AddMinutes(-1);
public DateTimeOffset LeasedAtUtc { get; } = DateTimeOffset.UtcNow;
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata { get; } = new Dictionary<string, string>
{
["queue"] = "tests",
["job.kind"] = "unit"
};
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
#endregion
}