Add Canonical JSON serialization library with tests and documentation
- Implemented CanonJson class for deterministic JSON serialization and hashing. - Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters. - Created project files for the Canonical JSON library and its tests, including necessary package references. - Added README.md for library usage and API reference. - Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
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 Task InitializeAsync()
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<TriageDbContext>()
|
||||
.UseNpgsql(_fixture.ConnectionString);
|
||||
|
||||
_context = new TriageDbContext(optionsBuilder.Options);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_context != null)
|
||||
{
|
||||
await _context.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private TriageDbContext Context => _context ?? throw new InvalidOperationException("Context not initialized");
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_Create_And_Query_TriageFinding()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
|
||||
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 = DateTimeOffset.UtcNow,
|
||||
LastSeenAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_Create_TriageDecision_With_Finding()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
|
||||
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"
|
||||
};
|
||||
|
||||
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 = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_Create_TriageRiskResult_With_Finding()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
|
||||
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"
|
||||
};
|
||||
|
||||
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 = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Finding_Cascade_Deletes_Related_Entities()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
|
||||
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"
|
||||
};
|
||||
|
||||
Context.Findings.Add(finding);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
var decision = new TriageDecision
|
||||
{
|
||||
FindingId = finding.Id,
|
||||
Kind = TriageDecisionKind.Ack,
|
||||
ReasonCode = "ACKNOWLEDGED",
|
||||
ActorSubject = "user:admin"
|
||||
};
|
||||
|
||||
var riskResult = new TriageRiskResult
|
||||
{
|
||||
FindingId = finding.Id,
|
||||
PolicyId = "policy-v1",
|
||||
PolicyVersion = "1.0",
|
||||
InputsHash = "hash123",
|
||||
Score = 50,
|
||||
Why = "Medium risk"
|
||||
};
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
[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 finding1 = new TriageFinding
|
||||
{
|
||||
AssetId = assetId,
|
||||
EnvironmentId = envId,
|
||||
AssetLabel = "prod/api:1.0",
|
||||
Purl = purl,
|
||||
CveId = cveId
|
||||
};
|
||||
|
||||
Context.Findings.Add(finding1);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
var finding2 = new TriageFinding
|
||||
{
|
||||
AssetId = assetId,
|
||||
EnvironmentId = envId,
|
||||
AssetLabel = "prod/api:1.0",
|
||||
Purl = purl,
|
||||
CveId = cveId
|
||||
};
|
||||
|
||||
Context.Findings.Add(finding2);
|
||||
|
||||
// Act & Assert - should throw due to unique constraint
|
||||
await Assert.ThrowsAsync<DbUpdateException>(async () =>
|
||||
{
|
||||
await Context.SaveChangesAsync();
|
||||
});
|
||||
}
|
||||
|
||||
[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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user