sprints work

This commit is contained in:
StellaOps Bot
2025-12-25 12:19:12 +02:00
parent 223843f1d1
commit 2a06f780cf
224 changed files with 41796 additions and 1515 deletions

View File

@@ -0,0 +1,537 @@
// -----------------------------------------------------------------------------
// CrossModuleEvidenceLinkingTests.cs
// Sprint: SPRINT_8100_0012_0002 - Unified Evidence Model
// Task: EVID-8100-018 - Cross-module evidence linking integration tests
// Description: Integration tests verifying evidence linking across modules:
// - Same subject can have evidence from multiple modules
// - Evidence types from Scanner, Attestor, Policy, Excititor
// - Evidence chain/graph queries work correctly
// -----------------------------------------------------------------------------
using System.Text;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Evidence.Core;
using StellaOps.Evidence.Storage.Postgres.Tests.Fixtures;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Evidence.Storage.Postgres.Tests;
/// <summary>
/// Integration tests for cross-module evidence linking.
/// Verifies that the unified evidence model correctly links evidence
/// from different modules (Scanner, Attestor, Policy, Excititor) to the same subject.
/// </summary>
[Collection(EvidencePostgresTestCollection.Name)]
public sealed class CrossModuleEvidenceLinkingTests : IAsyncLifetime
{
private readonly EvidencePostgresContainerFixture _fixture;
private readonly ITestOutputHelper _output;
private readonly string _tenantId = Guid.NewGuid().ToString();
private PostgresEvidenceStore _store = null!;
public CrossModuleEvidenceLinkingTests(
EvidencePostgresContainerFixture fixture,
ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
}
public Task InitializeAsync()
{
_store = _fixture.CreateStore(_tenantId);
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
await _fixture.TruncateAllTablesAsync();
}
#region Multi-Module Evidence for Same Subject
[Fact]
public async Task SameSubject_MultipleEvidenceTypes_AllLinked()
{
// Arrange - A container image subject with evidence from multiple modules
var subjectNodeId = $"sha256:{Guid.NewGuid():N}"; // Container image digest
var scannerEvidence = CreateScannerEvidence(subjectNodeId);
var reachabilityEvidence = CreateReachabilityEvidence(subjectNodeId);
var policyEvidence = CreatePolicyEvidence(subjectNodeId);
var vexEvidence = CreateVexEvidence(subjectNodeId);
var provenanceEvidence = CreateProvenanceEvidence(subjectNodeId);
// Act - Store all evidence
await _store.StoreAsync(scannerEvidence);
await _store.StoreAsync(reachabilityEvidence);
await _store.StoreAsync(policyEvidence);
await _store.StoreAsync(vexEvidence);
await _store.StoreAsync(provenanceEvidence);
// Assert - All evidence linked to same subject
var allEvidence = await _store.GetBySubjectAsync(subjectNodeId);
allEvidence.Should().HaveCount(5);
allEvidence.Select(e => e.EvidenceType).Should().Contain(new[]
{
EvidenceType.Scan,
EvidenceType.Reachability,
EvidenceType.Policy,
EvidenceType.Vex,
EvidenceType.Provenance
});
_output.WriteLine($"Subject {subjectNodeId} has {allEvidence.Count} evidence records from different modules");
}
[Fact]
public async Task SameSubject_FilterByType_ReturnsCorrectEvidence()
{
// Arrange
var subjectNodeId = $"sha256:{Guid.NewGuid():N}";
await _store.StoreAsync(CreateScannerEvidence(subjectNodeId));
await _store.StoreAsync(CreateScannerEvidence(subjectNodeId)); // Another scan finding
await _store.StoreAsync(CreateReachabilityEvidence(subjectNodeId));
await _store.StoreAsync(CreatePolicyEvidence(subjectNodeId));
// Act - Filter by Scan type
var scanEvidence = await _store.GetBySubjectAsync(subjectNodeId, EvidenceType.Scan);
var policyEvidence = await _store.GetBySubjectAsync(subjectNodeId, EvidenceType.Policy);
// Assert
scanEvidence.Should().HaveCount(2);
policyEvidence.Should().HaveCount(1);
}
#endregion
#region Evidence Chain Scenarios
[Fact]
public async Task EvidenceChain_ScanToVexToPolicy_LinkedCorrectly()
{
// Scenario: Vulnerability scan → VEX assessment → Policy decision
// All evidence points to the same subject (vulnerability finding)
var vulnerabilitySubject = $"sha256:{Guid.NewGuid():N}";
// 1. Scanner finds vulnerability
var scanEvidence = CreateScannerEvidence(vulnerabilitySubject);
await _store.StoreAsync(scanEvidence);
// 2. VEX assessment received
var vexEvidence = CreateVexEvidence(vulnerabilitySubject, referencedEvidenceId: scanEvidence.EvidenceId);
await _store.StoreAsync(vexEvidence);
// 3. Policy engine makes decision
var policyEvidence = CreatePolicyEvidence(vulnerabilitySubject, referencedEvidenceId: vexEvidence.EvidenceId);
await _store.StoreAsync(policyEvidence);
// Assert - Chain is queryable
var allEvidence = await _store.GetBySubjectAsync(vulnerabilitySubject);
allEvidence.Should().HaveCount(3);
// Verify order by type represents the chain
var scan = allEvidence.First(e => e.EvidenceType == EvidenceType.Scan);
var vex = allEvidence.First(e => e.EvidenceType == EvidenceType.Vex);
var policy = allEvidence.First(e => e.EvidenceType == EvidenceType.Policy);
scan.Should().NotBeNull();
vex.Should().NotBeNull();
policy.Should().NotBeNull();
_output.WriteLine($"Evidence chain: Scan({scan.EvidenceId}) → VEX({vex.EvidenceId}) → Policy({policy.EvidenceId})");
}
[Fact]
public async Task EvidenceChain_ReachabilityToEpssToPolicy_LinkedCorrectly()
{
// Scenario: Reachability analysis + EPSS score → Policy decision
var subjectNodeId = $"sha256:{Guid.NewGuid():N}";
// 1. Reachability analysis
var reachability = CreateReachabilityEvidence(subjectNodeId);
await _store.StoreAsync(reachability);
// 2. EPSS score
var epss = CreateEpssEvidence(subjectNodeId);
await _store.StoreAsync(epss);
// 3. Policy decision based on both
var policy = CreatePolicyEvidence(subjectNodeId, referencedEvidenceIds: new[]
{
reachability.EvidenceId,
epss.EvidenceId
});
await _store.StoreAsync(policy);
// Assert
var allEvidence = await _store.GetBySubjectAsync(subjectNodeId);
allEvidence.Should().HaveCount(3);
}
#endregion
#region Multi-Tenant Evidence Isolation
[Fact]
public async Task MultiTenant_SameSubject_IsolatedByTenant()
{
// Arrange - Two tenants with evidence for the same subject
var subjectNodeId = $"sha256:{Guid.NewGuid():N}";
var tenantA = Guid.NewGuid().ToString();
var tenantB = Guid.NewGuid().ToString();
var storeA = _fixture.CreateStore(tenantA);
var storeB = _fixture.CreateStore(tenantB);
var evidenceA = CreateScannerEvidence(subjectNodeId);
var evidenceB = CreateScannerEvidence(subjectNodeId);
// Act - Store in different tenant stores
await storeA.StoreAsync(evidenceA);
await storeB.StoreAsync(evidenceB);
// Assert - Each tenant only sees their own evidence
var retrievedA = await storeA.GetBySubjectAsync(subjectNodeId);
var retrievedB = await storeB.GetBySubjectAsync(subjectNodeId);
retrievedA.Should().HaveCount(1);
retrievedA[0].EvidenceId.Should().Be(evidenceA.EvidenceId);
retrievedB.Should().HaveCount(1);
retrievedB[0].EvidenceId.Should().Be(evidenceB.EvidenceId);
_output.WriteLine($"Tenant A evidence: {evidenceA.EvidenceId}");
_output.WriteLine($"Tenant B evidence: {evidenceB.EvidenceId}");
}
#endregion
#region Evidence Graph Queries
[Fact]
public async Task EvidenceGraph_AllTypesForArtifact_ReturnsComplete()
{
// Arrange - Simulate a complete evidence graph for a container artifact
var artifactDigest = $"sha256:{Guid.NewGuid():N}";
var evidenceRecords = new[]
{
CreateArtifactEvidence(artifactDigest), // SBOM entry
CreateScannerEvidence(artifactDigest), // Vulnerability scan
CreateReachabilityEvidence(artifactDigest), // Reachability analysis
CreateEpssEvidence(artifactDigest), // EPSS score
CreateVexEvidence(artifactDigest), // VEX statement
CreatePolicyEvidence(artifactDigest), // Policy decision
CreateProvenanceEvidence(artifactDigest), // Build provenance
CreateExceptionEvidence(artifactDigest) // Exception applied
};
foreach (var record in evidenceRecords)
{
await _store.StoreAsync(record);
}
// Act - Query all evidence types
var allEvidence = await _store.GetBySubjectAsync(artifactDigest);
// Assert - Complete evidence graph
allEvidence.Should().HaveCount(8);
allEvidence.Select(e => e.EvidenceType).Distinct().Should().HaveCount(8);
// Log evidence graph
foreach (var evidence in allEvidence)
{
_output.WriteLine($" {evidence.EvidenceType}: {evidence.EvidenceId}");
}
}
[Fact]
public async Task EvidenceGraph_ExistsCheck_ForAllTypes()
{
// Arrange
var subjectNodeId = $"sha256:{Guid.NewGuid():N}";
await _store.StoreAsync(CreateScannerEvidence(subjectNodeId));
await _store.StoreAsync(CreateReachabilityEvidence(subjectNodeId));
// Note: No Policy evidence
// Act & Assert
(await _store.ExistsAsync(subjectNodeId, EvidenceType.Scan)).Should().BeTrue();
(await _store.ExistsAsync(subjectNodeId, EvidenceType.Reachability)).Should().BeTrue();
(await _store.ExistsAsync(subjectNodeId, EvidenceType.Policy)).Should().BeFalse();
(await _store.ExistsAsync(subjectNodeId, EvidenceType.Vex)).Should().BeFalse();
}
#endregion
#region Cross-Module Evidence Correlation
[Fact]
public async Task Correlation_SameCorrelationId_FindsRelatedEvidence()
{
// Arrange - Evidence from different modules with same correlation ID
var subjectNodeId = $"sha256:{Guid.NewGuid():N}";
var correlationId = Guid.NewGuid().ToString();
var scanEvidence = CreateScannerEvidence(subjectNodeId, correlationId: correlationId);
var reachEvidence = CreateReachabilityEvidence(subjectNodeId, correlationId: correlationId);
var policyEvidence = CreatePolicyEvidence(subjectNodeId, correlationId: correlationId);
await _store.StoreAsync(scanEvidence);
await _store.StoreAsync(reachEvidence);
await _store.StoreAsync(policyEvidence);
// Act - Get all evidence for subject
var allEvidence = await _store.GetBySubjectAsync(subjectNodeId);
// Assert - All have same correlation ID
allEvidence.Should().HaveCount(3);
allEvidence.Should().OnlyContain(e => e.Provenance.CorrelationId == correlationId);
}
[Fact]
public async Task Generators_MultiplePerSubject_AllPreserved()
{
// Arrange - Evidence from different generators
var subjectNodeId = $"sha256:{Guid.NewGuid():N}";
var trivyEvidence = CreateScannerEvidence(subjectNodeId, generator: "stellaops/scanner/trivy");
var grypeEvidence = CreateScannerEvidence(subjectNodeId, generator: "stellaops/scanner/grype");
var snykEvidence = CreateScannerEvidence(subjectNodeId, generator: "vendor/snyk");
await _store.StoreAsync(trivyEvidence);
await _store.StoreAsync(grypeEvidence);
await _store.StoreAsync(snykEvidence);
// Act
var scanEvidence = await _store.GetBySubjectAsync(subjectNodeId, EvidenceType.Scan);
// Assert
scanEvidence.Should().HaveCount(3);
scanEvidence.Select(e => e.Provenance.GeneratorId).Should()
.Contain(new[] { "stellaops/scanner/trivy", "stellaops/scanner/grype", "vendor/snyk" });
}
#endregion
#region Evidence Count and Statistics
[Fact]
public async Task CountBySubject_AfterMultiModuleInserts_ReturnsCorrectCount()
{
// Arrange
var subjectNodeId = $"sha256:{Guid.NewGuid():N}";
await _store.StoreAsync(CreateScannerEvidence(subjectNodeId));
await _store.StoreAsync(CreateReachabilityEvidence(subjectNodeId));
await _store.StoreAsync(CreatePolicyEvidence(subjectNodeId));
// Act
var count = await _store.CountBySubjectAsync(subjectNodeId);
// Assert
count.Should().Be(3);
}
[Fact]
public async Task GetByType_AcrossSubjects_ReturnsAll()
{
// Arrange - Multiple subjects with same evidence type
var subject1 = $"sha256:{Guid.NewGuid():N}";
var subject2 = $"sha256:{Guid.NewGuid():N}";
var subject3 = $"sha256:{Guid.NewGuid():N}";
await _store.StoreAsync(CreateScannerEvidence(subject1));
await _store.StoreAsync(CreateScannerEvidence(subject2));
await _store.StoreAsync(CreateScannerEvidence(subject3));
await _store.StoreAsync(CreateReachabilityEvidence(subject1)); // Different type
// Act
var scanEvidence = await _store.GetByTypeAsync(EvidenceType.Scan);
// Assert
scanEvidence.Should().HaveCount(3);
scanEvidence.Select(e => e.SubjectNodeId).Should()
.Contain(new[] { subject1, subject2, subject3 });
}
#endregion
#region Helpers
private static EvidenceRecord CreateScannerEvidence(
string subjectNodeId,
string? correlationId = null,
string generator = "stellaops/scanner/trivy")
{
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
cve = $"CVE-2024-{Random.Shared.Next(1000, 9999)}",
severity = "HIGH",
affectedPackage = "example-lib@1.0.0"
});
var provenance = new EvidenceProvenance
{
GeneratorId = generator,
GeneratorVersion = "1.0.0",
GeneratedAt = DateTimeOffset.UtcNow,
CorrelationId = correlationId ?? Guid.NewGuid().ToString()
};
return EvidenceRecord.Create(subjectNodeId, EvidenceType.Scan, payload, provenance, "1.0.0");
}
private static EvidenceRecord CreateReachabilityEvidence(
string subjectNodeId,
string? correlationId = null)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
reachable = true,
confidence = 0.95,
paths = new[] { "main.go:42", "handler.go:128" }
});
var provenance = new EvidenceProvenance
{
GeneratorId = "stellaops/scanner/reachability",
GeneratorVersion = "1.0.0",
GeneratedAt = DateTimeOffset.UtcNow,
CorrelationId = correlationId ?? Guid.NewGuid().ToString()
};
return EvidenceRecord.Create(subjectNodeId, EvidenceType.Reachability, payload, provenance, "1.0.0");
}
private static EvidenceRecord CreatePolicyEvidence(
string subjectNodeId,
string? referencedEvidenceId = null,
string[]? referencedEvidenceIds = null,
string? correlationId = null)
{
var refs = referencedEvidenceIds ?? (referencedEvidenceId is not null ? new[] { referencedEvidenceId } : null);
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
ruleId = "vuln-severity-block",
verdict = "BLOCK",
referencedEvidence = refs
});
var provenance = new EvidenceProvenance
{
GeneratorId = "stellaops/policy/opa",
GeneratorVersion = "1.0.0",
GeneratedAt = DateTimeOffset.UtcNow,
CorrelationId = correlationId ?? Guid.NewGuid().ToString()
};
return EvidenceRecord.Create(subjectNodeId, EvidenceType.Policy, payload, provenance, "1.0.0");
}
private static EvidenceRecord CreateVexEvidence(
string subjectNodeId,
string? referencedEvidenceId = null)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
status = "not_affected",
justification = "vulnerable_code_not_in_execute_path",
referencedEvidence = referencedEvidenceId
});
var provenance = new EvidenceProvenance
{
GeneratorId = "stellaops/excititor/vex",
GeneratorVersion = "1.0.0",
GeneratedAt = DateTimeOffset.UtcNow
};
return EvidenceRecord.Create(subjectNodeId, EvidenceType.Vex, payload, provenance, "1.0.0");
}
private static EvidenceRecord CreateEpssEvidence(string subjectNodeId)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
score = 0.0342,
percentile = 0.89,
modelDate = "2024-12-25"
});
var provenance = new EvidenceProvenance
{
GeneratorId = "stellaops/scanner/epss",
GeneratorVersion = "1.0.0",
GeneratedAt = DateTimeOffset.UtcNow
};
return EvidenceRecord.Create(subjectNodeId, EvidenceType.Epss, payload, provenance, "1.0.0");
}
private static EvidenceRecord CreateProvenanceEvidence(string subjectNodeId)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
buildId = Guid.NewGuid().ToString(),
builder = "github-actions",
inputs = new[] { "go.mod", "main.go" }
});
var provenance = new EvidenceProvenance
{
GeneratorId = "stellaops/attestor/provenance",
GeneratorVersion = "1.0.0",
GeneratedAt = DateTimeOffset.UtcNow
};
return EvidenceRecord.Create(subjectNodeId, EvidenceType.Provenance, payload, provenance, "1.0.0");
}
private static EvidenceRecord CreateArtifactEvidence(string subjectNodeId)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
purl = "pkg:golang/example.com/mylib@1.0.0",
digest = subjectNodeId,
sbomFormat = "SPDX-3.0.1"
});
var provenance = new EvidenceProvenance
{
GeneratorId = "stellaops/scanner/sbom",
GeneratorVersion = "1.0.0",
GeneratedAt = DateTimeOffset.UtcNow
};
return EvidenceRecord.Create(subjectNodeId, EvidenceType.Artifact, payload, provenance, "1.0.0");
}
private static EvidenceRecord CreateExceptionEvidence(string subjectNodeId)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
exceptionId = Guid.NewGuid().ToString(),
reason = "Risk accepted per security review",
expiry = DateTimeOffset.UtcNow.AddDays(90)
});
var provenance = new EvidenceProvenance
{
GeneratorId = "stellaops/policy/exceptions",
GeneratorVersion = "1.0.0",
GeneratedAt = DateTimeOffset.UtcNow
};
return EvidenceRecord.Create(subjectNodeId, EvidenceType.Exception, payload, provenance, "1.0.0");
}
#endregion
}

