feat: add bulk triage view component and related stories

- Exported BulkTriageViewComponent and its related types from findings module.
- Created a new accessibility test suite for score components using axe-core.
- Introduced design tokens for score components to standardize styling.
- Enhanced score breakdown popover for mobile responsiveness with drag handle.
- Added date range selector functionality to score history chart component.
- Implemented unit tests for date range selector in score history chart.
- Created Storybook stories for bulk triage view and score history chart with date range selector.
This commit is contained in:
StellaOps Bot
2025-12-26 01:01:35 +02:00
parent ed3079543c
commit 17613acf57
45 changed files with 9418 additions and 64 deletions

View File

@@ -0,0 +1,545 @@
// -----------------------------------------------------------------------------
// FederationE2ETests.cs
// Sprint: SPRINT_8200_0014_0003_CONCEL_bundle_import_merge
// Tasks: IMPORT-8200-024, IMPORT-8200-029, IMPORT-8200-033
// Description: End-to-end tests for federation scenarios
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Concelier.Federation.Compression;
using StellaOps.Concelier.Federation.Import;
using StellaOps.Concelier.Federation.Models;
using StellaOps.Concelier.Federation.Serialization;
using StellaOps.Concelier.Federation.Signing;
using System.Formats.Tar;
using System.Text;
using System.Text.Json;
namespace StellaOps.Concelier.Federation.Tests.Integration;
/// <summary>
/// End-to-end tests for federation scenarios.
/// </summary>
public sealed class FederationE2ETests : IDisposable
{
private readonly List<Stream> _disposableStreams = [];
public void Dispose()
{
foreach (var stream in _disposableStreams)
{
stream.Dispose();
}
}
#region Export to Import Round-Trip Tests (Task 24)
[Fact]
public async Task RoundTrip_ExportBundle_ImportVerifiesState()
{
// This test simulates: export from Site A -> import to Site B -> verify state
// Arrange - Site A exports a bundle
var siteAManifest = new BundleManifest
{
Version = "feedser-bundle/1.0",
SiteId = "site-a",
ExportCursor = "2025-01-15T10:00:00.000Z#0001",
SinceCursor = null,
ExportedAt = DateTimeOffset.Parse("2025-01-15T10:00:00Z"),
BundleHash = "sha256:roundtrip-test",
Counts = new BundleCounts { Canonicals = 3, Edges = 3, Deletions = 1 }
};
var bundleStream = await CreateTestBundleAsync(siteAManifest, 3, 3, 1);
// Act - Site B reads and parses the bundle
using var reader = await BundleReader.ReadAsync(bundleStream);
// Assert - Manifest parsed correctly
reader.Manifest.SiteId.Should().Be("site-a");
reader.Manifest.Counts.Canonicals.Should().Be(3);
// Assert - Content streams correctly
var canonicals = await reader.StreamCanonicalsAsync().ToListAsync();
var edges = await reader.StreamEdgesAsync().ToListAsync();
var deletions = await reader.StreamDeletionsAsync().ToListAsync();
canonicals.Should().HaveCount(3);
edges.Should().HaveCount(3);
deletions.Should().HaveCount(1);
// Verify canonical data integrity
canonicals.All(c => c.Id != Guid.Empty).Should().BeTrue();
canonicals.All(c => c.MergeHash.StartsWith("sha256:")).Should().BeTrue();
canonicals.All(c => c.Status == "active").Should().BeTrue();
}
[Fact]
public async Task RoundTrip_DeltaBundle_OnlyIncludesChanges()
{
// Arrange - Delta bundle with since_cursor
var deltaManifest = new BundleManifest
{
Version = "feedser-bundle/1.0",
SiteId = "site-a",
ExportCursor = "2025-01-15T12:00:00.000Z#0050",
SinceCursor = "2025-01-15T10:00:00.000Z#0001", // Delta since previous cursor
ExportedAt = DateTimeOffset.Parse("2025-01-15T12:00:00Z"),
BundleHash = "sha256:delta-bundle",
Counts = new BundleCounts { Canonicals = 5, Edges = 2, Deletions = 0 }
};
var bundleStream = await CreateTestBundleAsync(deltaManifest, 5, 2, 0);
// Act
using var reader = await BundleReader.ReadAsync(bundleStream);
// Assert - Delta bundle has since_cursor
reader.Manifest.SinceCursor.Should().Be("2025-01-15T10:00:00.000Z#0001");
reader.Manifest.ExportCursor.Should().Be("2025-01-15T12:00:00.000Z#0050");
// Delta only has 5 canonicals (changes since cursor)
var canonicals = await reader.StreamCanonicalsAsync().ToListAsync();
canonicals.Should().HaveCount(5);
}
[Fact]
public async Task RoundTrip_VerifyBundle_PassesValidation()
{
// Arrange
var manifest = new BundleManifest
{
Version = "feedser-bundle/1.0",
SiteId = "verified-site",
ExportCursor = "2025-01-15T10:00:00.000Z#0001",
ExportedAt = DateTimeOffset.UtcNow,
BundleHash = "sha256:verified",
Counts = new BundleCounts { Canonicals = 2 }
};
var bundleStream = await CreateTestBundleAsync(manifest, 2, 0, 0);
using var reader = await BundleReader.ReadAsync(bundleStream);
var signerMock = new Mock<IBundleSigner>();
signerMock
.Setup(x => x.VerifyBundleAsync(It.IsAny<string>(), It.IsAny<BundleSignature>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new BundleVerificationResult { IsValid = true, SignerIdentity = "trusted-key" });
var options = Options.Create(new FederationImportOptions());
var verifier = new BundleVerifier(signerMock.Object, options, NullLogger<BundleVerifier>.Instance);
// Act
var result = await verifier.VerifyAsync(reader, skipSignature: true);
// Assert
result.IsValid.Should().BeTrue();
result.Manifest.Should().NotBeNull();
}
#endregion
#region Air-Gap Workflow Tests (Task 29)
[Fact]
public async Task AirGap_ExportToFile_ImportFromFile_Succeeds()
{
// This simulates: export to file -> transfer (air-gap) -> import from file
// Arrange - Create bundle
var manifest = new BundleManifest
{
Version = "feedser-bundle/1.0",
SiteId = "airgap-source",
ExportCursor = "2025-01-15T10:00:00.000Z#0001",
ExportedAt = DateTimeOffset.UtcNow,
BundleHash = "sha256:airgap-bundle",
Counts = new BundleCounts { Canonicals = 10, Edges = 15, Deletions = 2 }
};
var bundleStream = await CreateTestBundleAsync(manifest, 10, 15, 2);
// Simulate writing to file (in memory for test)
var fileBuffer = new MemoryStream();
bundleStream.Position = 0;
await bundleStream.CopyToAsync(fileBuffer);
fileBuffer.Position = 0;
// Act - "Transfer" and read from file
using var reader = await BundleReader.ReadAsync(fileBuffer);
// Assert - All data survives air-gap transfer
reader.Manifest.SiteId.Should().Be("airgap-source");
var canonicals = await reader.StreamCanonicalsAsync().ToListAsync();
var edges = await reader.StreamEdgesAsync().ToListAsync();
var deletions = await reader.StreamDeletionsAsync().ToListAsync();
canonicals.Should().HaveCount(10);
edges.Should().HaveCount(15);
deletions.Should().HaveCount(2);
}
[Fact]
public async Task AirGap_LargeBundle_StreamsEfficiently()
{
// Arrange - Large bundle
var manifest = new BundleManifest
{
Version = "feedser-bundle/1.0",
SiteId = "large-site",
ExportCursor = "2025-01-15T10:00:00.000Z#0100",
ExportedAt = DateTimeOffset.UtcNow,
BundleHash = "sha256:large-bundle",
Counts = new BundleCounts { Canonicals = 100, Edges = 200, Deletions = 10 }
};
var bundleStream = await CreateTestBundleAsync(manifest, 100, 200, 10);
// Act - Stream and count items
using var reader = await BundleReader.ReadAsync(bundleStream);
var canonicalCount = 0;
await foreach (var _ in reader.StreamCanonicalsAsync())
{
canonicalCount++;
}
var edgeCount = 0;
await foreach (var _ in reader.StreamEdgesAsync())
{
edgeCount++;
}
// Assert - All items streamed
canonicalCount.Should().Be(100);
edgeCount.Should().Be(200);
}
[Fact]
public async Task AirGap_BundleWithAllEntryTypes_HasAllFiles()
{
// Arrange
var manifest = new BundleManifest
{
Version = "feedser-bundle/1.0",
SiteId = "complete-site",
ExportCursor = "cursor",
ExportedAt = DateTimeOffset.UtcNow,
BundleHash = "sha256:complete",
Counts = new BundleCounts { Canonicals = 1, Edges = 1, Deletions = 1 }
};
var bundleStream = await CreateTestBundleAsync(manifest, 1, 1, 1);
// Act
using var reader = await BundleReader.ReadAsync(bundleStream);
var entries = await reader.GetEntryNamesAsync();
// Assert - All expected files present
entries.Should().Contain("MANIFEST.json");
entries.Should().Contain("canonicals.ndjson");
entries.Should().Contain("edges.ndjson");
entries.Should().Contain("deletions.ndjson");
}
#endregion
#region Multi-Site Federation Tests (Task 33)
[Fact]
public async Task MultiSite_DifferentSiteIds_ParsedCorrectly()
{
// Arrange - Bundles from different sites
var siteAManifest = new BundleManifest
{
Version = "feedser-bundle/1.0",
SiteId = "us-west-1",
ExportCursor = "2025-01-15T10:00:00.000Z#0001",
ExportedAt = DateTimeOffset.UtcNow,
BundleHash = "sha256:site-a",
Counts = new BundleCounts { Canonicals = 5 }
};
var siteBManifest = new BundleManifest
{
Version = "feedser-bundle/1.0",
SiteId = "eu-central-1",
ExportCursor = "2025-01-15T11:00:00.000Z#0002",
ExportedAt = DateTimeOffset.UtcNow,
BundleHash = "sha256:site-b",
Counts = new BundleCounts { Canonicals = 8 }
};
var bundleA = await CreateTestBundleAsync(siteAManifest, 5, 0, 0);
var bundleB = await CreateTestBundleAsync(siteBManifest, 8, 0, 0);
// Act
using var readerA = await BundleReader.ReadAsync(bundleA);
using var readerB = await BundleReader.ReadAsync(bundleB);
// Assert - Each site has distinct data
readerA.Manifest.SiteId.Should().Be("us-west-1");
readerB.Manifest.SiteId.Should().Be("eu-central-1");
var canonicalsA = await readerA.StreamCanonicalsAsync().ToListAsync();
var canonicalsB = await readerB.StreamCanonicalsAsync().ToListAsync();
canonicalsA.Should().HaveCount(5);
canonicalsB.Should().HaveCount(8);
}
[Fact]
public async Task MultiSite_CursorsAreIndependent()
{
// Arrange - Sites with different cursors
var sites = new[]
{
("site-alpha", "2025-01-15T08:00:00.000Z#0100"),
("site-beta", "2025-01-15T09:00:00.000Z#0050"),
("site-gamma", "2025-01-15T10:00:00.000Z#0200")
};
var readers = new List<BundleReader>();
foreach (var (siteId, cursor) in sites)
{
var manifest = new BundleManifest
{
Version = "feedser-bundle/1.0",
SiteId = siteId,
ExportCursor = cursor,
ExportedAt = DateTimeOffset.UtcNow,
BundleHash = $"sha256:{siteId}",
Counts = new BundleCounts { Canonicals = 1 }
};
var bundle = await CreateTestBundleAsync(manifest, 1, 0, 0);
readers.Add(await BundleReader.ReadAsync(bundle));
}
try
{
// Assert - Each site has independent cursor
readers[0].Manifest.ExportCursor.Should().Contain("#0100");
readers[1].Manifest.ExportCursor.Should().Contain("#0050");
readers[2].Manifest.ExportCursor.Should().Contain("#0200");
}
finally
{
foreach (var reader in readers)
{
reader.Dispose();
}
}
}
[Fact]
public async Task MultiSite_SameMergeHash_DifferentSources()
{
// Arrange - Same vulnerability from different sites with same merge hash
var mergeHash = "sha256:cve-2024-1234-express-4.0.0";
var siteAManifest = new BundleManifest
{
Version = "feedser-bundle/1.0",
SiteId = "primary-site",
ExportCursor = "cursor-a",
ExportedAt = DateTimeOffset.UtcNow,
BundleHash = "sha256:primary",
Counts = new BundleCounts { Canonicals = 1 }
};
// Create bundle with specific merge hash
var bundleA = await CreateTestBundleWithSpecificHashAsync(siteAManifest, mergeHash);
// Act
using var reader = await BundleReader.ReadAsync(bundleA);
var canonicals = await reader.StreamCanonicalsAsync().ToListAsync();
// Assert
canonicals.Should().HaveCount(1);
canonicals[0].MergeHash.Should().Be(mergeHash);
}
[Fact]
public void MultiSite_FederationSiteInfo_TracksPerSiteState()
{
// This tests the data structures for tracking multi-site state
// Arrange
var sites = new List<FederationSiteInfo>
{
new()
{
SiteId = "us-west-1",
DisplayName = "US West",
Enabled = true,
LastCursor = "2025-01-15T10:00:00.000Z#0100",
LastSyncAt = DateTimeOffset.Parse("2025-01-15T10:00:00Z"),
BundlesImported = 42
},
new()
{
SiteId = "eu-central-1",
DisplayName = "EU Central",
Enabled = true,
LastCursor = "2025-01-15T09:00:00.000Z#0050",
LastSyncAt = DateTimeOffset.Parse("2025-01-15T09:00:00Z"),
BundlesImported = 38
},
new()
{
SiteId = "ap-south-1",
DisplayName = "Asia Pacific",
Enabled = false,
LastCursor = null,
LastSyncAt = null,
BundlesImported = 0
}
};
// Assert - Per-site state tracked independently
sites.Should().HaveCount(3);
sites.Count(s => s.Enabled).Should().Be(2);
sites.Sum(s => s.BundlesImported).Should().Be(80);
sites.Single(s => s.SiteId == "ap-south-1").LastCursor.Should().BeNull();
}
#endregion
#region Helper Types
private sealed record FederationSiteInfo
{
public required string SiteId { get; init; }
public string? DisplayName { get; init; }
public bool Enabled { get; init; }
public string? LastCursor { get; init; }
public DateTimeOffset? LastSyncAt { get; init; }
public int BundlesImported { get; init; }
}
#endregion
#region Helper Methods
private async Task<Stream> CreateTestBundleAsync(
BundleManifest manifest,
int canonicalCount,
int edgeCount,
int deletionCount)
{
var tarBuffer = new MemoryStream();
await using (var tarWriter = new TarWriter(tarBuffer, leaveOpen: true))
{
var manifestJson = JsonSerializer.Serialize(manifest, BundleSerializer.Options);
await WriteEntryAsync(tarWriter, "MANIFEST.json", manifestJson);
var canonicalsNdjson = new StringBuilder();
for (var i = 1; i <= canonicalCount; i++)
{
var canonical = new CanonicalBundleLine
{
Id = Guid.NewGuid(),
Cve = $"CVE-2024-{i:D4}",
AffectsKey = $"pkg:generic/test{i}@1.0",
MergeHash = $"sha256:hash{i}",
Status = "active",
Title = $"Test Advisory {i}",
UpdatedAt = DateTimeOffset.UtcNow
};
canonicalsNdjson.AppendLine(JsonSerializer.Serialize(canonical, BundleSerializer.Options));
}
await WriteEntryAsync(tarWriter, "canonicals.ndjson", canonicalsNdjson.ToString());
var edgesNdjson = new StringBuilder();
for (var i = 1; i <= edgeCount; i++)
{
var edge = new EdgeBundleLine
{
Id = Guid.NewGuid(),
CanonicalId = Guid.NewGuid(),
Source = "nvd",
SourceAdvisoryId = $"CVE-2024-{i:D4}",
ContentHash = $"sha256:edge{i}",
UpdatedAt = DateTimeOffset.UtcNow
};
edgesNdjson.AppendLine(JsonSerializer.Serialize(edge, BundleSerializer.Options));
}
await WriteEntryAsync(tarWriter, "edges.ndjson", edgesNdjson.ToString());
var deletionsNdjson = new StringBuilder();
for (var i = 1; i <= deletionCount; i++)
{
var deletion = new DeletionBundleLine
{
CanonicalId = Guid.NewGuid(),
Reason = "rejected",
DeletedAt = DateTimeOffset.UtcNow
};
deletionsNdjson.AppendLine(JsonSerializer.Serialize(deletion, BundleSerializer.Options));
}
await WriteEntryAsync(tarWriter, "deletions.ndjson", deletionsNdjson.ToString());
}
tarBuffer.Position = 0;
var compressedBuffer = new MemoryStream();
await ZstdCompression.CompressAsync(tarBuffer, compressedBuffer);
compressedBuffer.Position = 0;
_disposableStreams.Add(compressedBuffer);
return compressedBuffer;
}
private async Task<Stream> CreateTestBundleWithSpecificHashAsync(
BundleManifest manifest,
string mergeHash)
{
var tarBuffer = new MemoryStream();
await using (var tarWriter = new TarWriter(tarBuffer, leaveOpen: true))
{
var manifestJson = JsonSerializer.Serialize(manifest, BundleSerializer.Options);
await WriteEntryAsync(tarWriter, "MANIFEST.json", manifestJson);
var canonical = new CanonicalBundleLine
{
Id = Guid.NewGuid(),
Cve = "CVE-2024-1234",
AffectsKey = "pkg:npm/express@4.0.0",
MergeHash = mergeHash,
Status = "active",
Title = "Express vulnerability",
UpdatedAt = DateTimeOffset.UtcNow
};
var canonicalsNdjson = JsonSerializer.Serialize(canonical, BundleSerializer.Options) + "\n";
await WriteEntryAsync(tarWriter, "canonicals.ndjson", canonicalsNdjson);
await WriteEntryAsync(tarWriter, "edges.ndjson", "");
await WriteEntryAsync(tarWriter, "deletions.ndjson", "");
}
tarBuffer.Position = 0;
var compressedBuffer = new MemoryStream();
await ZstdCompression.CompressAsync(tarBuffer, compressedBuffer);
compressedBuffer.Position = 0;
_disposableStreams.Add(compressedBuffer);
return compressedBuffer;
}
private static async Task WriteEntryAsync(TarWriter tarWriter, string name, string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var entry = new PaxTarEntry(TarEntryType.RegularFile, name)
{
DataStream = new MemoryStream(bytes)
};
await tarWriter.WriteEntryAsync(entry);
}
#endregion
}