using Microsoft.EntityFrameworkCore; using StellaOps.Scanner.Triage.Entities; using StellaOps.TestKit; using Xunit; namespace StellaOps.Scanner.Triage.Tests; /// /// Integration tests for the Triage schema using Testcontainers. /// [Collection("triage-postgres")] public sealed class TriageSchemaIntegrationTests : IAsyncLifetime { private readonly TriagePostgresFixture _fixture; private TriageDbContext? _context; public TriageSchemaIntegrationTests(TriagePostgresFixture fixture) { _fixture = fixture; } public ValueTask InitializeAsync() { var optionsBuilder = new DbContextOptionsBuilder() .UseNpgsql(_fixture.ConnectionString); _context = new TriageDbContext(optionsBuilder.Options); return ValueTask.CompletedTask; } public async ValueTask DisposeAsync() { if (_context != null) { await _context.DisposeAsync(); } } private TriageDbContext Context => _context ?? throw new InvalidOperationException("Context not initialized"); [Trait("Category", TestCategories.Unit)] [Fact] public async Task Schema_Creates_Successfully() { // Arrange / Act await Context.Database.EnsureCreatedAsync(); // Assert - verify tables exist by querying the metadata var findingsCount = await Context.Findings.CountAsync(); var decisionsCount = await Context.Decisions.CountAsync(); Assert.Equal(0, findingsCount); Assert.Equal(0, decisionsCount); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Can_Create_And_Query_TriageFinding() { // Arrange await Context.Database.EnsureCreatedAsync(); var now = DateTimeOffset.UtcNow; var finding = new TriageFinding { Id = Guid.NewGuid(), AssetId = Guid.NewGuid(), AssetLabel = "prod/api-gateway:1.2.3", Purl = "pkg:npm/lodash@4.17.20", CveId = "CVE-2021-23337", FirstSeenAt = now, LastSeenAt = now, UpdatedAt = now }; // Act Context.Findings.Add(finding); await Context.SaveChangesAsync(); // Assert var retrieved = await Context.Findings.FirstOrDefaultAsync(f => f.Id == finding.Id); Assert.NotNull(retrieved); Assert.Equal(finding.AssetLabel, retrieved.AssetLabel); Assert.Equal(finding.Purl, retrieved.Purl); Assert.Equal(finding.CveId, retrieved.CveId); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Can_Create_TriageDecision_With_Finding() { // Arrange await Context.Database.EnsureCreatedAsync(); var now = DateTimeOffset.UtcNow; var finding = new TriageFinding { Id = Guid.NewGuid(), AssetId = Guid.NewGuid(), AssetLabel = "prod/api-gateway:1.2.3", Purl = "pkg:npm/lodash@4.17.20", CveId = "CVE-2021-23337", FirstSeenAt = now, LastSeenAt = now, UpdatedAt = now }; Context.Findings.Add(finding); await Context.SaveChangesAsync(); var decision = new TriageDecision { Id = Guid.NewGuid(), FindingId = finding.Id, Kind = TriageDecisionKind.MuteReach, ReasonCode = "NOT_REACHABLE", Note = "Code path is not reachable per RichGraph analysis", ActorSubject = "user:test@example.com", ActorDisplay = "Test User", CreatedAt = now }; // Act Context.Decisions.Add(decision); await Context.SaveChangesAsync(); // Assert var retrieved = await Context.Decisions .Include(d => d.Finding) .FirstOrDefaultAsync(d => d.Id == decision.Id); Assert.NotNull(retrieved); Assert.Equal(TriageDecisionKind.MuteReach, retrieved.Kind); Assert.Equal("NOT_REACHABLE", retrieved.ReasonCode); Assert.NotNull(retrieved.Finding); Assert.Equal(finding.Purl, retrieved.Finding!.Purl); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Can_Create_TriageRiskResult_With_Finding() { // Arrange await Context.Database.EnsureCreatedAsync(); var now = DateTimeOffset.UtcNow; var finding = new TriageFinding { Id = Guid.NewGuid(), AssetId = Guid.NewGuid(), AssetLabel = "prod/api-gateway:1.2.3", Purl = "pkg:npm/lodash@4.17.20", CveId = "CVE-2021-23337", FirstSeenAt = now, LastSeenAt = now, UpdatedAt = now }; Context.Findings.Add(finding); await Context.SaveChangesAsync(); var riskResult = new TriageRiskResult { Id = Guid.NewGuid(), FindingId = finding.Id, PolicyId = "security-policy-v1", PolicyVersion = "1.0.0", InputsHash = "abc123def456", Score = 75, Verdict = TriageVerdict.Block, Lane = TriageLane.Blocked, Why = "High-severity CVE with network exposure", ComputedAt = now }; // Act Context.RiskResults.Add(riskResult); await Context.SaveChangesAsync(); // Assert var retrieved = await Context.RiskResults .Include(r => r.Finding) .FirstOrDefaultAsync(r => r.Id == riskResult.Id); Assert.NotNull(retrieved); Assert.Equal(75, retrieved.Score); Assert.Equal(TriageVerdict.Block, retrieved.Verdict); Assert.Equal(TriageLane.Blocked, retrieved.Lane); Assert.NotNull(retrieved.Finding); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Finding_Cascade_Deletes_Related_Entities() { // Arrange await Context.Database.EnsureCreatedAsync(); var now = DateTimeOffset.UtcNow; var finding = new TriageFinding { Id = Guid.NewGuid(), AssetId = Guid.NewGuid(), AssetLabel = "prod/api:1.0", Purl = "pkg:npm/test@1.0.0", CveId = "CVE-2024-0001", FirstSeenAt = now, LastSeenAt = now, UpdatedAt = now }; Context.Findings.Add(finding); await Context.SaveChangesAsync(); var decision = new TriageDecision { Id = Guid.NewGuid(), FindingId = finding.Id, Kind = TriageDecisionKind.Ack, ReasonCode = "ACKNOWLEDGED", ActorSubject = "user:admin", CreatedAt = now }; var riskResult = new TriageRiskResult { Id = Guid.NewGuid(), FindingId = finding.Id, PolicyId = "policy-v1", PolicyVersion = "1.0", InputsHash = "hash123", Score = 50, Why = "Medium risk", ComputedAt = now }; Context.Decisions.Add(decision); Context.RiskResults.Add(riskResult); await Context.SaveChangesAsync(); // Verify entities exist Assert.Single(await Context.Decisions.Where(d => d.FindingId == finding.Id).ToListAsync()); Assert.Single(await Context.RiskResults.Where(r => r.FindingId == finding.Id).ToListAsync()); // Act - delete the finding Context.Findings.Remove(finding); await Context.SaveChangesAsync(); // Assert - related entities should be cascade deleted Assert.Empty(await Context.Decisions.Where(d => d.FindingId == finding.Id).ToListAsync()); Assert.Empty(await Context.RiskResults.Where(r => r.FindingId == finding.Id).ToListAsync()); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Unique_Constraint_Prevents_Duplicate_Findings() { // Arrange await Context.Database.EnsureCreatedAsync(); var assetId = Guid.NewGuid(); var envId = Guid.NewGuid(); const string purl = "pkg:npm/lodash@4.17.20"; const string cveId = "CVE-2021-23337"; var now = DateTimeOffset.UtcNow; var finding1 = new TriageFinding { Id = Guid.NewGuid(), AssetId = assetId, EnvironmentId = envId, AssetLabel = "prod/api:1.0", Purl = purl, CveId = cveId, FirstSeenAt = now, LastSeenAt = now, UpdatedAt = now }; Context.Findings.Add(finding1); await Context.SaveChangesAsync(); var finding2 = new TriageFinding { Id = Guid.NewGuid(), AssetId = assetId, EnvironmentId = envId, AssetLabel = "prod/api:1.0", Purl = purl, CveId = cveId, FirstSeenAt = now, LastSeenAt = now, UpdatedAt = now }; Context.Findings.Add(finding2); // Act & Assert - should throw due to unique constraint await Assert.ThrowsAsync(async () => { await Context.SaveChangesAsync(); }); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task Indexes_Exist_For_Performance() { // Arrange await Context.Database.EnsureCreatedAsync(); // Act - query for indexes on triage_finding table var indexes = await Context.Database.SqlQueryRaw( "SELECT indexname FROM pg_indexes WHERE tablename = 'triage_finding'") .ToListAsync(); // Assert - verify expected indexes exist Assert.Contains(indexes, i => i.Contains("last_seen")); Assert.Contains(indexes, i => i.Contains("purl")); } }