325 lines
9.8 KiB
C#
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"));
|
|
}
|
|
}
|
|
|
|
|
|
|