- 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.
374 lines
12 KiB
C#
374 lines
12 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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>
|
|
{
|
|
}
|