stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class CrossModuleEvidenceLinkingTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Correlation_SameCorrelationId_FindsRelatedEvidenceAsync()
|
||||
{
|
||||
var subjectNodeId = "sha256:subject-corr-001";
|
||||
var correlationId = "corr-001";
|
||||
|
||||
var scanEvidence = CreateScannerEvidence(subjectNodeId, "CVE-2024-4001", correlationId);
|
||||
var reachEvidence = CreateReachabilityEvidence(subjectNodeId, correlationId);
|
||||
var policyEvidence = CreatePolicyEvidence(subjectNodeId, correlationId: correlationId);
|
||||
|
||||
await _store.StoreAsync(scanEvidence);
|
||||
await _store.StoreAsync(reachEvidence);
|
||||
await _store.StoreAsync(policyEvidence);
|
||||
|
||||
var allEvidence = await _store.GetBySubjectAsync(subjectNodeId);
|
||||
|
||||
allEvidence.Should().HaveCount(3);
|
||||
allEvidence.Should().OnlyContain(e => e.Provenance.CorrelationId == correlationId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Generators_MultiplePerSubject_AllPreservedAsync()
|
||||
{
|
||||
var subjectNodeId = "sha256:subject-gen-001";
|
||||
|
||||
var trivyEvidence = CreateScannerEvidence(
|
||||
subjectNodeId,
|
||||
"CVE-2024-5001",
|
||||
generator: "stellaops/scanner/trivy");
|
||||
var grypeEvidence = CreateScannerEvidence(
|
||||
subjectNodeId,
|
||||
"CVE-2024-5002",
|
||||
generator: "stellaops/scanner/grype");
|
||||
var snykEvidence = CreateScannerEvidence(
|
||||
subjectNodeId,
|
||||
"CVE-2024-5003",
|
||||
generator: "vendor/snyk");
|
||||
|
||||
await _store.StoreAsync(trivyEvidence);
|
||||
await _store.StoreAsync(grypeEvidence);
|
||||
await _store.StoreAsync(snykEvidence);
|
||||
|
||||
var scanEvidence = await _store.GetBySubjectAsync(subjectNodeId, EvidenceType.Scan);
|
||||
|
||||
scanEvidence.Should().HaveCount(3);
|
||||
scanEvidence.Select(e => e.Provenance.GeneratorId).Should().Contain(new[]
|
||||
{
|
||||
"stellaops/scanner/trivy",
|
||||
"stellaops/scanner/grype",
|
||||
"vendor/snyk"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class CrossModuleEvidenceLinkingTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CountBySubject_AfterMultiModuleInserts_ReturnsCorrectCountAsync()
|
||||
{
|
||||
var subjectNodeId = "sha256:subject-count-001";
|
||||
|
||||
await _store.StoreAsync(CreateScannerEvidence(subjectNodeId, "CVE-2024-6001"));
|
||||
await _store.StoreAsync(CreateReachabilityEvidence(subjectNodeId));
|
||||
await _store.StoreAsync(CreatePolicyEvidence(subjectNodeId));
|
||||
|
||||
var count = await _store.CountBySubjectAsync(subjectNodeId);
|
||||
|
||||
count.Should().Be(3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByType_AcrossSubjects_ReturnsAllAsync()
|
||||
{
|
||||
var subject1 = "sha256:subject-type-001";
|
||||
var subject2 = "sha256:subject-type-002";
|
||||
var subject3 = "sha256:subject-type-003";
|
||||
|
||||
await _store.StoreAsync(CreateScannerEvidence(subject1, "CVE-2024-6002"));
|
||||
await _store.StoreAsync(CreateScannerEvidence(subject2, "CVE-2024-6003"));
|
||||
await _store.StoreAsync(CreateScannerEvidence(subject3, "CVE-2024-6004"));
|
||||
await _store.StoreAsync(CreateReachabilityEvidence(subject1));
|
||||
|
||||
var scanEvidence = await _store.GetByTypeAsync(EvidenceType.Scan);
|
||||
|
||||
scanEvidence.Should().HaveCount(3);
|
||||
scanEvidence.Select(e => e.SubjectNodeId).Should().Contain(new[] { subject1, subject2, subject3 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class CrossModuleEvidenceLinkingTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvidenceChain_ScanToVexToPolicy_LinkedCorrectlyAsync()
|
||||
{
|
||||
var vulnerabilitySubject = "sha256:vuln-001";
|
||||
|
||||
var scanEvidence = CreateScannerEvidence(vulnerabilitySubject, "CVE-2024-1001");
|
||||
await _store.StoreAsync(scanEvidence);
|
||||
|
||||
var vexEvidence = CreateVexEvidence(vulnerabilitySubject, scanEvidence.EvidenceId);
|
||||
await _store.StoreAsync(vexEvidence);
|
||||
|
||||
var policyEvidence = CreatePolicyEvidence(
|
||||
vulnerabilitySubject,
|
||||
referencedEvidenceId: vexEvidence.EvidenceId);
|
||||
await _store.StoreAsync(policyEvidence);
|
||||
|
||||
var allEvidence = await _store.GetBySubjectAsync(vulnerabilitySubject);
|
||||
|
||||
allEvidence.Should().HaveCount(3);
|
||||
|
||||
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})");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvidenceChain_ReachabilityToEpssToPolicy_LinkedCorrectlyAsync()
|
||||
{
|
||||
var subjectNodeId = "sha256:vuln-002";
|
||||
|
||||
var reachability = CreateReachabilityEvidence(subjectNodeId);
|
||||
await _store.StoreAsync(reachability);
|
||||
|
||||
var epss = CreateEpssEvidence(subjectNodeId);
|
||||
await _store.StoreAsync(epss);
|
||||
|
||||
var policy = CreatePolicyEvidence(
|
||||
subjectNodeId,
|
||||
referencedEvidenceIds: new[] { reachability.EvidenceId, epss.EvidenceId });
|
||||
await _store.StoreAsync(policy);
|
||||
|
||||
var allEvidence = await _store.GetBySubjectAsync(subjectNodeId);
|
||||
|
||||
allEvidence.Should().HaveCount(3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class CrossModuleEvidenceLinkingTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvidenceGraph_AllTypesForArtifact_ReturnsCompleteAsync()
|
||||
{
|
||||
var artifactDigest = "sha256:artifact-001";
|
||||
|
||||
var evidenceRecords = new[]
|
||||
{
|
||||
CreateArtifactEvidence(artifactDigest),
|
||||
CreateScannerEvidence(artifactDigest, "CVE-2024-3001"),
|
||||
CreateReachabilityEvidence(artifactDigest),
|
||||
CreateEpssEvidence(artifactDigest),
|
||||
CreateVexEvidence(artifactDigest),
|
||||
CreatePolicyEvidence(artifactDigest),
|
||||
CreateProvenanceEvidence(artifactDigest),
|
||||
CreateExceptionEvidence(artifactDigest)
|
||||
};
|
||||
|
||||
foreach (var record in evidenceRecords)
|
||||
{
|
||||
await _store.StoreAsync(record);
|
||||
}
|
||||
|
||||
var allEvidence = await _store.GetBySubjectAsync(artifactDigest);
|
||||
|
||||
allEvidence.Should().HaveCount(8);
|
||||
allEvidence.Select(e => e.EvidenceType).Distinct().Should().HaveCount(8);
|
||||
|
||||
foreach (var evidence in allEvidence)
|
||||
{
|
||||
_output.WriteLine($" {evidence.EvidenceType}: {evidence.EvidenceId}");
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EvidenceGraph_ExistsCheck_ForAllTypesAsync()
|
||||
{
|
||||
var subjectNodeId = "sha256:artifact-002";
|
||||
|
||||
await _store.StoreAsync(CreateScannerEvidence(subjectNodeId, "CVE-2024-3002"));
|
||||
await _store.StoreAsync(CreateReachabilityEvidence(subjectNodeId));
|
||||
|
||||
(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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using StellaOps.Evidence.Core;
|
||||
using System.Text.Json;
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
public sealed partial class CrossModuleEvidenceLinkingTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static EvidenceRecord CreateScannerEvidence(
|
||||
string subjectNodeId,
|
||||
string cveId,
|
||||
string? correlationId = null,
|
||||
string generator = "stellaops/scanner/trivy")
|
||||
{
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
cve = cveId,
|
||||
severity = "HIGH",
|
||||
affectedPackage = "example-lib@1.0.0"
|
||||
});
|
||||
|
||||
var provenance = new EvidenceProvenance
|
||||
{
|
||||
GeneratorId = generator,
|
||||
GeneratorVersion = "1.0.0",
|
||||
GeneratedAt = FixedNow,
|
||||
CorrelationId = correlationId
|
||||
};
|
||||
|
||||
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 = FixedNow,
|
||||
CorrelationId = correlationId
|
||||
};
|
||||
|
||||
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 = FixedNow,
|
||||
CorrelationId = correlationId
|
||||
};
|
||||
|
||||
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 = FixedNow
|
||||
};
|
||||
|
||||
return EvidenceRecord.Create(subjectNodeId, EvidenceType.Vex, payload, provenance, "1.0.0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using StellaOps.Evidence.Core;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class CrossModuleEvidenceLinkingTests
|
||||
{
|
||||
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 = FixedNow
|
||||
};
|
||||
|
||||
return EvidenceRecord.Create(subjectNodeId, EvidenceType.Epss, payload, provenance, "1.0.0");
|
||||
}
|
||||
|
||||
private static EvidenceRecord CreateProvenanceEvidence(string subjectNodeId)
|
||||
{
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
buildId = "build-001",
|
||||
builder = "github-actions",
|
||||
inputs = new[] { "go.mod", "main.go" }
|
||||
});
|
||||
|
||||
var provenance = new EvidenceProvenance
|
||||
{
|
||||
GeneratorId = "stellaops/attestor/provenance",
|
||||
GeneratorVersion = "1.0.0",
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
|
||||
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 = FixedNow
|
||||
};
|
||||
|
||||
return EvidenceRecord.Create(subjectNodeId, EvidenceType.Artifact, payload, provenance, "1.0.0");
|
||||
}
|
||||
|
||||
private static EvidenceRecord CreateExceptionEvidence(string subjectNodeId)
|
||||
{
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
exceptionId = "exception-001",
|
||||
reason = "Risk accepted per security review",
|
||||
expiry = FixedNow.AddDays(90)
|
||||
});
|
||||
|
||||
var provenance = new EvidenceProvenance
|
||||
{
|
||||
GeneratorId = "stellaops/policy/exceptions",
|
||||
GeneratorVersion = "1.0.0",
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
|
||||
return EvidenceRecord.Create(subjectNodeId, EvidenceType.Exception, payload, provenance, "1.0.0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class CrossModuleEvidenceLinkingTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MultiTenant_SameSubject_IsolatedByTenantAsync()
|
||||
{
|
||||
var subjectNodeId = "sha256:subject-tenant-001";
|
||||
var tenantA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
var tenantB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
||||
|
||||
var storeA = _fixture.CreateStore(tenantA);
|
||||
var storeB = _fixture.CreateStore(tenantB);
|
||||
|
||||
var evidenceA = CreateScannerEvidence(subjectNodeId, "CVE-2024-2001");
|
||||
var evidenceB = CreateScannerEvidence(subjectNodeId, "CVE-2024-2002");
|
||||
|
||||
await storeA.StoreAsync(evidenceA);
|
||||
await storeB.StoreAsync(evidenceB);
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class CrossModuleEvidenceLinkingTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SameSubject_MultipleEvidenceTypes_AllLinkedAsync()
|
||||
{
|
||||
var subjectNodeId = "sha256:subject-001";
|
||||
|
||||
var scannerEvidence = CreateScannerEvidence(subjectNodeId, "CVE-2024-0001");
|
||||
var reachabilityEvidence = CreateReachabilityEvidence(subjectNodeId);
|
||||
var policyEvidence = CreatePolicyEvidence(subjectNodeId);
|
||||
var vexEvidence = CreateVexEvidence(subjectNodeId);
|
||||
var provenanceEvidence = CreateProvenanceEvidence(subjectNodeId);
|
||||
|
||||
await _store.StoreAsync(scannerEvidence);
|
||||
await _store.StoreAsync(reachabilityEvidence);
|
||||
await _store.StoreAsync(policyEvidence);
|
||||
await _store.StoreAsync(vexEvidence);
|
||||
await _store.StoreAsync(provenanceEvidence);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SameSubject_FilterByType_ReturnsCorrectEvidenceAsync()
|
||||
{
|
||||
var subjectNodeId = "sha256:subject-002";
|
||||
|
||||
await _store.StoreAsync(CreateScannerEvidence(subjectNodeId, "CVE-2024-0001"));
|
||||
await _store.StoreAsync(CreateScannerEvidence(subjectNodeId, "CVE-2024-0002"));
|
||||
await _store.StoreAsync(CreateReachabilityEvidence(subjectNodeId));
|
||||
await _store.StoreAsync(CreatePolicyEvidence(subjectNodeId));
|
||||
|
||||
var scanEvidence = await _store.GetBySubjectAsync(subjectNodeId, EvidenceType.Scan);
|
||||
var policyEvidence = await _store.GetBySubjectAsync(subjectNodeId, EvidenceType.Policy);
|
||||
|
||||
scanEvidence.Should().HaveCount(2);
|
||||
policyEvidence.Should().HaveCount(1);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,15 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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.Persistence.Postgres;
|
||||
using StellaOps.Evidence.Persistence.Postgres.Tests.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Evidence.Persistence.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
|
||||
public sealed partial class CrossModuleEvidenceLinkingTests : IAsyncLifetime
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private readonly EvidencePostgresContainerFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
private PostgresEvidenceStore _store = null!;
|
||||
|
||||
public CrossModuleEvidenceLinkingTests(
|
||||
@@ -41,7 +22,7 @@ public sealed class CrossModuleEvidenceLinkingTests : IAsyncLifetime
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_store = _fixture.CreateStore(_tenantId);
|
||||
_store = _fixture.CreateStore(TenantId);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -49,503 +30,4 @@ public sealed class CrossModuleEvidenceLinkingTests : IAsyncLifetime
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
#region Multi-Module Evidence for Same Subject
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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})");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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}");
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
// Description: PostgreSQL test fixture for Evidence persistence tests.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Evidence.Persistence.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using System.Reflection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests.Fixtures;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class PostgresEvidenceStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CountBySubjectAsync_MultipleEvidence_ReturnsCorrectCountAsync()
|
||||
{
|
||||
var subjectId = "sha256:count-001";
|
||||
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Scan, "payload-1"));
|
||||
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Reachability, "payload-2"));
|
||||
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Policy, "payload-3"));
|
||||
|
||||
var count = await _store.CountBySubjectAsync(subjectId);
|
||||
|
||||
count.Should().Be(3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CountBySubjectAsync_NoEvidence_ReturnsZeroAsync()
|
||||
{
|
||||
var nonExistentSubject = "sha256:count-002";
|
||||
|
||||
var count = await _store.CountBySubjectAsync(nonExistentSubject);
|
||||
|
||||
count.Should().Be(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class PostgresEvidenceStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ExistingEvidence_ReturnsTrueAsync()
|
||||
{
|
||||
var evidence = CreateTestEvidence("sha256:delete-001", EvidenceType.Scan, "payload-1");
|
||||
await _store.StoreAsync(evidence);
|
||||
|
||||
var deleted = await _store.DeleteAsync(evidence.EvidenceId);
|
||||
|
||||
deleted.Should().BeTrue();
|
||||
|
||||
var retrieved = await _store.GetByIdAsync(evidence.EvidenceId);
|
||||
retrieved.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NonExistingEvidence_ReturnsFalseAsync()
|
||||
{
|
||||
var nonExistentId = "sha256:missing-delete-001";
|
||||
|
||||
var deleted = await _store.DeleteAsync(nonExistentId);
|
||||
|
||||
deleted.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class PostgresEvidenceStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExistsAsync_ExistingEvidence_ReturnsTrueAsync()
|
||||
{
|
||||
var evidence = CreateTestEvidence("sha256:exists-001", EvidenceType.Scan, "payload-1");
|
||||
await _store.StoreAsync(evidence);
|
||||
|
||||
var exists = await _store.ExistsAsync(evidence.SubjectNodeId, evidence.EvidenceType);
|
||||
|
||||
exists.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExistsAsync_NonExistingEvidence_ReturnsFalseAsync()
|
||||
{
|
||||
var evidence = CreateTestEvidence("sha256:exists-002", EvidenceType.Scan, "payload-2");
|
||||
await _store.StoreAsync(evidence);
|
||||
|
||||
var exists = await _store.ExistsAsync(evidence.SubjectNodeId, EvidenceType.License);
|
||||
|
||||
exists.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExistsAsync_NonExistingSubject_ReturnsFalseAsync()
|
||||
{
|
||||
var nonExistentSubject = "sha256:missing-subject-001";
|
||||
|
||||
var exists = await _store.ExistsAsync(nonExistentSubject, EvidenceType.Scan);
|
||||
|
||||
exists.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class PostgresEvidenceStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Factory_CreateStore_ReturnsTenantScopedStore()
|
||||
{
|
||||
var factory = _fixture.CreateStoreFactory();
|
||||
var tenantId1 = "cccccccc-cccc-cccc-cccc-cccccccccccc";
|
||||
var tenantId2 = "dddddddd-dddd-dddd-dddd-dddddddddddd";
|
||||
|
||||
var store1 = factory.Create(tenantId1);
|
||||
var store2 = factory.Create(tenantId2);
|
||||
|
||||
store1.Should().NotBeNull();
|
||||
store2.Should().NotBeNull();
|
||||
store1.Should().NotBeSameAs(store2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class PostgresEvidenceStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ExistingEvidence_ReturnsEvidenceAsync()
|
||||
{
|
||||
var evidence = CreateTestEvidence("sha256:byid-001", EvidenceType.Scan, "payload-001");
|
||||
await _store.StoreAsync(evidence);
|
||||
|
||||
var retrieved = await _store.GetByIdAsync(evidence.EvidenceId);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_NonExistingEvidence_ReturnsNullAsync()
|
||||
{
|
||||
var nonExistentId = "sha256:missing-001";
|
||||
|
||||
var retrieved = await _store.GetByIdAsync(nonExistentId);
|
||||
|
||||
retrieved.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WithSignatures_PreservesSignaturesAsync()
|
||||
{
|
||||
var evidence = CreateTestEvidenceWithSignatures("sha256:signed-001");
|
||||
await _store.StoreAsync(evidence);
|
||||
|
||||
var retrieved = await _store.GetByIdAsync(evidence.EvidenceId);
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.Signatures.Should().HaveCount(2);
|
||||
retrieved.Signatures[0].SignerId.Should().Be("signer-1");
|
||||
retrieved.Signatures[1].SignerId.Should().Be("signer-2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class PostgresEvidenceStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetBySubjectAsync_MultipleEvidence_ReturnsAllAsync()
|
||||
{
|
||||
var subjectId = "sha256:subject-001";
|
||||
var records = new[]
|
||||
{
|
||||
CreateTestEvidence(subjectId, EvidenceType.Scan, "payload-1"),
|
||||
CreateTestEvidence(subjectId, EvidenceType.Reachability, "payload-2"),
|
||||
CreateTestEvidence(subjectId, EvidenceType.Policy, "payload-3")
|
||||
};
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
await _store.StoreAsync(record);
|
||||
}
|
||||
|
||||
var retrieved = await _store.GetBySubjectAsync(subjectId);
|
||||
|
||||
retrieved.Should().HaveCount(3);
|
||||
retrieved.Select(e => e.EvidenceType).Should()
|
||||
.Contain(new[] { EvidenceType.Scan, EvidenceType.Reachability, EvidenceType.Policy });
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetBySubjectAsync_WithTypeFilter_ReturnsFilteredAsync()
|
||||
{
|
||||
var subjectId = "sha256:subject-002";
|
||||
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Scan, "payload-1"));
|
||||
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Reachability, "payload-2"));
|
||||
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Policy, "payload-3"));
|
||||
|
||||
var retrieved = await _store.GetBySubjectAsync(subjectId, EvidenceType.Scan);
|
||||
|
||||
retrieved.Should().HaveCount(1);
|
||||
retrieved[0].EvidenceType.Should().Be(EvidenceType.Scan);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetBySubjectAsync_NoEvidence_ReturnsEmptyListAsync()
|
||||
{
|
||||
var nonExistentSubject = "sha256:subject-003";
|
||||
|
||||
var retrieved = await _store.GetBySubjectAsync(nonExistentSubject);
|
||||
|
||||
retrieved.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class PostgresEvidenceStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByTypeAsync_MultipleEvidence_ReturnsMatchingTypeAsync()
|
||||
{
|
||||
await _store.StoreAsync(CreateTestEvidence("sha256:type-001", EvidenceType.Scan, "payload-1"));
|
||||
await _store.StoreAsync(CreateTestEvidence("sha256:type-002", EvidenceType.Scan, "payload-2"));
|
||||
await _store.StoreAsync(CreateTestEvidence("sha256:type-003", EvidenceType.Reachability, "payload-3"));
|
||||
|
||||
var retrieved = await _store.GetByTypeAsync(EvidenceType.Scan);
|
||||
|
||||
retrieved.Should().HaveCount(2);
|
||||
retrieved.Should().OnlyContain(e => e.EvidenceType == EvidenceType.Scan);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByTypeAsync_WithLimit_RespectsLimitAsync()
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var subjectId = $"sha256:vex-{i:D2}";
|
||||
await _store.StoreAsync(CreateTestEvidence(subjectId, EvidenceType.Vex, $"payload-{i:D2}"));
|
||||
}
|
||||
|
||||
var retrieved = await _store.GetByTypeAsync(EvidenceType.Vex, limit: 5);
|
||||
|
||||
retrieved.Should().HaveCount(5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using StellaOps.Evidence.Core;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class PostgresEvidenceStoreIntegrationTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow =
|
||||
new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static EvidenceRecord CreateTestEvidence(
|
||||
string subjectNodeId,
|
||||
EvidenceType evidenceType,
|
||||
string payloadId,
|
||||
DateTimeOffset? generatedAt = null)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes($"{{\"test\": \"{payloadId}\"}}");
|
||||
var provenance = new EvidenceProvenance
|
||||
{
|
||||
GeneratorId = "test/scanner",
|
||||
GeneratorVersion = "1.0.0",
|
||||
GeneratedAt = generatedAt ?? FixedNow,
|
||||
CorrelationId = "corr-test",
|
||||
Environment = "test"
|
||||
};
|
||||
|
||||
return EvidenceRecord.Create(
|
||||
subjectNodeId,
|
||||
evidenceType,
|
||||
payload,
|
||||
provenance,
|
||||
"1.0.0");
|
||||
}
|
||||
|
||||
private static EvidenceRecord CreateTestEvidenceWithSignatures(string subjectNodeId)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{\"signed\": true}");
|
||||
var provenance = new EvidenceProvenance
|
||||
{
|
||||
GeneratorId = "test/signer",
|
||||
GeneratorVersion = "1.0.0",
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
|
||||
var signatures = new List<EvidenceSignature>
|
||||
{
|
||||
new()
|
||||
{
|
||||
SignerId = "signer-1",
|
||||
Algorithm = "ES256",
|
||||
SignatureBase64 = Convert.ToBase64String(new byte[] { 1, 2, 3 }),
|
||||
SignedAt = FixedNow.AddMinutes(1),
|
||||
SignerType = SignerType.Internal
|
||||
},
|
||||
new()
|
||||
{
|
||||
SignerId = "signer-2",
|
||||
Algorithm = "RS256",
|
||||
SignatureBase64 = Convert.ToBase64String(new byte[] { 4, 5, 6 }),
|
||||
SignedAt = FixedNow.AddMinutes(2),
|
||||
SignerType = SignerType.CI
|
||||
}
|
||||
};
|
||||
|
||||
return EvidenceRecord.Create(
|
||||
subjectNodeId,
|
||||
EvidenceType.Provenance,
|
||||
payload,
|
||||
provenance,
|
||||
"1.0.0",
|
||||
signatures);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class PostgresEvidenceStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RoundTrip_EvidenceRecord_PreservesIntegrityAsync()
|
||||
{
|
||||
var evidence = CreateTestEvidence("sha256:integrity-001", EvidenceType.Scan, "payload-integrity");
|
||||
await _store.StoreAsync(evidence);
|
||||
|
||||
var retrieved = await _store.GetByIdAsync(evidence.EvidenceId) as EvidenceRecord;
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.VerifyIntegrity().Should().BeTrue("evidence ID should match computed hash");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RoundTrip_BinaryPayload_PreservesDataAsync()
|
||||
{
|
||||
var binaryPayload = new byte[] { 0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD };
|
||||
var provenance = new EvidenceProvenance
|
||||
{
|
||||
GeneratorId = "test/binary",
|
||||
GeneratorVersion = "1.0.0",
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
var evidence = EvidenceRecord.Create(
|
||||
"sha256:binary-001",
|
||||
EvidenceType.Artifact,
|
||||
binaryPayload,
|
||||
provenance,
|
||||
"1.0.0");
|
||||
|
||||
await _store.StoreAsync(evidence);
|
||||
|
||||
var retrieved = await _store.GetByIdAsync(evidence.EvidenceId);
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.Payload.ToArray().Should().BeEquivalentTo(binaryPayload);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RoundTrip_UnicodePayload_PreservesDataAsync()
|
||||
{
|
||||
var unicodeJson = "{\"message\": \"Hello 世界 🌍 مرحبا\"}";
|
||||
var payload = Encoding.UTF8.GetBytes(unicodeJson);
|
||||
var provenance = new EvidenceProvenance
|
||||
{
|
||||
GeneratorId = "test/unicode",
|
||||
GeneratorVersion = "1.0.0",
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
var evidence = EvidenceRecord.Create(
|
||||
"sha256:unicode-001",
|
||||
EvidenceType.Custom,
|
||||
payload,
|
||||
provenance,
|
||||
"1.0.0");
|
||||
|
||||
await _store.StoreAsync(evidence);
|
||||
|
||||
var retrieved = await _store.GetByIdAsync(evidence.EvidenceId);
|
||||
|
||||
retrieved.Should().NotBeNull();
|
||||
var retrievedJson = Encoding.UTF8.GetString(retrieved!.Payload.Span);
|
||||
retrievedJson.Should().Be(unicodeJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Evidence.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Evidence.Persistence.Postgres.Tests;
|
||||
|
||||
public sealed partial class PostgresEvidenceStoreIntegrationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StoreAsync_NewEvidence_ReturnsEvidenceIdAsync()
|
||||
{
|
||||
var evidence = CreateTestEvidence("sha256:store-001", EvidenceType.Scan, "payload-001");
|
||||
|
||||
var storedId = await _store.StoreAsync(evidence);
|
||||
|
||||
storedId.Should().Be(evidence.EvidenceId);
|
||||
_output.WriteLine($"Stored evidence: {storedId}");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StoreAsync_DuplicateEvidence_IsIdempotentAsync()
|
||||
{
|
||||
var evidence = CreateTestEvidence("sha256:store-dup-001", EvidenceType.Scan, "payload-dup");
|
||||
|
||||
var firstId = await _store.StoreAsync(evidence);
|
||||
var secondId = await _store.StoreAsync(evidence);
|
||||
|
||||
firstId.Should().Be(evidence.EvidenceId);
|
||||
secondId.Should().Be(evidence.EvidenceId);
|
||||
|
||||
var count = await _store.CountBySubjectAsync(evidence.SubjectNodeId);
|
||||
count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StoreBatchAsync_MultipleRecords_StoresAllSuccessfullyAsync()
|
||||
{
|
||||
var subjectId = "sha256:batch-001";
|
||||
var records = new[]
|
||||
{
|
||||
CreateTestEvidence(subjectId, EvidenceType.Scan, "payload-1"),
|
||||
CreateTestEvidence(subjectId, EvidenceType.Reachability, "payload-2"),
|
||||
CreateTestEvidence(subjectId, EvidenceType.Policy, "payload-3"),
|
||||
CreateTestEvidence(subjectId, EvidenceType.Vex, "payload-4"),
|
||||
CreateTestEvidence(subjectId, EvidenceType.Provenance, "payload-5")
|
||||
};
|
||||
|
||||
var storedCount = await _store.StoreBatchAsync(records);
|
||||
|
||||
storedCount.Should().Be(5);
|
||||
var count = await _store.CountBySubjectAsync(subjectId);
|
||||
count.Should().Be(5);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StoreBatchAsync_WithDuplicates_StoresOnlyUniqueAsync()
|
||||
{
|
||||
var evidence = CreateTestEvidence("sha256:batch-dup-001", EvidenceType.Scan, "payload-dup");
|
||||
var records = new[] { evidence, evidence, evidence };
|
||||
|
||||
var storedCount = await _store.StoreBatchAsync(records);
|
||||
|
||||
storedCount.Should().Be(1);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,15 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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.Persistence.Postgres;
|
||||
using StellaOps.Evidence.Persistence.Postgres.Tests.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Evidence.Persistence.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
|
||||
public sealed partial class PostgresEvidenceStoreIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private const string TenantId = "22222222-2222-2222-2222-222222222222";
|
||||
private readonly EvidencePostgresContainerFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
private PostgresEvidenceStore _store = null!;
|
||||
|
||||
public PostgresEvidenceStoreIntegrationTests(
|
||||
@@ -36,7 +22,7 @@ public sealed class PostgresEvidenceStoreIntegrationTests : IAsyncLifetime
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_store = _fixture.CreateStore(_tenantId);
|
||||
_store = _fixture.CreateStore(TenantId);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -44,513 +30,4 @@ public sealed class PostgresEvidenceStoreIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
#region Store Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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}");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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 });
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0285-T | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0285-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-2026-02-03 | DONE | Split integration tests into partials, normalized async naming, deterministic fixtures; tests passed 2026-02-03. |
|
||||
|
||||
Reference in New Issue
Block a user