Add integration tests for Proof Chain and Reachability workflows
- Implement ProofChainTestFixture for PostgreSQL-backed integration tests. - Create StellaOps.Integration.ProofChain project with necessary dependencies. - Add ReachabilityIntegrationTests to validate call graph extraction and reachability analysis. - Introduce ReachabilityTestFixture for managing corpus and fixture paths. - Establish StellaOps.Integration.Reachability project with required references. - Develop UnknownsWorkflowTests to cover the unknowns lifecycle: detection, ranking, escalation, and resolution. - Create StellaOps.Integration.Unknowns project with dependencies for unknowns workflow.
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ProofChainIntegrationTests.cs
|
||||
// Sprint: SPRINT_3500_0004_0003_integration_tests_corpus
|
||||
// Task: T1 - Proof Chain Integration Tests
|
||||
// Description: End-to-end tests for complete proof chain workflow:
|
||||
// scan → manifest → score → proof bundle → verify
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Integration.ProofChain;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end integration tests for the proof chain workflow.
|
||||
/// Tests the complete flow: scan submission → manifest creation → score computation
|
||||
/// → proof bundle generation → verification.
|
||||
/// </summary>
|
||||
[Collection("ProofChainIntegration")]
|
||||
public class ProofChainIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ProofChainTestFixture _fixture;
|
||||
private HttpClient _client = null!;
|
||||
|
||||
public ProofChainIntegrationTests(ProofChainTestFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_client = await _fixture.CreateClientAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#region T1-AC1: Test scan submission creates manifest
|
||||
|
||||
[Fact]
|
||||
public async Task ScanSubmission_CreatesManifest_WithCorrectHashes()
|
||||
{
|
||||
// Arrange
|
||||
var sbomContent = CreateMinimalSbom();
|
||||
var scanRequest = new
|
||||
{
|
||||
sbom = sbomContent,
|
||||
policyId = "default",
|
||||
metadata = new { source = "integration-test" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scans", scanRequest);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var scanResult = await response.Content.ReadFromJsonAsync<ScanResponse>();
|
||||
scanResult.Should().NotBeNull();
|
||||
scanResult!.ScanId.Should().NotBeEmpty();
|
||||
|
||||
// Verify manifest was created
|
||||
var manifestResponse = await _client.GetAsync($"/api/v1/scans/{scanResult.ScanId}/manifest");
|
||||
manifestResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var manifest = await manifestResponse.Content.ReadFromJsonAsync<ManifestResponse>();
|
||||
manifest.Should().NotBeNull();
|
||||
manifest!.SbomHash.Should().StartWith("sha256:");
|
||||
manifest.ManifestHash.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region T1-AC2: Test score computation produces deterministic results
|
||||
|
||||
[Fact]
|
||||
public async Task ScoreComputation_IsDeterministic_WithSameInputs()
|
||||
{
|
||||
// Arrange
|
||||
var sbomContent = CreateSbomWithVulnerability("CVE-2024-12345");
|
||||
var scanRequest = new
|
||||
{
|
||||
sbom = sbomContent,
|
||||
policyId = "default"
|
||||
};
|
||||
|
||||
// Act - Run scan twice with identical inputs
|
||||
var response1 = await _client.PostAsJsonAsync("/api/v1/scans", scanRequest);
|
||||
var scan1 = await response1.Content.ReadFromJsonAsync<ScanResponse>();
|
||||
|
||||
var response2 = await _client.PostAsJsonAsync("/api/v1/scans", scanRequest);
|
||||
var scan2 = await response2.Content.ReadFromJsonAsync<ScanResponse>();
|
||||
|
||||
// Assert - Both scans should produce identical manifest hashes
|
||||
var manifest1 = await GetManifestAsync(scan1!.ScanId);
|
||||
var manifest2 = await GetManifestAsync(scan2!.ScanId);
|
||||
|
||||
manifest1.SbomHash.Should().Be(manifest2.SbomHash);
|
||||
manifest1.RulesHash.Should().Be(manifest2.RulesHash);
|
||||
manifest1.PolicyHash.Should().Be(manifest2.PolicyHash);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region T1-AC3: Test proof bundle generation and signing
|
||||
|
||||
[Fact]
|
||||
public async Task ProofBundle_IsGenerated_WithValidDsseEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var sbomContent = CreateMinimalSbom();
|
||||
var scanRequest = new { sbom = sbomContent, policyId = "default" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scans", scanRequest);
|
||||
var scan = await response.Content.ReadFromJsonAsync<ScanResponse>();
|
||||
|
||||
// Get proof bundle
|
||||
var proofsResponse = await _client.GetAsync($"/api/v1/scans/{scan!.ScanId}/proofs");
|
||||
|
||||
// Assert
|
||||
proofsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var proofs = await proofsResponse.Content.ReadFromJsonAsync<ProofsListResponse>();
|
||||
proofs.Should().NotBeNull();
|
||||
proofs!.Items.Should().NotBeEmpty();
|
||||
|
||||
var proof = proofs.Items.First();
|
||||
proof.RootHash.Should().StartWith("sha256:");
|
||||
proof.DsseEnvelopeValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region T1-AC4: Test proof verification succeeds for valid bundles
|
||||
|
||||
[Fact]
|
||||
public async Task ProofVerification_Succeeds_ForValidBundle()
|
||||
{
|
||||
// Arrange
|
||||
var sbomContent = CreateMinimalSbom();
|
||||
var scanRequest = new { sbom = sbomContent, policyId = "default" };
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scans", scanRequest);
|
||||
var scan = await response.Content.ReadFromJsonAsync<ScanResponse>();
|
||||
|
||||
var proofsResponse = await _client.GetAsync($"/api/v1/scans/{scan!.ScanId}/proofs");
|
||||
var proofs = await proofsResponse.Content.ReadFromJsonAsync<ProofsListResponse>();
|
||||
var rootHash = proofs!.Items.First().RootHash;
|
||||
|
||||
// Act
|
||||
var verifyResponse = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/scans/{scan.ScanId}/proofs/{rootHash}/verify",
|
||||
new { });
|
||||
|
||||
// Assert
|
||||
verifyResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var verifyResult = await verifyResponse.Content.ReadFromJsonAsync<VerifyResponse>();
|
||||
verifyResult.Should().NotBeNull();
|
||||
verifyResult!.Valid.Should().BeTrue();
|
||||
verifyResult.Checks.Should().Contain(c => c.Name == "dsse_signature" && c.Passed);
|
||||
verifyResult.Checks.Should().Contain(c => c.Name == "merkle_root" && c.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region T1-AC5: Test verification fails for tampered bundles
|
||||
|
||||
[Fact]
|
||||
public async Task ProofVerification_Fails_ForTamperedBundle()
|
||||
{
|
||||
// Arrange
|
||||
var sbomContent = CreateMinimalSbom();
|
||||
var scanRequest = new { sbom = sbomContent, policyId = "default" };
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scans", scanRequest);
|
||||
var scan = await response.Content.ReadFromJsonAsync<ScanResponse>();
|
||||
|
||||
// Get a valid proof then tamper with the hash
|
||||
var proofsResponse = await _client.GetAsync($"/api/v1/scans/{scan!.ScanId}/proofs");
|
||||
var proofs = await proofsResponse.Content.ReadFromJsonAsync<ProofsListResponse>();
|
||||
var originalHash = proofs!.Items.First().RootHash;
|
||||
var tamperedHash = "sha256:" + new string('0', 64); // Tampered hash
|
||||
|
||||
// Act
|
||||
var verifyResponse = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/scans/{scan.ScanId}/proofs/{tamperedHash}/verify",
|
||||
new { });
|
||||
|
||||
// Assert
|
||||
verifyResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region T1-AC6: Test replay produces identical scores
|
||||
|
||||
[Fact]
|
||||
public async Task ScoreReplay_ProducesIdenticalScore_WithSameManifest()
|
||||
{
|
||||
// Arrange
|
||||
var sbomContent = CreateSbomWithVulnerability("CVE-2024-99999");
|
||||
var scanRequest = new { sbom = sbomContent, policyId = "default" };
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/scans", scanRequest);
|
||||
var scan = await response.Content.ReadFromJsonAsync<ScanResponse>();
|
||||
|
||||
var manifest = await GetManifestAsync(scan!.ScanId);
|
||||
var originalProofs = await GetProofsAsync(scan.ScanId);
|
||||
var originalRootHash = originalProofs.Items.First().RootHash;
|
||||
|
||||
// Act - Replay the score computation
|
||||
var replayResponse = await _client.PostAsJsonAsync(
|
||||
$"/api/v1/scans/{scan.ScanId}/score/replay",
|
||||
new { manifestHash = manifest.ManifestHash });
|
||||
|
||||
// Assert
|
||||
replayResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var replayResult = await replayResponse.Content.ReadFromJsonAsync<ReplayResponse>();
|
||||
replayResult.Should().NotBeNull();
|
||||
replayResult!.RootHash.Should().Be(originalRootHash);
|
||||
replayResult.Deterministic.Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string CreateMinimalSbom()
|
||||
{
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
specVersion = "1.5",
|
||||
version = 1,
|
||||
metadata = new
|
||||
{
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
component = new
|
||||
{
|
||||
type = "application",
|
||||
name = "integration-test-app",
|
||||
version = "1.0.0"
|
||||
}
|
||||
},
|
||||
components = Array.Empty<object>()
|
||||
});
|
||||
}
|
||||
|
||||
private static string CreateSbomWithVulnerability(string cveId)
|
||||
{
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
bomFormat = "CycloneDX",
|
||||
specVersion = "1.5",
|
||||
version = 1,
|
||||
metadata = new
|
||||
{
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
component = new
|
||||
{
|
||||
type = "application",
|
||||
name = "vuln-test-app",
|
||||
version = "1.0.0"
|
||||
}
|
||||
},
|
||||
components = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "library",
|
||||
name = "vulnerable-package",
|
||||
version = "1.0.0",
|
||||
purl = "pkg:npm/vulnerable-package@1.0.0"
|
||||
}
|
||||
},
|
||||
vulnerabilities = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = cveId,
|
||||
source = new { name = "NVD" },
|
||||
ratings = new[]
|
||||
{
|
||||
new { severity = "high", score = 7.5, method = "CVSSv31" }
|
||||
},
|
||||
affects = new[]
|
||||
{
|
||||
new { @ref = "pkg:npm/vulnerable-package@1.0.0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<ManifestResponse> GetManifestAsync(string scanId)
|
||||
{
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/manifest");
|
||||
response.EnsureSuccessStatusCode();
|
||||
return (await response.Content.ReadFromJsonAsync<ManifestResponse>())!;
|
||||
}
|
||||
|
||||
private async Task<ProofsListResponse> GetProofsAsync(string scanId)
|
||||
{
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/proofs");
|
||||
response.EnsureSuccessStatusCode();
|
||||
return (await response.Content.ReadFromJsonAsync<ProofsListResponse>())!;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DTOs
|
||||
|
||||
private sealed record ScanResponse(
|
||||
string ScanId,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
private sealed record ManifestResponse(
|
||||
string ManifestHash,
|
||||
string SbomHash,
|
||||
string RulesHash,
|
||||
string FeedHash,
|
||||
string PolicyHash,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
private sealed record ProofsListResponse(
|
||||
IReadOnlyList<ProofItem> Items);
|
||||
|
||||
private sealed record ProofItem(
|
||||
string RootHash,
|
||||
string BundleUri,
|
||||
bool DsseEnvelopeValid,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
private sealed record VerifyResponse(
|
||||
bool Valid,
|
||||
string RootHash,
|
||||
IReadOnlyList<VerifyCheck> Checks);
|
||||
|
||||
private sealed record VerifyCheck(
|
||||
string Name,
|
||||
bool Passed,
|
||||
string? Message);
|
||||
|
||||
private sealed record ReplayResponse(
|
||||
string RootHash,
|
||||
double Score,
|
||||
bool Deterministic,
|
||||
DateTimeOffset ReplayedAt);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for proof chain integration tests.
|
||||
/// </summary>
|
||||
[CollectionDefinition("ProofChainIntegration")]
|
||||
public class ProofChainIntegrationCollection : ICollectionFixture<ProofChainTestFixture>
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user