Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Triage.Tests/TriageSchemaIntegrationTests.cs

325 lines
9.8 KiB
C#

using Microsoft.EntityFrameworkCore;
using StellaOps.Scanner.Triage.Entities;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Triage.Tests;
/// <summary>
/// Integration tests for the Triage schema using Testcontainers.
/// </summary>
[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<TriageDbContext>()
.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<DbUpdateException>(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<string>(
"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"));
}
}