save checkpoint
This commit is contained in:
@@ -17,7 +17,12 @@ public sealed class BuildProvenancePolicyLoaderTests
|
||||
"buildProvenancePolicy": {
|
||||
"minimumSlsaLevel": 3,
|
||||
"sourceRequirements": {
|
||||
"requireSignedCommits": true
|
||||
"requireSignedCommits": true,
|
||||
"minimumReviewApprovals": 2,
|
||||
"requireNoSelfMerge": true,
|
||||
"requireProtectedBranch": true,
|
||||
"requireStatusChecksPassed": true,
|
||||
"requirePolicyHash": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,5 +33,10 @@ public sealed class BuildProvenancePolicyLoaderTests
|
||||
|
||||
Assert.Equal(3, policy.MinimumSlsaLevel);
|
||||
Assert.True(policy.SourceRequirements.RequireSignedCommits);
|
||||
Assert.Equal(2, policy.SourceRequirements.MinimumReviewApprovals);
|
||||
Assert.True(policy.SourceRequirements.RequireNoSelfMerge);
|
||||
Assert.True(policy.SourceRequirements.RequireProtectedBranch);
|
||||
Assert.True(policy.SourceRequirements.RequireStatusChecksPassed);
|
||||
Assert.True(policy.SourceRequirements.RequirePolicyHash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,28 @@ public sealed class BuildProvenanceReportFormatterTests
|
||||
var report = new BuildProvenanceReport
|
||||
{
|
||||
AchievedLevel = SlsaLevel.Level2,
|
||||
ProvenanceChain = BuildProvenanceChain.Empty
|
||||
ProvenanceChain = BuildProvenanceChain.Empty with
|
||||
{
|
||||
SourceTrack = SourceTrackEvidence.Empty with
|
||||
{
|
||||
Reference = "refs/heads/main",
|
||||
PolicyHash = "sha256:policy123",
|
||||
ReviewCount = 2,
|
||||
ApproverIds = ["approver-a", "approver-b"],
|
||||
AuthorId = "author-a",
|
||||
MergedById = "approver-a",
|
||||
BranchProtected = true,
|
||||
StatusChecksPassed = true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = BuildProvenanceReportFormatter.ToInTotoPredicateBytes(report);
|
||||
var payload = Encoding.UTF8.GetString(json);
|
||||
|
||||
Assert.Contains("https://slsa.dev/provenance/v1", payload);
|
||||
Assert.Contains("\"reference\":\"refs/heads/main\"", payload);
|
||||
Assert.Contains("\"policyHash\":\"sha256:policy123\"", payload);
|
||||
Assert.Contains("\"approvers\":[\"approver-a\",\"approver-b\"]", payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Linq;
|
||||
using StellaOps.Scanner.BuildProvenance.Analyzers;
|
||||
using StellaOps.Scanner.BuildProvenance.Models;
|
||||
using StellaOps.Scanner.BuildProvenance.Policy;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.BuildProvenance.Tests;
|
||||
|
||||
public sealed class SourceVerifierTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_RequiresMinimumReviewApprovals_FailsWhenBelowThreshold()
|
||||
{
|
||||
var sbom = TestSbomFactory.CreateSbom(
|
||||
TestSbomFactory.CreateBuildInfo(builder =>
|
||||
{
|
||||
builder.WithParameter("sourceRepository", "https://git.example/stella/repo");
|
||||
builder.WithParameter("sourceReviewCount", "1");
|
||||
}));
|
||||
|
||||
var chain = new BuildProvenanceChainBuilder().Build(sbom);
|
||||
var policy = BuildProvenancePolicyDefaults.Default with
|
||||
{
|
||||
SourceRequirements = BuildProvenancePolicyDefaults.Default.SourceRequirements with
|
||||
{
|
||||
MinimumReviewApprovals = 2
|
||||
}
|
||||
};
|
||||
|
||||
var findings = new SourceVerifier().Verify(sbom, chain, policy).ToArray();
|
||||
|
||||
var finding = Assert.Single(findings.Where(f => f.Type == BuildProvenanceFindingType.SourcePolicyFailed));
|
||||
Assert.Equal("Insufficient source review approvals", finding.Title);
|
||||
Assert.Equal("2", finding.Metadata["minimumReviewApprovals"]);
|
||||
Assert.Equal("1", finding.Metadata["actualReviewApprovals"]);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_RequireNoSelfMerge_FailsWhenAuthorMatchesMergeActor()
|
||||
{
|
||||
var sbom = TestSbomFactory.CreateSbom(
|
||||
TestSbomFactory.CreateBuildInfo(builder =>
|
||||
{
|
||||
builder.WithParameter("sourceRepository", "https://git.example/stella/repo");
|
||||
builder.WithParameter("sourceAuthorId", "alice");
|
||||
builder.WithParameter("sourceMergedById", "alice");
|
||||
}));
|
||||
|
||||
var chain = new BuildProvenanceChainBuilder().Build(sbom);
|
||||
var policy = BuildProvenancePolicyDefaults.Default with
|
||||
{
|
||||
SourceRequirements = BuildProvenancePolicyDefaults.Default.SourceRequirements with
|
||||
{
|
||||
RequireNoSelfMerge = true
|
||||
}
|
||||
};
|
||||
|
||||
var findings = new SourceVerifier().Verify(sbom, chain, policy).ToArray();
|
||||
|
||||
var finding = Assert.Single(findings.Where(f => f.Type == BuildProvenanceFindingType.SourcePolicyFailed));
|
||||
Assert.Equal("Self-merge detected", finding.Title);
|
||||
Assert.Equal("alice", finding.Metadata["authorId"]);
|
||||
Assert.Equal("alice", finding.Metadata["mergedById"]);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_MinimumReviewApprovals_UsesApproverListWhenReviewCountMissing()
|
||||
{
|
||||
var sbom = TestSbomFactory.CreateSbom(
|
||||
TestSbomFactory.CreateBuildInfo(builder =>
|
||||
{
|
||||
builder.WithParameter("sourceRepository", "https://git.example/stella/repo");
|
||||
builder.WithParameter("sourceApproverIds", "approver-b,approver-a");
|
||||
}));
|
||||
|
||||
var chain = new BuildProvenanceChainBuilder().Build(sbom);
|
||||
var policy = BuildProvenancePolicyDefaults.Default with
|
||||
{
|
||||
SourceRequirements = BuildProvenancePolicyDefaults.Default.SourceRequirements with
|
||||
{
|
||||
MinimumReviewApprovals = 2
|
||||
}
|
||||
};
|
||||
|
||||
var findings = new SourceVerifier().Verify(sbom, chain, policy).ToArray();
|
||||
|
||||
Assert.DoesNotContain(findings, finding => finding.Type == BuildProvenanceFindingType.SourcePolicyFailed);
|
||||
Assert.Equal(["approver-a", "approver-b"], chain.SourceTrack.ApproverIds);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_RequireBranchStatusAndPolicyHash_PassesWhenSignalsPresent()
|
||||
{
|
||||
var sbom = TestSbomFactory.CreateSbom(
|
||||
TestSbomFactory.CreateBuildInfo(builder =>
|
||||
{
|
||||
builder.WithParameter("sourceRepository", "https://git.example/stella/repo");
|
||||
builder.WithParameter("sourceAuthorId", "alice");
|
||||
builder.WithParameter("sourceMergedById", "bob");
|
||||
builder.WithParameter("sourceBranchProtected", "true");
|
||||
builder.WithParameter("sourceStatusChecksPassed", "true");
|
||||
builder.WithParameter("sourcePolicyHash", "sha256:policy123");
|
||||
}));
|
||||
|
||||
var chain = new BuildProvenanceChainBuilder().Build(sbom);
|
||||
var policy = BuildProvenancePolicyDefaults.Default with
|
||||
{
|
||||
SourceRequirements = BuildProvenancePolicyDefaults.Default.SourceRequirements with
|
||||
{
|
||||
RequireNoSelfMerge = true,
|
||||
RequireProtectedBranch = true,
|
||||
RequireStatusChecksPassed = true,
|
||||
RequirePolicyHash = true
|
||||
}
|
||||
};
|
||||
|
||||
var findings = new SourceVerifier().Verify(sbom, chain, policy).ToArray();
|
||||
|
||||
Assert.DoesNotContain(findings, finding => finding.Type == BuildProvenanceFindingType.SourcePolicyFailed);
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.BuildProvenance.Tests/StellaOps.Scanner.BuildProvenance.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| STS-005 | DONE | SPRINT_20260210_004 - Added SourceVerifier and formatter/policy tests for Source Track controls (execution blocked by pre-existing Policy.Determinization compile errors). |
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Cache.Abstractions;
|
||||
using StellaOps.Scanner.Cache.LayerCache;
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Cache.Tests.LayerCache;
|
||||
|
||||
@@ -11,6 +11,7 @@ using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
namespace StellaOps.Scanner.Cache.Tests;
|
||||
|
||||
public sealed class LayerCacheRoundTripTests : IAsyncLifetime
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.Core.Configuration;
|
||||
using StellaOps.Scanner.Core.TrustAnchors;
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests;
|
||||
|
||||
@@ -11,6 +11,7 @@ using StellaOps.Scanner.Queue;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
namespace StellaOps.Scanner.Queue.Tests;
|
||||
|
||||
public sealed class QueueLeaseIntegrationTests
|
||||
|
||||
@@ -17,6 +17,7 @@ using StellaOps.Scanner.ReachabilityDrift.Attestation;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Tests;
|
||||
|
||||
public sealed class DriftAttestationServiceTests
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Tests;
|
||||
|
||||
[Collection("scanner-postgres")]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class ArtifactBomRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ScannerPostgresFixture _fixture;
|
||||
private IArtifactBomRepository _repository = null!;
|
||||
|
||||
public ArtifactBomRepositoryTests(ScannerPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
var options = new ScannerStorageOptions
|
||||
{
|
||||
Postgres = new PostgresOptions
|
||||
{
|
||||
ConnectionString = _fixture.ConnectionString,
|
||||
SchemaName = _fixture.SchemaName
|
||||
}
|
||||
};
|
||||
|
||||
var dataSource = new ScannerDataSource(Options.Create(options), NullLogger<ScannerDataSource>.Instance);
|
||||
_repository = new PostgresArtifactBomRepository(dataSource, NullLogger<PostgresArtifactBomRepository>.Instance);
|
||||
await _repository.EnsureFuturePartitionsAsync(2);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertMonthlyAsync_DuplicateCanonicalAndPayload_IsIdempotent()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var digest = $"sha256:{Guid.NewGuid():N}";
|
||||
var canonicalHash = $"sha256:{Guid.NewGuid():N}";
|
||||
|
||||
var first = CreateRow(
|
||||
buildId: "build-a",
|
||||
payloadDigest: digest,
|
||||
canonicalHash: canonicalHash,
|
||||
insertedAt: now,
|
||||
evidenceScore: 42);
|
||||
|
||||
var second = CreateRow(
|
||||
buildId: "build-b",
|
||||
payloadDigest: digest,
|
||||
canonicalHash: canonicalHash,
|
||||
insertedAt: now.AddMinutes(1),
|
||||
evidenceScore: 97);
|
||||
|
||||
var savedFirst = await _repository.UpsertMonthlyAsync(first);
|
||||
var savedSecond = await _repository.UpsertMonthlyAsync(second);
|
||||
|
||||
savedSecond.BuildId.Should().Be(savedFirst.BuildId);
|
||||
savedSecond.InsertedAt.Should().Be(savedFirst.InsertedAt);
|
||||
|
||||
var latest = await _repository.TryGetLatestByPayloadDigestAsync(digest);
|
||||
latest.Should().NotBeNull();
|
||||
latest!.BuildId.Should().Be(savedFirst.BuildId);
|
||||
latest.EvidenceScore.Should().Be(97);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindByComponentPurlAsync_ReturnsDeterministicDescendingOrder()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
const string purl = "pkg:deb/debian/openssl@3.0.12";
|
||||
|
||||
await _repository.UpsertMonthlyAsync(CreateRow("build-1", "sha256:payload-1", "sha256:canon-1", now));
|
||||
await _repository.UpsertMonthlyAsync(CreateRow("build-2", "sha256:payload-2", "sha256:canon-2", now.AddMinutes(2)));
|
||||
await _repository.UpsertMonthlyAsync(CreateRow("build-3", "sha256:payload-3", "sha256:canon-3", now.AddMinutes(1)));
|
||||
|
||||
var result = await _repository.FindByComponentPurlAsync(purl, limit: 10, offset: 0);
|
||||
|
||||
result.Should().HaveCount(3);
|
||||
result.Select(row => row.BuildId).Should().ContainInOrder("build-2", "build-3", "build-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindPendingTriageAsync_OnlyReturnsUnknownOrPendingRows()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var pendingRow = CreateRow(
|
||||
buildId: "build-pending",
|
||||
payloadDigest: "sha256:payload-pending",
|
||||
canonicalHash: "sha256:canon-pending",
|
||||
insertedAt: now,
|
||||
mergedVexJson: """[{"id":"CVE-2026-0001","state":"triage_pending"}]""");
|
||||
|
||||
var resolvedRow = CreateRow(
|
||||
buildId: "build-resolved",
|
||||
payloadDigest: "sha256:payload-resolved",
|
||||
canonicalHash: "sha256:canon-resolved",
|
||||
insertedAt: now.AddMinutes(1),
|
||||
mergedVexJson: """[{"id":"CVE-2026-0002","state":"resolved"}]""");
|
||||
|
||||
await _repository.UpsertMonthlyAsync(pendingRow);
|
||||
await _repository.UpsertMonthlyAsync(resolvedRow);
|
||||
|
||||
var pending = await _repository.FindPendingTriageAsync(limit: 20, offset: 0);
|
||||
|
||||
pending.Should().HaveCount(1);
|
||||
pending[0].BuildId.Should().Be("build-pending");
|
||||
pending[0].PendingMergedVexJson.Should().Contain("triage_pending");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HotLookupQueries_BenchmarkOnFixture_AreSubSecond()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
for (var i = 0; i < 300; i++)
|
||||
{
|
||||
var mergedVex = i % 5 == 0
|
||||
? """[{"id":"CVE-2026-0001","state":"unknown"}]"""
|
||||
: """[{"id":"CVE-2026-0001","state":"resolved"}]""";
|
||||
|
||||
await _repository.UpsertMonthlyAsync(CreateRow(
|
||||
buildId: $"build-bench-{i:D4}",
|
||||
payloadDigest: $"sha256:payload-bench-{i:D4}",
|
||||
canonicalHash: $"sha256:canon-bench-{i:D4}",
|
||||
insertedAt: now.AddSeconds(i),
|
||||
mergedVexJson: mergedVex));
|
||||
}
|
||||
|
||||
var payloadStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
await _repository.TryGetLatestByPayloadDigestAsync("sha256:payload-bench-0299");
|
||||
payloadStopwatch.Stop();
|
||||
|
||||
var purlStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
await _repository.FindByComponentPurlAsync("pkg:deb/debian/openssl@3.0.12", 50, 0);
|
||||
purlStopwatch.Stop();
|
||||
|
||||
var pendingStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
await _repository.FindPendingTriageAsync(100, 0);
|
||||
pendingStopwatch.Stop();
|
||||
|
||||
payloadStopwatch.ElapsedMilliseconds.Should().BeLessThan(1000);
|
||||
purlStopwatch.ElapsedMilliseconds.Should().BeLessThan(1000);
|
||||
pendingStopwatch.ElapsedMilliseconds.Should().BeLessThan(1000);
|
||||
}
|
||||
|
||||
private static ArtifactBomRow CreateRow(
|
||||
string buildId,
|
||||
string payloadDigest,
|
||||
string canonicalHash,
|
||||
DateTimeOffset insertedAt,
|
||||
int evidenceScore = 50,
|
||||
string? mergedVexJson = """[{"id":"CVE-2026-0000","state":"resolved"}]""")
|
||||
{
|
||||
return new ArtifactBomRow
|
||||
{
|
||||
BuildId = buildId,
|
||||
CanonicalBomSha256 = canonicalHash,
|
||||
PayloadDigest = payloadDigest,
|
||||
InsertedAt = insertedAt,
|
||||
RawBomRef = $"cas://raw/{buildId}",
|
||||
CanonicalBomRef = $"cas://canonical/{buildId}",
|
||||
DsseEnvelopeRef = null,
|
||||
MergedVexRef = $"cas://vex/{buildId}",
|
||||
CanonicalBomJson = """{"format":"cyclonedx","components":[{"name":"openssl","version":"3.0.12","purl":"pkg:deb/debian/openssl@3.0.12"}]}""",
|
||||
MergedVexJson = mergedVexJson,
|
||||
AttestationsJson = "[]",
|
||||
EvidenceScore = evidenceScore,
|
||||
RekorTileId = null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
namespace StellaOps.Scanner.Storage.Tests;
|
||||
|
||||
[Collection("scanner-postgres")]
|
||||
|
||||
@@ -6,3 +6,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Scanner/__Tests/StellaOps.Scanner.Storage.Tests/StellaOps.Scanner.Storage.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| HOT-002 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: migration coverage for `scanner.artifact_boms` partition/index profile. |
|
||||
| HOT-003 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: repository idempotent write-path coverage for canonical+payload inputs. |
|
||||
| HOT-006 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: deterministic ordering and latency checks for hot-lookup query methods; local execution is Docker-gated in this environment. |
|
||||
|
||||
@@ -183,7 +183,7 @@ public sealed class StackTraceExploitPathViewServiceTests
|
||||
var view = _service.BuildView(request);
|
||||
|
||||
view.PathId.Should().Be("path:test-001");
|
||||
view.Frames.Should().HaveCountGreaterOrEqualTo(2);
|
||||
view.Frames.Should().HaveCountGreaterThanOrEqualTo(2);
|
||||
view.Frames[0].Role.Should().Be(FrameRole.Entrypoint);
|
||||
view.Frames[^1].Role.Should().Be(FrameRole.Sink);
|
||||
}
|
||||
@@ -422,7 +422,7 @@ public sealed class StackTraceExploitPathViewServiceTests
|
||||
var path = CreateExploitPath();
|
||||
var chain = StackTraceExploitPathViewService.ExtractCallChain(path);
|
||||
|
||||
chain.Should().HaveCountGreaterOrEqualTo(2);
|
||||
chain.Should().HaveCountGreaterThanOrEqualTo(2);
|
||||
chain[0].Symbol.Should().Be("POST /api/orders");
|
||||
chain[^1].Symbol.Should().Be("SqlClient.Execute");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class SbomHotLookupEndpointsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HotLookupEndpoints_ReturnLatestComponentAndPendingRows()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
await using var factory = await CreateFactoryAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var (scanId, payloadDigest) = await CreateScanAsync(client, "sha256:hotlookup0001");
|
||||
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"name": "openssl",
|
||||
"version": "3.0.12",
|
||||
"purl": "pkg:deb/debian/openssl@3.0.12"
|
||||
}
|
||||
],
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"id": "CVE-2026-0001",
|
||||
"analysis": {
|
||||
"state": "triage_pending"
|
||||
},
|
||||
"affects": [
|
||||
{ "ref": "pkg:deb/debian/openssl@3.0.12" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var submitResponse = await client.PostAsync(
|
||||
$"/api/v1/scans/{scanId}/sbom",
|
||||
new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json"));
|
||||
Assert.Equal(HttpStatusCode.Accepted, submitResponse.StatusCode);
|
||||
|
||||
var latest = await client.GetFromJsonAsync<SbomHotLookupLatestResponseDto>(
|
||||
$"/api/v1/sbom/hot-lookup/payload/{payloadDigest}/latest");
|
||||
Assert.NotNull(latest);
|
||||
Assert.Equal(payloadDigest, latest!.PayloadDigest);
|
||||
|
||||
var components = await client.GetFromJsonAsync<SbomHotLookupComponentSearchResponseDto>(
|
||||
"/api/v1/sbom/hot-lookup/components?purl=pkg:deb/debian/openssl@3.0.12&limit=20");
|
||||
Assert.NotNull(components);
|
||||
Assert.NotEmpty(components!.Items);
|
||||
|
||||
var pending = await client.GetFromJsonAsync<SbomHotLookupPendingSearchResponseDto>(
|
||||
"/api/v1/sbom/hot-lookup/pending-triage?limit=20");
|
||||
Assert.NotNull(pending);
|
||||
Assert.NotEmpty(pending!.Items);
|
||||
Assert.Equal(JsonValueKind.Array, pending.Items[0].Pending.ValueKind);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HotLookupEndpoints_DuplicateCanonicalPayload_RemainsSingleProjectionRow()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
await using var factory = await CreateFactoryAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var (scanId, payloadDigest) = await CreateScanAsync(client, "sha256:hotlookup0002");
|
||||
const string queryPurl = "pkg:deb/debian/openssl@3.0.12";
|
||||
|
||||
var first = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": [
|
||||
{ "name": "openssl", "version": "3.0.12", "purl": "pkg:deb/debian/openssl@3.0.12" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var second = """
|
||||
{
|
||||
"specVersion": "1.7",
|
||||
"components": [
|
||||
{ "version": "3.0.12", "purl": "pkg:deb/debian/openssl@3.0.12", "name": "openssl" }
|
||||
],
|
||||
"version": 1,
|
||||
"bomFormat": "CycloneDX"
|
||||
}
|
||||
""";
|
||||
|
||||
var firstSubmit = await client.PostAsync(
|
||||
$"/api/v1/scans/{scanId}/sbom",
|
||||
new StringContent(first, Encoding.UTF8, "application/vnd.cyclonedx+json"));
|
||||
Assert.Equal(HttpStatusCode.Accepted, firstSubmit.StatusCode);
|
||||
|
||||
var firstLatest = await client.GetFromJsonAsync<SbomHotLookupLatestResponseDto>(
|
||||
$"/api/v1/sbom/hot-lookup/payload/{payloadDigest}/latest");
|
||||
Assert.NotNull(firstLatest);
|
||||
|
||||
var secondSubmit = await client.PostAsync(
|
||||
$"/api/v1/scans/{scanId}/sbom",
|
||||
new StringContent(second, Encoding.UTF8, "application/vnd.cyclonedx+json"));
|
||||
Assert.Equal(HttpStatusCode.Accepted, secondSubmit.StatusCode);
|
||||
|
||||
var secondLatest = await client.GetFromJsonAsync<SbomHotLookupLatestResponseDto>(
|
||||
$"/api/v1/sbom/hot-lookup/payload/{payloadDigest}/latest");
|
||||
Assert.NotNull(secondLatest);
|
||||
Assert.Equal(firstLatest!.CanonicalBomSha256, secondLatest!.CanonicalBomSha256);
|
||||
|
||||
var componentHits = await client.GetFromJsonAsync<SbomHotLookupComponentSearchResponseDto>(
|
||||
$"/api/v1/sbom/hot-lookup/components?purl={queryPurl}");
|
||||
Assert.NotNull(componentHits);
|
||||
Assert.Single(componentHits!.Items);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HotLookupEndpoints_InvalidComponentQuery_ReturnsBadRequest()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
await using var factory = await CreateFactoryAsync();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/sbom/hot-lookup/components?purl=a&name=b");
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<ScannerApplicationFactory> CreateFactoryAsync()
|
||||
{
|
||||
var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
}, configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IArtifactObjectStore>();
|
||||
services.AddSingleton<IArtifactObjectStore>(new InMemoryArtifactObjectStore());
|
||||
});
|
||||
|
||||
await factory.InitializeAsync();
|
||||
return factory;
|
||||
}
|
||||
|
||||
private static async Task<(string ScanId, string PayloadDigest)> CreateScanAsync(HttpClient client, string payloadDigest)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/api/v1/scans", new ScanSubmitRequest
|
||||
{
|
||||
Image = new ScanImageDescriptor
|
||||
{
|
||||
Reference = "example.com/hotlookup:1.0",
|
||||
Digest = payloadDigest
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload!.ScanId));
|
||||
|
||||
return (payload.ScanId, payloadDigest);
|
||||
}
|
||||
|
||||
private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
public async Task PutAsync(ArtifactObjectDescriptor descriptor, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await content.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
_objects[$"{descriptor.Bucket}:{descriptor.Key}"] = buffer.ToArray();
|
||||
}
|
||||
|
||||
public Task<Stream?> GetAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
if (!_objects.TryGetValue($"{descriptor.Bucket}:{descriptor.Key}", out var bytes))
|
||||
{
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
|
||||
}
|
||||
|
||||
public Task DeleteAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
_objects.TryRemove($"{descriptor.Bucket}:{descriptor.Key}", out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
|
||||
using Xunit;
|
||||
|
||||
|
||||
@@ -8,3 +8,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT-20260208-062-VEXREACH-001 | DONE | Added deterministic unit coverage for VEX+reachability filter matrix and controller endpoint (`6` tests passed on filtered run, 2026-02-08). |
|
||||
| SPRINT-20260208-063-TRIAGE-001 | DONE | Add endpoint tests for triage cluster inbox stats and batch triage actions (2026-02-08). |
|
||||
| HOT-004 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: added endpoint tests for payload/component/pending triage hot-lookup APIs. |
|
||||
| HOT-006 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: deterministic query ordering/latency coverage added; local execution is Docker-gated in this environment. |
|
||||
|
||||
@@ -25,6 +25,7 @@ using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using FakeTimeProvider = Microsoft.Extensions.Time.Testing.FakeTimeProvider;
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class WorkerBasicScanScenarioTests
|
||||
|
||||
Reference in New Issue
Block a user