View File

@@ -0,0 +1,185 @@
// -----------------------------------------------------------------------------
// EvidencePostgresContainerFixture.cs
// Sprint: SPRINT_8100_0012_0002 - Unified Evidence Model
// Task: EVID-8100-017 - PostgreSQL store integration tests
// Description: Collection fixture providing a shared PostgreSQL container for Evidence storage tests
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Infrastructure.Postgres.Testing;
using Testcontainers.PostgreSql;
using Xunit.Sdk;
namespace StellaOps.Evidence.Storage.Postgres.Tests.Fixtures;
/// <summary>
/// Collection fixture that provides a shared PostgreSQL container for Evidence storage integration tests.
/// Uses Testcontainers to spin up a PostgreSQL instance with the evidence schema.
/// </summary>
public sealed class EvidencePostgresContainerFixture : IAsyncLifetime, IAsyncDisposable
{
private PostgreSqlContainer? _container;
private PostgresFixture? _fixture;
private bool _disposed;
/// <summary>
/// Gets whether the container is running.
/// </summary>
public bool IsRunning => _container is not null;
/// <summary>
/// Gets the connection string for the PostgreSQL container.
/// </summary>
public string ConnectionString => _container?.GetConnectionString()
?? throw new InvalidOperationException("Container not started");
/// <summary>
/// Gets the PostgreSQL fixture for test schema management.
/// </summary>
public PostgresFixture Fixture => _fixture
?? throw new InvalidOperationException("Fixture not initialized");
/// <summary>
/// Creates PostgreSQL options configured for the test container.
/// </summary>
public PostgresOptions CreateOptions()
{
return new PostgresOptions
{
ConnectionString = ConnectionString,
SchemaName = EvidenceDataSource.DefaultSchemaName,
CommandTimeoutSeconds = 30,
AutoMigrate = false
};
}
/// <summary>
/// Creates an EvidenceDataSource for tests.
/// </summary>
public EvidenceDataSource CreateDataSource()
{
var options = Options.Create(CreateOptions());
return new EvidenceDataSource(options, NullLogger<EvidenceDataSource>.Instance);
}
/// <summary>
/// Creates a PostgresEvidenceStore for the specified tenant.
/// </summary>
public PostgresEvidenceStore CreateStore(string tenantId)
{
var dataSource = CreateDataSource();
return new PostgresEvidenceStore(
dataSource,
tenantId,
NullLogger<PostgresEvidenceStore>.Instance);
}
/// <summary>
/// Creates a PostgresEvidenceStoreFactory for tests.
/// </summary>
public PostgresEvidenceStoreFactory CreateStoreFactory()
{
var dataSource = CreateDataSource();
return new PostgresEvidenceStoreFactory(dataSource, NullLoggerFactory.Instance);
}
/// <inheritdoc />
public async Task InitializeAsync()
{
try
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("stellaops_test")
.WithUsername("test")
.WithPassword("test")
.Build();
await _container.StartAsync();
// Create fixture for schema management
_fixture = PostgresFixtureFactory.CreateRandom(ConnectionString);
await _fixture.InitializeAsync();
// Run evidence schema migrations
await _fixture.RunMigrationsFromAssemblyAsync<EvidenceDataSource>(
"Evidence",
resourcePrefix: null);
}
catch (Exception ex)
{
try
{
if (_fixture is not null)
{
await _fixture.DisposeAsync();
}
if (_container is not null)
{
await _container.DisposeAsync();
}
}
catch
{
// Ignore cleanup failures during skip.
}
_container = null;
_fixture = null;
throw SkipException.ForSkip(
$"Evidence PostgreSQL integration tests require Docker/Testcontainers. Skipping because the container failed to start: {ex.Message}");
}
}
/// <inheritdoc />
public async Task DisposeAsync()
{
await DisposeAsyncCore();
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
await DisposeAsyncCore();
GC.SuppressFinalize(this);
}
private async Task DisposeAsyncCore()
{
if (_disposed) return;
_disposed = true;
if (_fixture is not null)
{
await _fixture.DisposeAsync();
}
if (_container is not null)
{
await _container.StopAsync();
await _container.DisposeAsync();
}
}
/// <summary>
/// Truncates all tables for test isolation.
/// </summary>
public async Task TruncateAllTablesAsync()
{
if (_fixture is null) return;
await _fixture.TruncateAllTablesAsync();
}
}
/// <summary>
/// Collection definition for Evidence PostgreSQL integration tests.
/// All tests in this collection share a single PostgreSQL container.
/// </summary>
[CollectionDefinition(Name)]
public sealed class EvidencePostgresTestCollection : ICollectionFixture<EvidencePostgresContainerFixture>
{
public const string Name = "Evidence PostgreSQL Integration Tests";
}

View File

@@ -0,0 +1,530 @@
// -----------------------------------------------------------------------------
// PostgresEvidenceStoreIntegrationTests.cs
// Sprint: SPRINT_8100_0012_0002 - Unified Evidence Model
// Task: EVID-8100-017 - PostgreSQL store CRUD integration tests
// Description: Integration tests verifying PostgresEvidenceStore CRUD operations
// -----------------------------------------------------------------------------
using System.Text;
using FluentAssertions;
using StellaOps.Evidence.Core;
using StellaOps.Evidence.Storage.Postgres.Tests.Fixtures;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Evidence.Storage.Postgres.Tests;
/// <summary>
/// Integration tests for PostgresEvidenceStore CRUD operations.
/// Tests run against a real PostgreSQL container via Testcontainers.
/// </summary>
[Collection(EvidencePostgresTestCollection.Name)]
public sealed class PostgresEvidenceStoreIntegrationTests : IAsyncLifetime
{
private readonly EvidencePostgresContainerFixture _fixture;
private readonly ITestOutputHelper _output;
private readonly string _tenantId = Guid.NewGuid().ToString();
private PostgresEvidenceStore _store = null!;
public PostgresEvidenceStoreIntegrationTests(
EvidencePostgresContainerFixture fixture,
ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
}
public Task InitializeAsync()
{
_store = _fixture.CreateStore(_tenantId);
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
await _fixture.TruncateAllTablesAsync();
}
#region Store Tests
[Fact]
public async Task StoreAsync_NewEvidence_ReturnsEvidenceId()
{
// Arrange
var evidence = CreateTestEvidence();
// Act
var storedId = await _store.StoreAsync(evidence);
// Assert
storedId.Should().Be(evidence.EvidenceId);
_output.WriteLine($"Stored evidence: {storedId}");
}
[Fact]
public async Task StoreAsync_DuplicateEvidence_IsIdempotent()
{
// Arrange
var evidence = CreateTestEvidence();
// Act - Store twice
var firstId = await _store.StoreAsync(evidence);
var secondId = await _store.StoreAsync(evidence);
// Assert - Both return same ID, no error
firstId.Should().Be(evidence.EvidenceId);
secondId.Should().Be(evidence.EvidenceId);
// Verify only one record exists
var count = await _store.CountBySubjectAsync(evidence.SubjectNodeId);
count.Should().Be(1);
}
[Fact]
public async Task StoreBatchAsync_MultipleRecords_StoresAllSuccessfully()
{
// Arrange
var subjectId = $"sha256:{Guid.NewGuid():N}";
var records = Enumerable.Range(1, 5)
.Select(i => CreateTestEvidence(subjectId, (EvidenceType)(i % 5 + 1)))
.ToList();
// Act
var storedCount = await _store.StoreBatchAsync(records);
// Assert
storedCount.Should().Be(5);
var count = await _store.CountBySubjectAsync(subjectId);
count.Should().Be(5);
}
[Fact]
public async Task StoreBatchAsync_WithDuplicates_StoresOnlyUnique()
{
// Arrange
var evidence = CreateTestEvidence();
var records = new[] { evidence, evidence, evidence };
// Act
var storedCount = await _store.StoreBatchAsync(records);
// Assert - Only one should be stored
storedCount.Should().Be(1);
}
#endregion
#region GetById Tests
[Fact]
public async Task GetByIdAsync_ExistingEvidence_ReturnsEvidence()
{
// Arrange
var evidence = CreateTestEvidence();
await _store.StoreAsync(evidence);
// Act
var retrieved = await _store.GetByIdAsync(evidence.EvidenceId);
// Assert
retrieved.Should().NotBeNull();
retrieved!.EvidenceId.Should().Be(evidence.EvidenceId);
retrieved.SubjectNodeId.Should().Be(evidence.SubjectNodeId);
retrieved.EvidenceType.Should().Be(evidence.EvidenceType);
retrieved.PayloadSchemaVersion.Should().Be(evidence.PayloadSchemaVersion);
retrieved.Payload.ToArray().Should().BeEquivalentTo(evidence.Payload.ToArray());
retrieved.Provenance.GeneratorId.Should().Be(evidence.Provenance.GeneratorId);
}
[Fact]
public async Task GetByIdAsync_NonExistingEvidence_ReturnsNull()
{
// Arrange
var nonExistentId = $"sha256:{Guid.NewGuid():N}";
// Act
var retrieved = await _store.GetByIdAsync(nonExistentId);
// Assert
retrieved.Should().BeNull();
}
[Fact]
public async Task GetByIdAsync_WithSignatures_PreservesSignatures()
{
// Arrange
var evidence = CreateTestEvidenceWithSignatures();
await _store.StoreAsync(evidence);
// Act
var retrieved = await _store.GetByIdAsync(evidence.EvidenceId);
// Assert
retrieved.Should().NotBeNull();
retrieved!.Signatures.Should().HaveCount(2);
retrieved.Signatures[0].SignerId.Should().Be("signer-1");
retrieved.Signatures[1].SignerId.Should().Be("signer-2");
}
#endregion
#region GetBySubject Tests
[Fact]
public async Task GetBySubjectAsync_MultipleEvidence_ReturnsAll()
{
// Arrange
var subjectId = $"sha256:{Guid.NewGuid():N}";
var records = new[]
{
CreateTestEvidence(subjectId, EvidenceType.Scan),
CreateTestEvidence(subjectId, EvidenceType.Reachability),
CreateTestEvidence(subjectId, EvidenceType.Policy)
};
foreach (var record in records)
{
await _store.StoreAsync(record);
}
// Act
var retrieved = await _store.GetBySubjectAsync(subjectId);
// Assert
retrieved.Should().HaveCount(3);
retrieved.Select(e => e.EvidenceType).Should()
.Contain(new[] { EvidenceType.Scan, EvidenceType.Reachability, EvidenceType.Policy });
}
[Fact]
public async Task GetBySubjectAsync_WithTypeFilter_ReturnsFiltered()
{
// Arrange
var subjectId = $"sha256:{Guid.NewGuid():N}";
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Scan));
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Reachability));
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Policy));
// Act
var retrieved = await _store.GetBySubjectAsync(subjectId, EvidenceType.Scan);
// Assert
retrieved.Should().HaveCount(1);
retrieved[0].EvidenceType.Should().Be(EvidenceType.Scan);
}
[Fact]
public async Task GetBySubjectAsync_NoEvidence_ReturnsEmptyList()
{
// Arrange
var nonExistentSubject = $"sha256:{Guid.NewGuid():N}";
// Act
var retrieved = await _store.GetBySubjectAsync(nonExistentSubject);
// Assert
retrieved.Should().BeEmpty();
}
#endregion
#region GetByType Tests
[Fact]
public async Task GetByTypeAsync_MultipleEvidence_ReturnsMatchingType()
{
// Arrange
await _store.StoreAsync(CreateTestEvidence(evidenceType: EvidenceType.Scan));
await _store.StoreAsync(CreateTestEvidence(evidenceType: EvidenceType.Scan));
await _store.StoreAsync(CreateTestEvidence(evidenceType: EvidenceType.Reachability));
// Act
var retrieved = await _store.GetByTypeAsync(EvidenceType.Scan);
// Assert
retrieved.Should().HaveCount(2);
retrieved.Should().OnlyContain(e => e.EvidenceType == EvidenceType.Scan);
}
[Fact]
public async Task GetByTypeAsync_WithLimit_RespectsLimit()
{
// Arrange
for (int i = 0; i < 10; i++)
{
await _store.StoreAsync(CreateTestEvidence(evidenceType: EvidenceType.Vex));
}
// Act
var retrieved = await _store.GetByTypeAsync(EvidenceType.Vex, limit: 5);
// Assert
retrieved.Should().HaveCount(5);
}
#endregion
#region Exists Tests
[Fact]
public async Task ExistsAsync_ExistingEvidence_ReturnsTrue()
{
// Arrange
var evidence = CreateTestEvidence();
await _store.StoreAsync(evidence);
// Act
var exists = await _store.ExistsAsync(evidence.SubjectNodeId, evidence.EvidenceType);
// Assert
exists.Should().BeTrue();
}
[Fact]
public async Task ExistsAsync_NonExistingEvidence_ReturnsFalse()
{
// Arrange
var evidence = CreateTestEvidence();
await _store.StoreAsync(evidence);
// Act - Check for different type
var exists = await _store.ExistsAsync(evidence.SubjectNodeId, EvidenceType.License);
// Assert
exists.Should().BeFalse();
}
[Fact]
public async Task ExistsAsync_NonExistingSubject_ReturnsFalse()
{
// Arrange
var nonExistentSubject = $"sha256:{Guid.NewGuid():N}";
// Act
var exists = await _store.ExistsAsync(nonExistentSubject, EvidenceType.Scan);
// Assert
exists.Should().BeFalse();
}
#endregion
#region Delete Tests
[Fact]
public async Task DeleteAsync_ExistingEvidence_ReturnsTrue()
{
// Arrange
var evidence = CreateTestEvidence();
await _store.StoreAsync(evidence);
// Act
var deleted = await _store.DeleteAsync(evidence.EvidenceId);
// Assert
deleted.Should().BeTrue();
// Verify deletion
var retrieved = await _store.GetByIdAsync(evidence.EvidenceId);
retrieved.Should().BeNull();
}
[Fact]
public async Task DeleteAsync_NonExistingEvidence_ReturnsFalse()
{
// Arrange
var nonExistentId = $"sha256:{Guid.NewGuid():N}";
// Act
var deleted = await _store.DeleteAsync(nonExistentId);
// Assert
deleted.Should().BeFalse();
}
#endregion
#region Count Tests
[Fact]
public async Task CountBySubjectAsync_MultipleEvidence_ReturnsCorrectCount()
{
// Arrange
var subjectId = $"sha256:{Guid.NewGuid():N}";
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Scan));
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Reachability));
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Policy));
// Act
var count = await _store.CountBySubjectAsync(subjectId);
// Assert
count.Should().Be(3);
}
[Fact]
public async Task CountBySubjectAsync_NoEvidence_ReturnsZero()
{
// Arrange
var nonExistentSubject = $"sha256:{Guid.NewGuid():N}";
// Act
var count = await _store.CountBySubjectAsync(nonExistentSubject);
// Assert
count.Should().Be(0);
}
#endregion
#region Integrity Tests
[Fact]
public async Task RoundTrip_EvidenceRecord_PreservesIntegrity()
{
// Arrange
var evidence = CreateTestEvidence();
await _store.StoreAsync(evidence);
// Act
var retrieved = await _store.GetByIdAsync(evidence.EvidenceId) as EvidenceRecord;
// Assert
retrieved.Should().NotBeNull();
retrieved!.VerifyIntegrity().Should().BeTrue("evidence ID should match computed hash");
}
[Fact]
public async Task RoundTrip_BinaryPayload_PreservesData()
{
// Arrange
var binaryPayload = new byte[] { 0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD };
var provenance = EvidenceProvenance.CreateMinimal("test/binary", "1.0.0");
var evidence = EvidenceRecord.Create(
$"sha256:{Guid.NewGuid():N}",
EvidenceType.Artifact,
binaryPayload,
provenance,
"1.0.0");
await _store.StoreAsync(evidence);
// Act
var retrieved = await _store.GetByIdAsync(evidence.EvidenceId);
// Assert
retrieved.Should().NotBeNull();
retrieved!.Payload.ToArray().Should().BeEquivalentTo(binaryPayload);
}
[Fact]
public async Task RoundTrip_UnicodePayload_PreservesData()
{
// Arrange
var unicodeJson = "{\"message\": \"Hello 世界 🌍 مرحبا\"}";
var payload = Encoding.UTF8.GetBytes(unicodeJson);
var provenance = EvidenceProvenance.CreateMinimal("test/unicode", "1.0.0");
var evidence = EvidenceRecord.Create(
$"sha256:{Guid.NewGuid():N}",
EvidenceType.Custom,
payload,
provenance,
"1.0.0");
await _store.StoreAsync(evidence);
// Act
var retrieved = await _store.GetByIdAsync(evidence.EvidenceId);
// Assert
retrieved.Should().NotBeNull();
var retrievedJson = Encoding.UTF8.GetString(retrieved!.Payload.Span);
retrievedJson.Should().Be(unicodeJson);
}
#endregion
#region Factory Tests
[Fact]
public void Factory_CreateStore_ReturnsTenantScopedStore()
{
// Arrange
var factory = _fixture.CreateStoreFactory();
var tenantId1 = Guid.NewGuid().ToString();
var tenantId2 = Guid.NewGuid().ToString();
// Act
var store1 = factory.Create(tenantId1);
var store2 = factory.Create(tenantId2);
// Assert
store1.Should().NotBeNull();
store2.Should().NotBeNull();
store1.Should().NotBeSameAs(store2);
}
#endregion
#region Helpers
private static EvidenceRecord CreateTestEvidence(
string? subjectNodeId = null,
EvidenceType evidenceType = EvidenceType.Scan)
{
var subject = subjectNodeId ?? $"sha256:{Guid.NewGuid():N}";
var payload = Encoding.UTF8.GetBytes($"{{\"test\": \"{Guid.NewGuid()}\"}}");
var provenance = new EvidenceProvenance
{
GeneratorId = "test/scanner",
GeneratorVersion = "1.0.0",
GeneratedAt = DateTimeOffset.UtcNow,
CorrelationId = Guid.NewGuid().ToString(),
Environment = "test"
};
return EvidenceRecord.Create(
subject,
evidenceType,
payload,
provenance,
"1.0.0");
}
private static EvidenceRecord CreateTestEvidenceWithSignatures()
{
var subject = $"sha256:{Guid.NewGuid():N}";
var payload = Encoding.UTF8.GetBytes("{\"signed\": true}");
var provenance = EvidenceProvenance.CreateMinimal("test/signer", "1.0.0");
var signatures = new List<EvidenceSignature>
{
new()
{
SignerId = "signer-1",
Algorithm = "ES256",
SignatureBase64 = Convert.ToBase64String(new byte[] { 1, 2, 3 }),
SignedAt = DateTimeOffset.UtcNow,
SignerType = SignerType.Internal
},
new()
{
SignerId = "signer-2",
Algorithm = "RS256",
SignatureBase64 = Convert.ToBase64String(new byte[] { 4, 5, 6 }),
SignedAt = DateTimeOffset.UtcNow,
SignerType = SignerType.CI
}
};
return EvidenceRecord.Create(
subject,
EvidenceType.Provenance,
payload,
provenance,
"1.0.0",
signatures);
}
#endregion
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.Evidence.Storage.Postgres.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Evidence.Storage.Postgres\StellaOps.Evidence.Storage.Postgres.csproj" />
<ProjectReference Include="..\..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>